diff --git a/.github/instructions/mcp-server-coverage.instructions.md b/.github/instructions/mcp-server-coverage.instructions.md new file mode 100644 index 00000000000..0b7d3fd575d --- /dev/null +++ b/.github/instructions/mcp-server-coverage.instructions.md @@ -0,0 +1,19 @@ +--- +applyTo: "packages/dev/core/src/{FlowGraph,Materials/Node,Meshes/Node,FrameGraph,Particles/Node}/**/*.{ts,tsx},packages/dev/smartFilterBlocks/src/**/*.ts,packages/dev/gui/src/2D/controls/**/*.ts,packages/tools/{flow-graph-mcp-server,nme-mcp-server,nge-mcp-server,nrge-mcp-server,npe-mcp-server,smart-filters-mcp-server,gui-mcp-server}/**/*.{ts,tsx}" +--- + +# MCP Server Coverage + +When adding, removing, or renaming a graph block or GUI control, update the matching MCP server registry or catalog in the same change: + +- Flow Graph blocks: `packages/tools/flow-graph-mcp-server/src/blockRegistry.ts` +- Node Material blocks: `packages/tools/nme-mcp-server/src/blockRegistry.ts` +- Node Geometry blocks: `packages/tools/nge-mcp-server/src/blockRegistry.ts` +- Node Render Graph blocks: `packages/tools/nrge-mcp-server/src/blockRegistry.ts` +- Node Particle blocks: `packages/tools/npe-mcp-server/src/blockRegistry.ts` +- Smart Filters blocks: `packages/tools/smart-filters-mcp-server/src/blockRegistry.ts` +- GUI controls: `packages/tools/gui-mcp-server/src/catalog.ts` + +Keep the MCP metadata aligned with the runtime block/control class name, serialized class name, public inputs, public outputs, configurable properties, and default serialized properties. If a block/control is intentionally omitted because it is abstract, a base class, editor-internal, or non-creatable, leave the omission clear in nearby registry comments or tests. + +After changing coverage, run the affected MCP server tests or build and also rebuild `@babylonjs/mcp-servers` so the public package stays current. diff --git a/package-lock.json b/package-lock.json index 808bd32bd7f..2e172caa205 100644 --- a/package-lock.json +++ b/package-lock.json @@ -510,6 +510,10 @@ "resolved": "packages/public/@babylonjs/materials", "link": true }, + "node_modules/@babylonjs/mcp-servers": { + "resolved": "packages/public/@babylonjs/mcp-servers", + "link": true + }, "node_modules/@babylonjs/node-editor": { "resolved": "packages/public/@babylonjs/node-editor", "link": true @@ -19028,6 +19032,24 @@ "@babylonjs/core": "^9.0.0" } }, + "packages/public/@babylonjs/mcp-servers": { + "version": "9.8.0", + "license": "Apache-2.0", + "bin": { + "babylonjs-flow-graph-mcp-server": "dist/flow-graph-mcp-server.js", + "babylonjs-gui-mcp-server": "dist/gui-mcp-server.js", + "babylonjs-mcp-servers": "dist/babylonjs-mcp-servers.js", + "babylonjs-nge-mcp-server": "dist/nge-mcp-server.js", + "babylonjs-nme-mcp-server": "dist/nme-mcp-server.js", + "babylonjs-npe-mcp-server": "dist/npe-mcp-server.js", + "babylonjs-nrge-mcp-server": "dist/nrge-mcp-server.js", + "babylonjs-smart-filters-mcp-server": "dist/smart-filters-mcp-server.js", + "mcp-servers": "dist/babylonjs-mcp-servers.js" + }, + "engines": { + "node": "^20.19.0 || >=22.13.0 <23.0.0" + } + }, "packages/public/@babylonjs/node-editor": { "version": "9.9.0", "license": "Apache-2.0", diff --git a/package.json b/package.json index 8807e8e5dea..b58e787cc10 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "build:umd:tools": "nx run-many --outputStyle=static --target=build --parallel --maxParallel=2 --projects=babylonjs-inspector-legacy,babylonjs-node-editor,babylonjs-node-geometry-editor,babylonjs-node-render-graph-editor,babylonjs-node-particle-editor,babylonjs-gui-editor,babylonjs-inspector,create-babylonjs", "build:es6": "npm run build:assets:smart-filters && npm run build:es6:libs && npm run build:es6:tools && npm run check:treeshaking-all", "build:es6:libs": "nx run-many --outputStyle=static --target=build --parallel --maxParallel=6 --projects=@babylonjs/core,@babylonjs/gui,@babylonjs/loaders,@babylonjs/materials,@babylonjs/serializers,@babylonjs/post-processes,@babylonjs/procedural-textures,@babylonjs/viewer,@babylonjs/shared-ui-components,@babylonjs/addons,@babylonjs/accessibility,@babylonjs/ktx2decoder,@babylonjs/smart-filters,@babylonjs/smart-filters-blocks,@babylonjs/lottie-player", - "build:es6:tools": "nx run-many --outputStyle=static --target=build --parallel --maxParallel=2 --projects=@babylonjs/node-editor,@babylonjs/node-geometry-editor,@babylonjs/node-render-graph-editor,@babylonjs/node-particle-editor,@babylonjs/inspector-legacy,@babylonjs/gui-editor,@tools/smart-filters-editor-control,@tools/smart-filters-debugger,@tools/smart-filters-editor,@babylonjs/inspector", + "build:es6:tools": "nx run-many --outputStyle=static --target=build --parallel --maxParallel=2 --projects=@babylonjs/node-editor,@babylonjs/node-geometry-editor,@babylonjs/node-render-graph-editor,@babylonjs/node-particle-editor,@babylonjs/inspector-legacy,@babylonjs/gui-editor,@tools/smart-filters-editor-control,@tools/smart-filters-debugger,@tools/smart-filters-editor,@babylonjs/inspector,@babylonjs/mcp-servers", "watch:shaders": "build-tools -c build-shaders --global --watch", "watch:assets": "build-tools -c pa --global --watch", "watch:assets:smart-filters": "npm run build:assets:smart-filters:prerequisites && node ./packages/dev/smartFilters/dist/utils/buildTools/watchShaders.js ./packages/dev/smartFilterBlocks/src/blocks smart-filters core", diff --git a/packages/dev/sharedUiComponents/src/mcp/mcpEditorSessionConnection.ts b/packages/dev/sharedUiComponents/src/mcp/mcpEditorSessionConnection.ts new file mode 100644 index 00000000000..ab3dff8e92f --- /dev/null +++ b/packages/dev/sharedUiComponents/src/mcp/mcpEditorSessionConnection.ts @@ -0,0 +1,99 @@ +const DocumentRoute = "document"; + +/** + * Options used to open an MCP editor session event stream. + */ +export interface IMcpEditorSessionEventSourceOptions { + /** Base session URL, such as `http://localhost:3001/session/`. */ + sessionUrl: string; + /** Called whenever the server sends a document update. */ + onDocument: (document: unknown) => void; + /** Called when the server explicitly closes the session. */ + onSessionClosed: (reason: string) => void; + /** Called when the EventSource reports a connection error. */ + onConnectionError: () => void; +} + +/** + * Normalize a user-provided MCP editor session URL. + * @param sessionUrl - The URL entered by the user or returned by an MCP tool. + * @returns The URL without trailing slash characters. + */ +export function NormalizeMcpEditorSessionUrl(sessionUrl: string): string { + return sessionUrl.replace(/\/+$/, ""); +} + +/** + * Open an EventSource for server-to-editor MCP session updates. + * @param options - Event stream options and callbacks. + * @returns The opened EventSource. Call `CloseMcpEditorSessionEventSource` to disconnect it. + */ +export function OpenMcpEditorSessionEventSource(options: IMcpEditorSessionEventSourceOptions): EventSource { + const normalizedUrl = NormalizeMcpEditorSessionUrl(options.sessionUrl); + const eventSource = new EventSource(`${normalizedUrl}/events`); + + eventSource.onmessage = (event) => { + try { + options.onDocument(JSON.parse(event.data)); + } catch { + // Ignore malformed document updates and keep the live session open. + } + }; + + eventSource.addEventListener("session-closed", (event) => { + options.onSessionClosed(ReadSessionClosedReason(event)); + eventSource.close(); + }); + + eventSource.onerror = () => { + options.onConnectionError(); + eventSource.close(); + }; + + return eventSource; +} + +/** + * Close an MCP editor session EventSource if one is active. + * @param eventSource - EventSource to close. + */ +export function CloseMcpEditorSessionEventSource(eventSource: EventSource | null | undefined): void { + eventSource?.close(); +} + +/** + * Post a document JSON payload to an MCP editor session. + * @param sessionUrl - Base session URL, such as `http://localhost:3001/session/`. + * @param document - Serialized JSON document to send to the MCP server. + * @param legacyDocumentRoute - Optional compatibility route to try when `/document` is unavailable. + * @returns The final fetch response from the standard or compatibility route. + */ +export async function PostMcpEditorSessionDocumentAsync(sessionUrl: string, document: string, legacyDocumentRoute?: string): Promise { + const normalizedUrl = NormalizeMcpEditorSessionUrl(sessionUrl); + const response = await PostDocumentToRouteAsync(normalizedUrl, DocumentRoute, document); + if (response.ok || !legacyDocumentRoute || (response.status !== 404 && response.status !== 405)) { + return response; + } + + return await PostDocumentToRouteAsync(normalizedUrl, legacyDocumentRoute.replace(/^\//, ""), document); +} + +async function PostDocumentToRouteAsync(sessionUrl: string, route: string, document: string): Promise { + const headers = new Headers(); + headers.set("Content-Type", "application/json"); + + return await fetch(`${sessionUrl}/${route}`, { + method: "POST", + headers, + body: document, + }); +} + +function ReadSessionClosedReason(event: Event): string { + try { + const data = JSON.parse((event as MessageEvent).data); + return typeof data.reason === "string" ? data.reason : "Session closed by MCP server"; + } catch { + return "Session closed by MCP server"; + } +} diff --git a/packages/public/@babylonjs/mcp-servers/package.json b/packages/public/@babylonjs/mcp-servers/package.json new file mode 100644 index 00000000000..ff425a976ed --- /dev/null +++ b/packages/public/@babylonjs/mcp-servers/package.json @@ -0,0 +1,48 @@ +{ + "name": "@babylonjs/mcp-servers", + "version": "9.8.0", + "description": "Bundled Model Context Protocol servers for Babylon.js authoring workflows", + "type": "module", + "bin": { + "mcp-servers": "dist/babylonjs-mcp-servers.js", + "babylonjs-mcp-servers": "dist/babylonjs-mcp-servers.js", + "babylonjs-flow-graph-mcp-server": "dist/flow-graph-mcp-server.js", + "babylonjs-gui-mcp-server": "dist/gui-mcp-server.js", + "babylonjs-nge-mcp-server": "dist/nge-mcp-server.js", + "babylonjs-nme-mcp-server": "dist/nme-mcp-server.js", + "babylonjs-npe-mcp-server": "dist/npe-mcp-server.js", + "babylonjs-nrge-mcp-server": "dist/nrge-mcp-server.js", + "babylonjs-smart-filters-mcp-server": "dist/smart-filters-mcp-server.js" + }, + "files": [ + "dist/**/*.*", + "readme.md" + ], + "scripts": { + "build": "npm run clean && npm run build:servers && npm run copy:servers", + "build:servers": "npm run build -w @tools/mcp-server-core && npm run build -w @tools/nme-mcp-server && npm run build -w @tools/npe-mcp-server && npm run build -w @tools/nge-mcp-server && npm run build -w @tools/nrge-mcp-server && npm run build -w @tools/gui-mcp-server && npm run build -w @tools/flow-graph-mcp-server && npm run build -w @tools/smart-filters-mcp-server", + "clean": "rimraf dist", + "copy:servers": "node scripts/copyMcpServers.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.13.0 <23.0.0" + }, + "keywords": [ + "3D", + "javascript", + "html5", + "webgl", + "babylon.js", + "mcp", + "model-context-protocol" + ], + "license": "Apache-2.0", + "homepage": "https://www.babylonjs.com", + "repository": { + "type": "git", + "url": "https://github.com/BabylonJS/Babylon.js.git" + }, + "bugs": { + "url": "https://github.com/BabylonJS/Babylon.js/issues" + } +} diff --git a/packages/public/@babylonjs/mcp-servers/readme.md b/packages/public/@babylonjs/mcp-servers/readme.md new file mode 100644 index 00000000000..a6092abb41f --- /dev/null +++ b/packages/public/@babylonjs/mcp-servers/readme.md @@ -0,0 +1,73 @@ +# Babylon.js MCP Servers + +`@babylonjs/mcp-servers` packages the Babylon.js Model Context Protocol servers as executable Node.js binaries. MCP-compatible clients can use these servers to create, edit, validate, import, export, and live-sync Babylon.js authoring graphs. + +## Included Servers + +| Server | Dispatcher name | Direct binary | Purpose | +| ------------------------ | --------------- | ------------------------------------ | ------------------------------------------------------------------------ | +| Node Material Editor | `nme` | `babylonjs-nme-mcp-server` | Node Material graph authoring and import/export workflows. | +| Node Geometry Editor | `nge` | `babylonjs-nge-mcp-server` | Node Geometry graph authoring and export/import workflows. | +| Node Render Graph Editor | `nrge` | `babylonjs-nrge-mcp-server` | Node Render Graph authoring and render-pipeline export/import workflows. | +| Node Particle Editor | `npe` | `babylonjs-npe-mcp-server` | Node Particle graph authoring and export/import workflows. | +| GUI Editor | `gui` | `babylonjs-gui-mcp-server` | Babylon.js GUI authoring, layout, export/import, and snippet flows. | +| Flow Graph Editor | `flow-graph` | `babylonjs-flow-graph-mcp-server` | Flow Graph authoring and coordinator JSON export/import workflows. | +| Smart Filters Editor | `smart-filters` | `babylonjs-smart-filters-mcp-server` | Smart Filters graph authoring and export/import workflows. | + +## Run With npx + +Use the dispatcher when you want a compact command: + +```sh +npx -y @babylonjs/mcp-servers nme +``` + +Use direct binaries when your MCP client expects a command name: + +```sh +npx -y -p @babylonjs/mcp-servers babylonjs-nme-mcp-server +``` + +## MCP Client Configuration + +Most MCP clients accept a command plus arguments. This example starts the Node Material Editor MCP server through the dispatcher: + +```json +{ + "mcpServers": { + "babylonjs-node-material": { + "command": "npx", + "args": ["-y", "@babylonjs/mcp-servers", "nme"] + } + } +} +``` + +This equivalent form uses the direct binary: + +```json +{ + "mcpServers": { + "babylonjs-node-material": { + "command": "npx", + "args": ["-y", "-p", "@babylonjs/mcp-servers", "babylonjs-nme-mcp-server"] + } + } +} +``` + +## Live Editor Sessions + +The graph MCP servers can start a local editor session server and return a session URL. Paste that URL into the matching Babylon.js editor's MCP session panel to see live updates from the MCP server and to push editor changes back to the MCP server. + +The local editor session server binds to `127.0.0.1` by default. It stops when the MCP process exits, when the `stop_session_server` MCP tool is called, or after 15 minutes without MCP/editor activity. + +## Local Development + +From the Babylon.js repository, build the package with: + +```sh +npm run build -w @babylonjs/mcp-servers +``` + +The build compiles the private MCP server workspaces and copies their bundled `dist/index.js` outputs into this package's `dist/` directory. diff --git a/packages/public/@babylonjs/mcp-servers/scripts/babylonjs-mcp-servers.mjs b/packages/public/@babylonjs/mcp-servers/scripts/babylonjs-mcp-servers.mjs new file mode 100644 index 00000000000..e2acdeb9145 --- /dev/null +++ b/packages/public/@babylonjs/mcp-servers/scripts/babylonjs-mcp-servers.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const serverAliases = new Map([ + ["flow-graph", "flow-graph-mcp-server.js"], + ["flowgraph", "flow-graph-mcp-server.js"], + ["gui", "gui-mcp-server.js"], + ["nge", "nge-mcp-server.js"], + ["node-geometry", "nge-mcp-server.js"], + ["nme", "nme-mcp-server.js"], + ["node-material", "nme-mcp-server.js"], + ["npe", "npe-mcp-server.js"], + ["node-particle", "npe-mcp-server.js"], + ["nrge", "nrge-mcp-server.js"], + ["node-render-graph", "nrge-mcp-server.js"], + ["smart-filters", "smart-filters-mcp-server.js"], + ["sfe", "smart-filters-mcp-server.js"], +]); + +const [, , serverName, ...serverArguments] = process.argv; + +if (!serverName || serverName === "--help" || serverName === "-h") { + printUsage(); + process.exit(serverName ? 0 : 1); +} + +const serverFile = serverAliases.get(serverName.toLowerCase()); +if (!serverFile) { + console.error(`Unknown Babylon.js MCP server "${serverName}".`); + printUsage(); + process.exit(1); +} + +const distDirectory = dirname(fileURLToPath(import.meta.url)); +const child = spawn(process.execPath, [join(distDirectory, serverFile), ...serverArguments], { stdio: "inherit" }); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 0); +}); + +child.on("error", (error) => { + console.error(error.message); + process.exit(1); +}); + +function printUsage() { + console.error(`Usage: babylonjs-mcp-servers + +Servers: + nme Node Material Editor + nge Node Geometry Editor + nrge Node Render Graph Editor + npe Node Particle Editor + gui GUI Editor + flow-graph Flow Graph Editor + smart-filters Smart Filters Editor +`); +} diff --git a/packages/public/@babylonjs/mcp-servers/scripts/copyMcpServers.mjs b/packages/public/@babylonjs/mcp-servers/scripts/copyMcpServers.mjs new file mode 100644 index 00000000000..113fc5376d4 --- /dev/null +++ b/packages/public/@babylonjs/mcp-servers/scripts/copyMcpServers.mjs @@ -0,0 +1,47 @@ +import { chmod, copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const repositoryRoot = join(packageRoot, "../../../.."); +const distDirectory = join(packageRoot, "dist"); + +const servers = [ + { packagePath: "packages/tools/flow-graph-mcp-server", outputName: "flow-graph-mcp-server.js" }, + { packagePath: "packages/tools/gui-mcp-server", outputName: "gui-mcp-server.js" }, + { packagePath: "packages/tools/nge-mcp-server", outputName: "nge-mcp-server.js" }, + { packagePath: "packages/tools/nme-mcp-server", outputName: "nme-mcp-server.js" }, + { packagePath: "packages/tools/npe-mcp-server", outputName: "npe-mcp-server.js" }, + { packagePath: "packages/tools/nrge-mcp-server", outputName: "nrge-mcp-server.js" }, + { packagePath: "packages/tools/smart-filters-mcp-server", outputName: "smart-filters-mcp-server.js" }, +]; + +await mkdir(distDirectory, { recursive: true }); +await copyDispatcherAsync(); + +for (const server of servers) { + await copyServerBundleAsync(server.packagePath, server.outputName); +} + +async function copyDispatcherAsync() { + const dispatcherSource = join(packageRoot, "scripts/babylonjs-mcp-servers.mjs"); + const dispatcherDestination = join(distDirectory, "babylonjs-mcp-servers.js"); + await copyFile(dispatcherSource, dispatcherDestination); + await chmod(dispatcherDestination, 0o755); +} + +async function copyServerBundleAsync(packagePath, outputName) { + const sourceFile = join(repositoryRoot, packagePath, "dist/index.js"); + const sourceMapFile = join(repositoryRoot, packagePath, "dist/index.js.map"); + const destinationFile = join(distDirectory, outputName); + const destinationMapFile = `${destinationFile}.map`; + const destinationMapName = `${outputName}.map`; + + const source = await readFile(sourceFile, "utf8"); + await writeFile(destinationFile, source.replace(/sourceMappingURL=index\.js\.map\s*$/u, `sourceMappingURL=${destinationMapName}`)); + await chmod(destinationFile, 0o755); + + const sourceMap = JSON.parse(await readFile(sourceMapFile, "utf8")); + sourceMap.file = outputName; + await writeFile(destinationMapFile, `${JSON.stringify(sourceMap)}\n`); +} diff --git a/packages/tools/README.md b/packages/tools/README.md new file mode 100644 index 00000000000..7a88880ae7e --- /dev/null +++ b/packages/tools/README.md @@ -0,0 +1,56 @@ +# 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. | +| `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..641d3bc34e2 --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/src/blockRegistry.ts @@ -0,0 +1,1893 @@ +/* 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" }, + }, + + KeyDownEvent: { + className: "FlowGraphKeyDownEventBlock", + category: "Event", + description: "Triggers when a keyboard key is pressed down. Can optionally ignore auto-repeat events.", + signalInputs: [{ name: "in" }], + signalOutputs: [ + { name: "out", description: "Fires once at graph startup (initialization)" }, + { name: "done", description: "Fires each time the matching key-down event occurs. USE THIS for keyboard logic." }, + { name: "error" }, + ], + dataInputs: [{ name: "key", type: "string", description: "KeyboardEvent.code value to match, such as KeyA or Space. Leave empty for any key.", isOptional: true }], + dataOutputs: [ + { name: "keyCode", type: "string", description: "KeyboardEvent.code for the pressed key" }, + { name: "keyValue", type: "string", description: "KeyboardEvent.key for the pressed key" }, + { name: "shiftKey", type: "boolean", description: "Whether Shift was held" }, + { name: "ctrlKey", type: "boolean", description: "Whether Ctrl was held" }, + { name: "altKey", type: "boolean", description: "Whether Alt/Option was held" }, + { name: "metaKey", type: "boolean", description: "Whether Meta/Cmd/Windows key was held" }, + { name: "commandOrCtrl", type: "boolean", description: "Whether the platform command modifier was held" }, + { name: "isRepeat", type: "boolean", description: "True when this key-down event is an auto-repeat event" }, + ], + config: { + stopPropagation: "boolean — whether to stop event propagation", + ignoreRepeat: "boolean — when true, ignores auto-repeat key-down events", + }, + }, + + KeyUpEvent: { + className: "FlowGraphKeyUpEventBlock", + category: "Event", + description: "Triggers when a keyboard key is released.", + signalInputs: [{ name: "in" }], + signalOutputs: [ + { name: "out", description: "Fires once at graph startup (initialization)" }, + { name: "done", description: "Fires each time the matching key-up event occurs. USE THIS for keyboard logic." }, + { name: "error" }, + ], + dataInputs: [{ name: "key", type: "string", description: "KeyboardEvent.code value to match, such as KeyA or Space. Leave empty for any key.", isOptional: true }], + dataOutputs: [ + { name: "keyCode", type: "string", description: "KeyboardEvent.code for the released key" }, + { name: "keyValue", type: "string", description: "KeyboardEvent.key for the released key" }, + { name: "shiftKey", type: "boolean", description: "Whether Shift was held" }, + { name: "ctrlKey", type: "boolean", description: "Whether Ctrl was held" }, + { name: "altKey", type: "boolean", description: "Whether Alt/Option was held" }, + { name: "metaKey", type: "boolean", description: "Whether Meta/Cmd/Windows key was held" }, + { name: "commandOrCtrl", type: "boolean", description: "Whether the platform command modifier was held" }, + ], + config: { stopPropagation: "boolean — whether to stop event propagation" }, + }, + + 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" }, + ], + }, + + IsKeyPressed: { + className: "FlowGraphIsKeyPressedBlock", + category: "Utility", + description: "Outputs whether a keyboard key is currently held down, optionally requiring modifier keys.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "key", type: "string", description: "KeyboardEvent.code value to check, such as KeyA, Space, ShiftLeft, or ControlLeft" }, + { name: "withShift", type: "boolean", description: "Require Shift to also be held", isOptional: true }, + { name: "withCtrl", type: "boolean", description: "Require Ctrl to also be held", isOptional: true }, + { name: "withAlt", type: "boolean", description: "Require Alt/Option to also be held", isOptional: true }, + { name: "withMeta", type: "boolean", description: "Require Meta/Cmd/Windows key to also be held", isOptional: true }, + { name: "withCommandOrCtrl", type: "boolean", description: "Require Cmd on macOS or Ctrl on Windows/Linux", isOptional: true }, + ], + dataOutputs: [{ name: "isPressed", type: "boolean", description: "True when the requested key and modifiers are currently held" }], + }, + + 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..0e1f87b63a2 --- /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 "@tools/mcp-server-core"; + +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..adf1803691f --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/src/index.ts @@ -0,0 +1,1153 @@ +#!/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 { + CreateErrorResponse, + CreateInlineJsonSchema, + CreateJsonExportResponse, + CreateJsonFileSchema, + CreateJsonImportResponse, + CreateOutputFileSchema, + CreateTextResponse, + McpEditorSessionController, + ResolveDefinedInput, +} from "@tools/mcp-server-core"; + +import { FlowGraphBlockRegistry, GetBlockCatalogSummary, GetBlockTypeDetails } from "./blockRegistry.js"; +import { FlowGraphManager } from "./flowGraphManager.js"; +const manager = new FlowGraphManager(); +const sessionController = new McpEditorSessionController( + { + serverName: "Flow Graph MCP Session Server", + documentKind: "flow-graph", + managerUnavailableMessage: "Flow graph manager is not available", + getDocument: (manager, session) => manager.exportJSON(session.name) ?? undefined, + setDocument: (manager, session, document) => { + const result = manager.importJSON(session.name, document); + return result && result !== "OK" ? result : undefined; + }, + }, + { + defaultPort: 3001, + statusTitle: "Flow Graph MCP Session Server", + } +); + +/** + * Notify SSE subscribers if a session exists for the given flow graph. + * @param graphName - The graph name to check for active sessions. + */ +function _notifyIfSession(graphName: string): void { + const sessionId = sessionController.getSessionIdForName(graphName); + if (sessionId) { + sessionController.notifySessionUpdate(sessionId); + } +} + +/** + * Import flow graph JSON and notify a matching live session on success. + * @param graphName - The graph name to import into. + * @param jsonText - Serialized Flow Graph JSON. + * @returns "OK" on success, or an error string. + */ +function _importGraphJson(graphName: string, jsonText: string): string { + const result = manager.importJSON(graphName, jsonText); + if (result === "OK") { + _notifyIfSession(graphName); + } + return result; +} + +// ─── 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); + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(name); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + 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.\n\nMCP Session URL: ${sessionUrl}`, + }, + ], + }; + } +); + +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); + if (ok) { + sessionController.closeSessionForName(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(); + for (const name of names) { + sessionController.closeSessionForName(name); + } + 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.", + }, + ], + }; +}); + +server.registerTool( + "get_session_url", + { + description: "Get or create a live-session URL for a flow graph. The URL can be pasted into the Flow Graph Editor MCP session panel.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph"), + }, + }, + async ({ graphName }) => { + const graphs = manager.listGraphs(); + if (!graphs.includes(graphName)) { + return CreateErrorResponse(`Graph "${graphName}" not found.`); + } + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(graphName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return CreateTextResponse(`MCP Session URL: ${sessionUrl}`); + } +); + +server.registerTool( + "start_session", + { + description: "Start a live editor session for a flow graph and return its URL.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph"), + }, + }, + async ({ graphName }) => { + const graphs = manager.listGraphs(); + if (!graphs.includes(graphName)) { + return CreateErrorResponse(`Graph "${graphName}" not found.`); + } + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(graphName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return CreateTextResponse(`Started Flow Graph editor session for "${graphName}".\n\nMCP Session URL: ${sessionUrl}`); + } +); + +server.registerTool( + "close_session", + { + description: "Close the live editor session for a flow graph without stopping the MCP server.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph"), + }, + }, + async ({ graphName }) => { + const closed = sessionController.closeSessionForName(graphName); + return CreateTextResponse(closed ? `Closed MCP session for "${graphName}".` : `No active MCP session found for "${graphName}".`); + } +); + +server.registerTool("stop_session_server", { description: "Stop the local Flow Graph MCP HTTP/SSE session server and close all active sessions." }, async () => { + await sessionController.stopAsync(); + return CreateTextResponse("Flow Graph MCP session server stopped."); +}); + +// ── 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 }; + } + _notifyIfSession(graphName); + + 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); + if (result === "OK") { + _notifyIfSession(graphName); + } + 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); + if (result === "OK") { + _notifyIfSession(graphName); + } + 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); + if (result === "OK") { + _notifyIfSession(graphName); + } + // 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); + if (result === "OK") { + _notifyIfSession(graphName); + } + 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); + if (result === "OK") { + _notifyIfSession(graphName); + } + 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); + if (result === "OK") { + _notifyIfSession(graphName); + } + 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); + if (result === "OK") { + _notifyIfSession(graphName); + } + 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) => _importGraphJson(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[] = []; + let didMutate = false; + 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 { + didMutate = true; + results.push(`[${result.id}] ${result.name} (${resolvedBlockType})`); + } + } + if (didMutate) { + _notifyIfSession(graphName); + } + 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[] = []; + let didMutate = false; + 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); + if (result === "OK") { + didMutate = true; + } + results.push(result === "OK" ? `[${conn.sourceBlockId}].${resolvedOutputName} → [${conn.targetBlockId}].${resolvedInputName}` : `Error: ${result}`); + } + if (didMutate) { + _notifyIfSession(resolvedGraphName); + } + 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[] = []; + let didMutate = false; + for (const conn of connections) { + const result = manager.connectData(graphName, conn.sourceBlockId, conn.outputName, conn.targetBlockId, conn.inputName); + if (result === "OK") { + didMutate = true; + } + results.push(result === "OK" ? `[${conn.sourceBlockId}].${conn.outputName} → [${conn.targetBlockId}].${conn.inputName}` : `Error: ${result}`); + } + if (didMutate) { + _notifyIfSession(graphName); + } + 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); +} + +const _shutdown = () => { + void sessionController.stopAsync(); + process.exit(0); +}; +process.on("SIGINT", _shutdown); +process.on("SIGTERM", _shutdown); 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..2c6df59671a --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "types": ["node"], + "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/flowGraphEditor/src/components/mcpSession/mcpSessionComponent.tsx b/packages/tools/flowGraphEditor/src/components/mcpSession/mcpSessionComponent.tsx new file mode 100644 index 00000000000..22ef32e0e4b --- /dev/null +++ b/packages/tools/flowGraphEditor/src/components/mcpSession/mcpSessionComponent.tsx @@ -0,0 +1,168 @@ +import { type FunctionComponent, useCallback, useEffect, useState } from "react"; +import { type GlobalState } from "../../globalState"; +import { SerializationTools } from "../../serializationTools"; +import { LogEntry } from "../log/logComponent"; +import { LineContainerComponent } from "../../sharedComponents/lineContainerComponent"; +import { ButtonLineComponent } from "shared-ui-components/lines/buttonLineComponent"; +import { TextInputLineComponent } from "shared-ui-components/lines/textInputLineComponent"; +import { TextLineComponent } from "shared-ui-components/lines/textLineComponent"; +import { + CloseMcpEditorSessionEventSource, + NormalizeMcpEditorSessionUrl, + OpenMcpEditorSessionEventSource, + PostMcpEditorSessionDocumentAsync, +} from "shared-ui-components/mcp/mcpEditorSessionConnection"; + +interface IMcpSessionComponentProps { + globalState: GlobalState; +} + +async function LoadFlowGraphFromJsonAsync(globalState: GlobalState, json: unknown): Promise { + await SerializationTools.DeserializeAsync(json, globalState); + globalState.onResetRequiredObservable.notifyObservers(false); + globalState.stateManager.onSelectionChangedObservable.notifyObservers(null); + globalState.onClearUndoStack.notifyObservers(); + globalState.onBuiltObservable.notifyObservers(); + globalState.onZoomToFitRequiredObservable.notifyObservers(); +} + +/** + * Panel that connects to a live MCP session for bidirectional flow-graph sync. + * @param props - Component props. + * @returns The React element. + */ +export const McpSessionComponent: FunctionComponent = (props) => { + const { globalState } = props; + const [url, setUrl] = useState(globalState.mcpSessionUrl ?? ""); + const [connected, setConnected] = useState(globalState.mcpSessionConnected); + + useEffect(() => { + const observer = globalState.onMcpSessionStateChangedObservable.add((state) => { + setConnected(state); + }); + setConnected(globalState.mcpSessionConnected); + if (globalState.mcpSessionUrl) { + setUrl(globalState.mcpSessionUrl); + } + return () => { + globalState.onMcpSessionStateChangedObservable.remove(observer); + }; + }, [globalState]); + + const loadFlowGraphFromJson = useCallback( + (json: unknown) => { + void (async () => { + try { + await LoadFlowGraphFromJsonAsync(globalState, json); + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Load failed - ${err}`, true)); + } + })(); + }, + [globalState] + ); + + const handleConnect = useCallback( + async (pushOnConnect: boolean = false) => { + const sessionUrl = NormalizeMcpEditorSessionUrl(url); + if (!sessionUrl) { + return; + } + + try { + if (pushOnConnect && globalState.flowGraph) { + await PostMcpEditorSessionDocumentAsync(sessionUrl, SerializationTools.Serialize(globalState.flowGraph, globalState)); + } + + CloseMcpEditorSessionEventSource(globalState.mcpEventSource); + globalState.mcpEventSource = null; + + const eventSource = OpenMcpEditorSessionEventSource({ + sessionUrl, + onDocument: loadFlowGraphFromJson, + onSessionClosed: (reason) => { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session ended: ${reason}`, false)); + globalState.mcpSessionConnected = false; + globalState.mcpEventSource = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, + onConnectionError: () => { + globalState.mcpSessionConnected = false; + globalState.mcpEventSource = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, + }); + globalState.mcpEventSource = eventSource; + + globalState.mcpSessionUrl = sessionUrl; + globalState.mcpSessionConnected = true; + globalState.onMcpSessionStateChangedObservable.notifyObservers(true); + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Connection failed - ${err}`, true)); + } + }, + [url, globalState, loadFlowGraphFromJson] + ); + + const handleDisconnect = useCallback(() => { + CloseMcpEditorSessionEventSource(globalState.mcpEventSource); + globalState.mcpEventSource = null; + globalState.mcpSessionConnected = false; + globalState.mcpSessionUrl = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, [globalState]); + + const handlePush = useCallback(async () => { + if (!globalState.mcpSessionUrl || !globalState.flowGraph) { + return; + } + try { + const res = await PostMcpEditorSessionDocumentAsync(globalState.mcpSessionUrl, SerializationTools.Serialize(globalState.flowGraph, globalState)); + if (!res.ok) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Push failed (${res.status})`, true)); + } + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Push failed - ${err}`, true)); + } + }, [globalState]); + + return ( + + setUrl(value)} + placeholder="http://localhost:3001/session/..." + disabled={connected} + lockObject={globalState.lockObject} + /> + + {!connected ? ( + <> + { + void handleConnect(false); + }} + /> + { + void handleConnect(true); + }} + /> + + ) : ( + <> + + { + void handlePush(); + }} + /> + + )} + + ); +}; diff --git a/packages/tools/flowGraphEditor/src/components/propertyTab/propertyTabComponent.tsx b/packages/tools/flowGraphEditor/src/components/propertyTab/propertyTabComponent.tsx index ad306c13118..dd6a8851764 100644 --- a/packages/tools/flowGraphEditor/src/components/propertyTab/propertyTabComponent.tsx +++ b/packages/tools/flowGraphEditor/src/components/propertyTab/propertyTabComponent.tsx @@ -27,6 +27,7 @@ import { SliderLineComponent } from "shared-ui-components/lines/sliderLineCompon import { Constants } from "core/Engines/constants"; import { TextInputLineComponent } from "shared-ui-components/lines/textInputLineComponent"; import { ShowToast } from "../toast/toastComponent"; +import { McpSessionComponent } from "../mcpSession/mcpSessionComponent"; interface IPropertyTabComponentProps { globalState: GlobalState; @@ -320,6 +321,7 @@ export class PropertyTabComponent extends React.ComponentFLOW GRAPH EDITOR
+ (); + // ── Time Scale ───────────────────────────────────────────────────── /** Observable triggered when the time scale changes. */ onTimeScaleChanged = new Observable(); diff --git a/packages/tools/gui-mcp-server/README.md b/packages/tools/gui-mcp-server/README.md new file mode 100644 index 00000000000..8630a41c3d7 --- /dev/null +++ b/packages/tools/gui-mcp-server/README.md @@ -0,0 +1,43 @@ +# @tools/gui-mcp-server + +MCP server for AI-driven Babylon.js GUI layout authoring. + +## Provides + +- create and manage GUIs and controls in memory +- add controls, reparent them, and update control properties +- manage grid rows and columns +- inspect GUI/control structure +- export and import Babylon.js GUI JSON +- import from and save to Babylon.js snippets + +## Typical Workflow + +```text +create_gui -> add_control -> set_properties -> describe_gui -> export_gui_json +``` + +Grid-based UIs typically also use the row and column management tools before adding child controls. + +## Binary + +```bash +babylonjs-gui +``` + +## Build And Run + +```bash +npm run build -w @tools/gui-mcp-server +npm run start -w @tools/gui-mcp-server +``` + +## Integration + +GUI JSON can be attached to the Scene MCP server through `attach_gui`, either inline or via `guiJsonFile`. + +## Related Files + +- `src/index.ts`: MCP tool registration +- `src/guiManager.ts`: GUI state and serialization logic +- `src/catalog.ts`: control catalog and shared property metadata diff --git a/packages/tools/gui-mcp-server/examples/ConfirmationDialog.json b/packages/tools/gui-mcp-server/examples/ConfirmationDialog.json new file mode 100644 index 00000000000..8ad4515c284 --- /dev/null +++ b/packages/tools/gui-mcp-server/examples/ConfirmationDialog.json @@ -0,0 +1,99 @@ +{ + "root": { + "name": "root", + "className": "Container", + "children": [ + { + "name": "overlay", + "className": "Rectangle", + "width": "1", + "height": "1", + "background": "rgba(0,0,0,0.5)", + "thickness": 0, + "children": [ + { + "name": "box", + "className": "Rectangle", + "width": "450px", + "height": "250px", + "background": "white", + "cornerRadius": 12, + "thickness": 0, + "children": [ + { + "name": "icon", + "className": "TextBlock", + "text": "⚠️", + "fontSize": "48px", + "top": "-60px" + }, + { + "name": "title", + "className": "TextBlock", + "text": "Delete Item?", + "color": "#333", + "fontSize": "22px", + "top": "-10px" + }, + { + "name": "message", + "className": "TextBlock", + "text": "This action cannot be undone. Are you sure you want to delete this item?", + "color": "#666", + "fontSize": "14px", + "textWrapping": 1, + "width": "380px", + "top": "30px" + }, + { + "name": "btnRow", + "className": "StackPanel", + "isVertical": false, + "top": "80px", + "height": "45px", + "children": [ + { + "name": "deleteBtn", + "className": "Button", + "width": "120px", + "height": "40px", + "background": "#F44336", + "color": "white", + "cornerRadius": 6, + "textBlockName": "deleteBtn_text", + "children": [ + { + "name": "deleteBtn_text", + "className": "TextBlock", + "text": "Delete" + } + ] + }, + { + "name": "cancelBtn", + "className": "Button", + "width": "120px", + "height": "40px", + "background": "#E0E0E0", + "color": "#333", + "cornerRadius": 6, + "textBlockName": "cancelBtn_text", + "children": [ + { + "name": "cancelBtn_text", + "className": "TextBlock", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "width": 1920, + "height": 1080 +} \ No newline at end of file diff --git a/packages/tools/gui-mcp-server/examples/GameHUD.json b/packages/tools/gui-mcp-server/examples/GameHUD.json new file mode 100644 index 00000000000..02381f6d8e6 --- /dev/null +++ b/packages/tools/gui-mcp-server/examples/GameHUD.json @@ -0,0 +1,89 @@ +{ + "root": { + "name": "root", + "className": "Container", + "children": [ + { + "name": "topBar", + "className": "Rectangle", + "width": "1", + "height": "60px", + "verticalAlignment": 0, + "background": "rgba(0,0,0,0.6)", + "thickness": 0, + "children": [ + { + "name": "topStack", + "className": "StackPanel", + "isVertical": false, + "width": "1", + "height": "1", + "children": [ + { + "name": "scoreLabel", + "className": "TextBlock", + "text": "Score: 12500", + "color": "gold", + "fontSize": "28px", + "width": "200px" + }, + { + "name": "levelLabel", + "className": "TextBlock", + "text": "Level 7", + "color": "white", + "fontSize": "22px", + "width": "100px" + } + ] + } + ] + }, + { + "name": "healthFrame", + "className": "Rectangle", + "width": "300px", + "height": "30px", + "left": "20px", + "top": "-20px", + "verticalAlignment": 1, + "horizontalAlignment": 0, + "background": "#333", + "cornerRadius": 5, + "children": [ + { + "name": "healthFill", + "className": "Rectangle", + "width": "0.75", + "height": "1", + "horizontalAlignment": 0, + "background": "linear-gradient(#4CAF50, #66BB6A)", + "thickness": 0 + }, + { + "name": "healthText", + "className": "TextBlock", + "text": "75/100", + "color": "white", + "fontSize": "14px" + } + ] + }, + { + "name": "minimapFrame", + "className": "Rectangle", + "width": "200px", + "height": "200px", + "left": "-20px", + "top": "-20px", + "verticalAlignment": 1, + "horizontalAlignment": 1, + "background": "rgba(0,0,0,0.5)", + "thickness": 2, + "color": "#888" + } + ] + }, + "width": 1920, + "height": 1080 +} \ No newline at end of file diff --git a/packages/tools/gui-mcp-server/examples/GridDashboard.json b/packages/tools/gui-mcp-server/examples/GridDashboard.json new file mode 100644 index 00000000000..2a14ff2ce19 --- /dev/null +++ b/packages/tools/gui-mcp-server/examples/GridDashboard.json @@ -0,0 +1,200 @@ +{ + "root": { + "name": "root", + "className": "Container", + "children": [ + { + "name": "mainGrid", + "className": "Grid", + "rows": [ + { + "value": 60, + "unit": 1 + }, + { + "value": 0.5, + "unit": 0 + }, + { + "value": 0.5, + "unit": 0 + } + ], + "columns": [ + { + "value": 0.5, + "unit": 0 + }, + { + "value": 0.5, + "unit": 0 + } + ], + "tags": [ + "0:0", + "1:0", + "1:1", + "2:0", + "2:1" + ], + "rowCount": 3, + "columnCount": 2, + "children": [ + { + "name": "header", + "className": "Rectangle", + "background": "#1976D2", + "thickness": 0, + "children": [ + { + "name": "headerText", + "className": "TextBlock", + "text": "📊 Dashboard", + "color": "white", + "fontSize": "24px" + } + ] + }, + { + "name": "usersCard", + "className": "Rectangle", + "background": "#E91E63", + "cornerRadius": 10, + "thickness": 0, + "width": "0.9", + "height": "0.85", + "children": [ + { + "name": "usersStack", + "className": "StackPanel", + "isVertical": true, + "children": [ + { + "name": "usersValue", + "className": "TextBlock", + "text": "1,234", + "color": "white", + "fontSize": "36px", + "height": "50px" + }, + { + "name": "usersLabel", + "className": "TextBlock", + "text": "Active Users", + "color": "rgba(255,255,255,0.8)", + "fontSize": "16px", + "height": "25px" + } + ] + } + ] + }, + { + "name": "ordersCard", + "className": "Rectangle", + "background": "#FF9800", + "cornerRadius": 10, + "thickness": 0, + "width": "0.9", + "height": "0.85", + "children": [ + { + "name": "ordersStack", + "className": "StackPanel", + "isVertical": true, + "children": [ + { + "name": "ordersValue", + "className": "TextBlock", + "text": "567", + "color": "white", + "fontSize": "36px", + "height": "50px" + }, + { + "name": "ordersLabel", + "className": "TextBlock", + "text": "Orders Today", + "color": "rgba(255,255,255,0.8)", + "fontSize": "16px", + "height": "25px" + } + ] + } + ] + }, + { + "name": "revenueCard", + "className": "Rectangle", + "background": "#4CAF50", + "cornerRadius": 10, + "thickness": 0, + "width": "0.9", + "height": "0.85", + "children": [ + { + "name": "revenueStack", + "className": "StackPanel", + "isVertical": true, + "children": [ + { + "name": "revenueValue", + "className": "TextBlock", + "text": "$12,345", + "color": "white", + "fontSize": "36px", + "height": "50px" + }, + { + "name": "revenueLabel", + "className": "TextBlock", + "text": "Revenue", + "color": "rgba(255,255,255,0.8)", + "fontSize": "16px", + "height": "25px" + } + ] + } + ] + }, + { + "name": "errorsCard", + "className": "Rectangle", + "background": "#9C27B0", + "cornerRadius": 10, + "thickness": 0, + "width": "0.9", + "height": "0.85", + "children": [ + { + "name": "errorsStack", + "className": "StackPanel", + "isVertical": true, + "children": [ + { + "name": "errorsValue", + "className": "TextBlock", + "text": "0.2%", + "color": "white", + "fontSize": "36px", + "height": "50px" + }, + { + "name": "errorsLabel", + "className": "TextBlock", + "text": "Error Rate", + "color": "rgba(255,255,255,0.8)", + "fontSize": "16px", + "height": "25px" + } + ] + } + ] + } + ] + } + ] + }, + "width": 1920, + "height": 1080 +} \ No newline at end of file diff --git a/packages/tools/gui-mcp-server/examples/LoginForm.json b/packages/tools/gui-mcp-server/examples/LoginForm.json new file mode 100644 index 00000000000..8f1940d0082 --- /dev/null +++ b/packages/tools/gui-mcp-server/examples/LoginForm.json @@ -0,0 +1,127 @@ +{ + "root": { + "name": "root", + "className": "Container", + "children": [ + { + "name": "bg", + "className": "Rectangle", + "width": "1", + "height": "1", + "background": "#1A1A2E", + "thickness": 0, + "children": [ + { + "name": "card", + "className": "Rectangle", + "width": "400px", + "height": "450px", + "background": "#16213E", + "cornerRadius": 20, + "thickness": 1, + "color": "#333", + "children": [ + { + "name": "formStack", + "className": "StackPanel", + "isVertical": true, + "width": "320px", + "children": [ + { + "name": "logo", + "className": "TextBlock", + "text": "🔐 Welcome Back", + "color": "#E94560", + "fontSize": "28px", + "height": "60px" + }, + { + "name": "userLabel", + "className": "TextBlock", + "text": "Username", + "color": "#AAA", + "fontSize": "14px", + "height": "25px", + "horizontalAlignment": 0 + }, + { + "name": "userInput", + "className": "InputText", + "placeholderText": "Enter username...", + "placeholderColor": "#555", + "color": "white", + "background": "#0F3460", + "height": "40px", + "width": "1" + }, + { + "name": "passLabel", + "className": "TextBlock", + "text": "Password", + "color": "#AAA", + "fontSize": "14px", + "height": "25px", + "horizontalAlignment": 0 + }, + { + "name": "passInput", + "className": "InputPassword", + "placeholderText": "Enter password...", + "placeholderColor": "#555", + "color": "white", + "background": "#0F3460", + "height": "40px", + "width": "1" + }, + { + "name": "rememberRow", + "className": "StackPanel", + "isVertical": false, + "height": "30px", + "children": [ + { + "name": "rememberCb", + "className": "Checkbox", + "isChecked": false, + "width": "20px", + "height": "20px", + "color": "#E94560" + }, + { + "name": "rememberLabel", + "className": "TextBlock", + "text": "Remember me", + "color": "#AAA", + "fontSize": "14px", + "width": "120px" + } + ] + }, + { + "name": "loginBtn", + "className": "Button", + "width": "1", + "height": "45px", + "background": "#E94560", + "color": "white", + "cornerRadius": 8, + "textBlockName": "loginBtn_text", + "children": [ + { + "name": "loginBtn_text", + "className": "TextBlock", + "text": "Log In" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "width": 1920, + "height": 1080 +} \ No newline at end of file diff --git a/packages/tools/gui-mcp-server/examples/SettingsPanel.json b/packages/tools/gui-mcp-server/examples/SettingsPanel.json new file mode 100644 index 00000000000..faef4956475 --- /dev/null +++ b/packages/tools/gui-mcp-server/examples/SettingsPanel.json @@ -0,0 +1,149 @@ +{ + "root": { + "name": "root", + "className": "Container", + "children": [ + { + "name": "overlay", + "className": "Rectangle", + "width": "1", + "height": "1", + "background": "rgba(0,0,0,0.7)", + "thickness": 0, + "children": [ + { + "name": "dialog", + "className": "Rectangle", + "width": "500px", + "height": "600px", + "background": "#2D2D2D", + "cornerRadius": 15, + "thickness": 2, + "color": "#555", + "children": [ + { + "name": "title", + "className": "TextBlock", + "text": "⚙ Settings", + "color": "white", + "fontSize": "24px", + "top": "-240px" + }, + { + "name": "options", + "className": "StackPanel", + "isVertical": true, + "width": "400px", + "top": "-100px", + "children": [ + { + "name": "volLabel", + "className": "TextBlock", + "text": "Volume", + "color": "#BBB", + "fontSize": "16px", + "height": "30px" + }, + { + "name": "volSlider", + "className": "Slider", + "minimum": 0, + "maximum": 100, + "value": 75, + "step": 5, + "height": "30px", + "color": "#4CAF50" + }, + { + "name": "brightLabel", + "className": "TextBlock", + "text": "Brightness", + "color": "#BBB", + "fontSize": "16px", + "height": "30px" + }, + { + "name": "brightSlider", + "className": "Slider", + "minimum": 0, + "maximum": 100, + "value": 50, + "height": "30px", + "color": "#2196F3" + }, + { + "name": "fsRow", + "className": "StackPanel", + "isVertical": false, + "height": "30px", + "children": [ + { + "name": "fsCb", + "className": "Checkbox", + "isChecked": false, + "width": "20px", + "height": "20px", + "color": "#4CAF50" + }, + { + "name": "fsLabel", + "className": "TextBlock", + "text": "Fullscreen", + "color": "white", + "fontSize": "16px", + "width": "150px" + } + ] + } + ] + }, + { + "name": "btnRow", + "className": "StackPanel", + "isVertical": false, + "top": "240px", + "height": "50px", + "children": [ + { + "name": "applyBtn", + "className": "Button", + "width": "120px", + "height": "40px", + "background": "#4CAF50", + "color": "white", + "textBlockName": "applyBtn_text", + "children": [ + { + "name": "applyBtn_text", + "className": "TextBlock", + "text": "Apply" + } + ] + }, + { + "name": "cancelBtn", + "className": "Button", + "width": "120px", + "height": "40px", + "background": "#666", + "color": "white", + "textBlockName": "cancelBtn_text", + "children": [ + { + "name": "cancelBtn_text", + "className": "TextBlock", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "width": 1920, + "height": 1080 +} \ No newline at end of file diff --git a/packages/tools/gui-mcp-server/package.json b/packages/tools/gui-mcp-server/package.json new file mode 100644 index 00000000000..f6a1c14bdc1 --- /dev/null +++ b/packages/tools/gui-mcp-server/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tools/gui-mcp-server", + "version": "1.0.0", + "private": true, + "description": "MCP server for AI-driven Babylon.js GUI (2D) layout and design", + "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", + "@tools/snippet-loader": "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/gui-mcp-server/rollup.config.mjs b/packages/tools/gui-mcp-server/rollup.config.mjs new file mode 100644 index 00000000000..b6a4d32812a --- /dev/null +++ b/packages/tools/gui-mcp-server/rollup.config.mjs @@ -0,0 +1,3 @@ +import { createConfig } from "../rollup.config.mcp.mjs"; + +export default createConfig(); diff --git a/packages/tools/gui-mcp-server/src/catalog.ts b/packages/tools/gui-mcp-server/src/catalog.ts new file mode 100644 index 00000000000..af6cfae9a94 --- /dev/null +++ b/packages/tools/gui-mcp-server/src/catalog.ts @@ -0,0 +1,575 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * Complete catalog of all Babylon.js GUI 2D control types. + * Each entry describes the control's class name, category, parent capability, + * and the properties specific to that control type. + * + * This file is the single source of truth that the AI agent uses to know + * which controls exist and what properties they support. + */ + +// ─── Alignment / layout enums ───────────────────────────────────────────── + +/** Horizontal alignment values (Control.horizontalAlignment) */ +export const HorizontalAlignment = { + LEFT: 0, + RIGHT: 1, + CENTER: 2, +} as const; + +/** Vertical alignment values (Control.verticalAlignment) */ +export const VerticalAlignment = { + TOP: 0, + BOTTOM: 1, + CENTER: 2, +} as const; + +/** Text wrapping modes (TextBlock.textWrapping) */ +export const TextWrapping = { + Clip: 0, + WordWrap: 1, + Ellipsis: 2, + WordWrapEllipsis: 3, + HTML: 4, +} as const; + +/** Image stretch modes (Image.stretch) */ +export const ImageStretch = { + STRETCH_NONE: 0, + STRETCH_FILL: 1, + STRETCH_UNIFORM: 2, + STRETCH_EXTEND: 3, + STRETCH_NINE_PATCH: 4, +} as const; + +// ─── Control-type catalog ───────────────────────────────────────────────── + +/** + * Describes a single property that can be set on a control. + */ +export interface IPropertyInfo { + /** Human-readable description */ + description: string; + /** Expected data type */ + type: "string" | "number" | "boolean" | "Color3" | "Color4" | "object"; + /** Default value (informational) */ + defaultValue?: unknown; +} + +/** + * Describes a GUI control type. + */ +export interface IControlTypeInfo { + /** The Babylon.js class name (used as `className` in serialization) */ + className: string; + /** Category for grouping */ + category: "Container" | "Text" | "Input" | "Button" | "Indicator" | "Shape" | "Image" | "Layout" | "Misc"; + /** Human-readable description */ + description: string; + /** Whether this control can contain children */ + isContainer: boolean; + /** Extra properties specific to this control type (beyond base Control props) */ + properties: Record; +} + +/** + * Base Control properties shared by ALL controls. + * These are not repeated in each entry below. + */ +export const BaseControlProperties: Record = { + // Size & position + width: { description: "Width as a string ('200px', '50%') or number", type: "string", defaultValue: "100%" }, + height: { description: "Height as a string ('40px', '50%') or number", type: "string", defaultValue: "100%" }, + left: { description: "Horizontal offset from alignment anchor", type: "string", defaultValue: "0px" }, + top: { description: "Vertical offset from alignment anchor", type: "string", defaultValue: "0px" }, + // Alignment + horizontalAlignment: { description: "0 = LEFT, 1 = RIGHT, 2 = CENTER", type: "number", defaultValue: 2 }, + verticalAlignment: { description: "0 = TOP, 1 = BOTTOM, 2 = CENTER", type: "number", defaultValue: 2 }, + // Padding + paddingLeft: { description: "Left padding (e.g. '10px')", type: "string", defaultValue: "0px" }, + paddingRight: { description: "Right padding", type: "string", defaultValue: "0px" }, + paddingTop: { description: "Top padding", type: "string", defaultValue: "0px" }, + paddingBottom: { description: "Bottom padding", type: "string", defaultValue: "0px" }, + // Appearance + color: { description: "Foreground / text color (CSS color string)", type: "string", defaultValue: "white" }, + alpha: { description: "Opacity (0 = transparent, 1 = opaque)", type: "number", defaultValue: 1 }, + isVisible: { description: "Whether the control is rendered", type: "boolean", defaultValue: true }, + zIndex: { description: "Rendering order among siblings", type: "number", defaultValue: 0 }, + rotation: { description: "Rotation in radians", type: "number", defaultValue: 0 }, + scaleX: { description: "Horizontal scale factor", type: "number", defaultValue: 1 }, + scaleY: { description: "Vertical scale factor", type: "number", defaultValue: 1 }, + // Font + fontFamily: { description: "CSS font family", type: "string" }, + fontSize: { description: "Font size (e.g. '24px' or 24)", type: "string", defaultValue: "18px" }, + fontWeight: { description: "CSS font weight (normal, bold, 600, etc.)", type: "string" }, + fontStyle: { description: "CSS font style (normal, italic)", type: "string" }, + // Shadow + shadowOffsetX: { description: "Shadow X offset", type: "number", defaultValue: 0 }, + shadowOffsetY: { description: "Shadow Y offset", type: "number", defaultValue: 0 }, + shadowBlur: { description: "Shadow blur radius", type: "number", defaultValue: 0 }, + shadowColor: { description: "Shadow color", type: "string", defaultValue: "black" }, + // Behaviour + isEnabled: { description: "Whether the control accepts input", type: "boolean", defaultValue: true }, + isHitTestVisible: { description: "Whether the control responds to pointer events", type: "boolean", defaultValue: true }, + isPointerBlocker: { description: "Block pointer events from reaching controls behind", type: "boolean", defaultValue: false }, + clipChildren: { description: "Clip child controls to this control's bounds", type: "boolean", defaultValue: true }, + clipContent: { description: "Clip this control's own content to its bounds", type: "boolean", defaultValue: true }, + // Transform + transformCenterX: { description: "Transform origin X (0–1)", type: "number", defaultValue: 0.5 }, + transformCenterY: { description: "Transform origin Y (0–1)", type: "number", defaultValue: 0.5 }, + fixedRatio: { description: "Lock aspect ratio (0 = off, >0 = width/height ratio)", type: "number", defaultValue: 0 }, +}; + +/** + * Full catalog of GUI control types. + * Control is the base class, and LinearGradient/RadialGradient are paint helpers rather than controls. + */ +export const ControlRegistry: Record = { + // ─── Containers ─────────────────────────────────────────────────── + Container: { + className: "Container", + category: "Container", + description: "Base container that can hold children. Provides background and child management.", + isContainer: true, + properties: { + background: { description: "Background fill color", type: "string" }, + adaptWidthToChildren: { description: "Auto-resize width to fit children", type: "boolean", defaultValue: false }, + adaptHeightToChildren: { description: "Auto-resize height to fit children", type: "boolean", defaultValue: false }, + }, + }, + + Rectangle: { + className: "Rectangle", + category: "Container", + description: "Rectangular container with border and corner radius. The most commonly used container control.", + isContainer: true, + properties: { + background: { description: "Background fill color", type: "string" }, + thickness: { description: "Border thickness in pixels", type: "number", defaultValue: 1 }, + cornerRadius: { description: "Uniform corner radius (all four corners)", type: "number", defaultValue: 0 }, + cornerRadiusX: { description: "Top-left corner radius", type: "number", defaultValue: 0 }, + cornerRadiusY: { description: "Top-right corner radius", type: "number", defaultValue: 0 }, + cornerRadiusZ: { description: "Bottom-left corner radius", type: "number", defaultValue: 0 }, + cornerRadiusW: { description: "Bottom-right corner radius", type: "number", defaultValue: 0 }, + adaptWidthToChildren: { description: "Auto-resize width to fit children", type: "boolean", defaultValue: false }, + adaptHeightToChildren: { description: "Auto-resize height to fit children", type: "boolean", defaultValue: false }, + }, + }, + + Ellipse: { + className: "Ellipse", + category: "Container", + description: "Elliptical container with optional border.", + isContainer: true, + properties: { + background: { description: "Background fill color", type: "string" }, + thickness: { description: "Border thickness", type: "number", defaultValue: 1 }, + arc: { description: "Arc ratio (0–1, 1 = full ellipse)", type: "number", defaultValue: 1 }, + adaptWidthToChildren: { description: "Auto-resize width to fit children", type: "boolean", defaultValue: false }, + adaptHeightToChildren: { description: "Auto-resize height to fit children", type: "boolean", defaultValue: false }, + }, + }, + + StackPanel: { + className: "StackPanel", + category: "Layout", + description: "Arranges children sequentially in a vertical or horizontal stack. " + "Automatically sizes itself along the stacking axis to fit its children.", + isContainer: true, + properties: { + background: { description: "Background fill color", type: "string" }, + isVertical: { description: "Stack vertically (true) or horizontally (false)", type: "boolean", defaultValue: true }, + spacing: { description: "Pixels of space between each child", type: "number", defaultValue: 0 }, + adaptWidthToChildren: { description: "Auto-resize width to fit children", type: "boolean", defaultValue: false }, + adaptHeightToChildren: { description: "Auto-resize height to fit children", type: "boolean", defaultValue: false }, + }, + }, + + Grid: { + className: "Grid", + category: "Layout", + description: + "Table-like layout that organises children into rows and columns. " + + "Use add_grid_row / add_grid_column to define the grid structure, " + + "then add_control with row/column to place controls in cells.", + isContainer: true, + properties: { + background: { description: "Background fill color", type: "string" }, + adaptWidthToChildren: { description: "Auto-resize width to fit children", type: "boolean", defaultValue: false }, + adaptHeightToChildren: { description: "Auto-resize height to fit children", type: "boolean", defaultValue: false }, + }, + }, + + ScrollViewer: { + className: "ScrollViewer", + category: "Layout", + description: + "A scrollable container. Children that exceed the visible area can be scrolled into view. " + "Based on Rectangle, so it supports thickness, cornerRadius, etc.", + isContainer: true, + properties: { + background: { description: "Background fill color", type: "string" }, + thickness: { description: "Border thickness", type: "number", defaultValue: 1 }, + cornerRadius: { description: "Corner radius", type: "number", defaultValue: 0 }, + barColor: { description: "Scrollbar color", type: "string" }, + barBackground: { description: "Scrollbar track color", type: "string" }, + barSize: { description: "Scrollbar width in pixels", type: "number", defaultValue: 20 }, + wheelPrecision: { description: "Mouse wheel scroll precision", type: "number", defaultValue: 3 }, + thumbLength: { description: "Scrollbar thumb minimum length", type: "number" }, + }, + }, + + // ─── Text ──────────────────────────────────────────────────────── + TextBlock: { + className: "TextBlock", + category: "Text", + description: "Displays static or dynamic text. Supports word wrap, ellipsis, and text alignment.", + isContainer: false, + properties: { + text: { description: "The text to display", type: "string", defaultValue: "" }, + textWrapping: { + description: "Wrapping mode: 0=Clip, 1=WordWrap, 2=Ellipsis, 3=WordWrapEllipsis, 4=HTML", + type: "number", + defaultValue: 0, + }, + textHorizontalAlignment: { description: "0=LEFT, 1=RIGHT, 2=CENTER", type: "number", defaultValue: 2 }, + textVerticalAlignment: { description: "0=TOP, 1=BOTTOM, 2=CENTER", type: "number", defaultValue: 2 }, + resizeToFit: { description: "Auto-resize control to fit text content", type: "boolean", defaultValue: false }, + lineSpacing: { description: "Extra spacing between lines (e.g. '5px')", type: "string" }, + outlineWidth: { description: "Text outline thickness", type: "number", defaultValue: 0 }, + outlineColor: { description: "Text outline color", type: "string", defaultValue: "white" }, + underline: { description: "Draw underline", type: "boolean", defaultValue: false }, + lineThrough: { description: "Draw strike-through", type: "boolean", defaultValue: false }, + }, + }, + + // ─── Input ──────────────────────────────────────────────────────── + InputText: { + className: "InputText", + category: "Input", + description: "Single-line text input field with placeholder support.", + isContainer: false, + properties: { + text: { description: "Current text value", type: "string", defaultValue: "" }, + placeholderText: { description: "Placeholder text when empty", type: "string", defaultValue: "" }, + placeholderColor: { description: "Placeholder text color", type: "string", defaultValue: "DarkGray" }, + background: { description: "Background fill color", type: "string", defaultValue: "black" }, + focusedBackground: { description: "Background color when focused", type: "string" }, + thickness: { description: "Border thickness", type: "number", defaultValue: 1 }, + margin: { description: "Internal margin (e.g. '10px')", type: "string", defaultValue: "10px" }, + autoStretchWidth: { description: "Auto-resize width to fit text", type: "boolean", defaultValue: true }, + maxWidth: { description: "Maximum width constraint", type: "string" }, + highligherOpacity: { description: "Selection highlight opacity", type: "number", defaultValue: 0.4 }, + textHighlightColor: { description: "Selection highlight color", type: "string", defaultValue: "#d5e0ff" }, + onFocusSelectAll: { description: "Select all text when focused", type: "boolean", defaultValue: false }, + }, + }, + + InputPassword: { + className: "InputPassword", + category: "Input", + description: "Password input field. Same as InputText but displays bullet characters.", + isContainer: false, + properties: { + text: { description: "Current text value (hidden by bullets)", type: "string", defaultValue: "" }, + placeholderText: { description: "Placeholder text when empty", type: "string" }, + placeholderColor: { description: "Placeholder text color", type: "string", defaultValue: "DarkGray" }, + background: { description: "Background fill color", type: "string", defaultValue: "black" }, + focusedBackground: { description: "Background color when focused", type: "string" }, + thickness: { description: "Border thickness", type: "number", defaultValue: 1 }, + margin: { description: "Internal margin", type: "string", defaultValue: "10px" }, + }, + }, + + InputTextArea: { + className: "InputTextArea", + category: "Input", + description: "Multi-line text input area with optional auto-stretching height.", + isContainer: false, + properties: { + text: { description: "Current text value", type: "string", defaultValue: "" }, + placeholderText: { description: "Placeholder text", type: "string" }, + placeholderColor: { description: "Placeholder color", type: "string", defaultValue: "DarkGray" }, + background: { description: "Background fill color", type: "string", defaultValue: "black" }, + thickness: { description: "Border thickness", type: "number", defaultValue: 1 }, + autoStretchHeight: { description: "Auto-resize height to fit content", type: "boolean", defaultValue: true }, + maxHeight: { description: "Maximum height constraint", type: "string" }, + textHorizontalAlignment: { description: "0=LEFT, 1=RIGHT, 2=CENTER", type: "number", defaultValue: 0 }, + textVerticalAlignment: { description: "0=TOP, 1=BOTTOM, 2=CENTER", type: "number", defaultValue: 0 }, + lineSpacing: { description: "Extra line spacing", type: "string" }, + }, + }, + + // ─── Button controls ────────────────────────────────────────────── + Button: { + className: "Button", + category: "Button", + description: + "Interactive button. Inherits from Rectangle, so supports thickness, cornerRadius, background. " + + "Use the 'buttonText' and 'buttonImage' properties (set via set_control_properties) to configure its children.", + isContainer: true, + properties: { + background: { description: "Background fill color", type: "string" }, + thickness: { description: "Border thickness", type: "number", defaultValue: 1 }, + cornerRadius: { description: "Corner radius", type: "number", defaultValue: 0 }, + // The special child properties are managed by the guiManager + }, + }, + + FocusableButton: { + className: "FocusableButton", + category: "Button", + description: "A button that supports keyboard focus / tab navigation. Same visual properties as Button.", + isContainer: true, + properties: { + background: { description: "Background fill color", type: "string" }, + thickness: { description: "Border thickness", type: "number", defaultValue: 1 }, + cornerRadius: { description: "Corner radius", type: "number", defaultValue: 0 }, + }, + }, + + ToggleButton: { + className: "ToggleButton", + category: "Button", + description: "A button with on/off toggle state. Inherits from Rectangle.", + isContainer: true, + properties: { + background: { description: "Background fill color", type: "string" }, + thickness: { description: "Border thickness", type: "number", defaultValue: 1 }, + cornerRadius: { description: "Corner radius", type: "number", defaultValue: 0 }, + isActive: { description: "Whether the button is currently in the active/on state", type: "boolean", defaultValue: false }, + group: { description: "Group name for mutual exclusion (like radio buttons)", type: "string" }, + }, + }, + + // ─── Indicators / Selectors ─────────────────────────────────────── + Checkbox: { + className: "Checkbox", + category: "Indicator", + description: "A checkbox control with checked/unchecked state.", + isContainer: false, + properties: { + isChecked: { description: "Current checked state", type: "boolean", defaultValue: false }, + background: { description: "Background color of the check area", type: "string", defaultValue: "black" }, + checkSizeRatio: { description: "Size ratio of the check mark (0–1)", type: "number", defaultValue: 0.8 }, + thickness: { description: "Border thickness", type: "number", defaultValue: 1 }, + }, + }, + + RadioButton: { + className: "RadioButton", + category: "Indicator", + description: "A radio button control. Use 'group' property to create mutually exclusive sets.", + isContainer: false, + properties: { + isChecked: { description: "Current checked state", type: "boolean", defaultValue: false }, + background: { description: "Background color", type: "string", defaultValue: "black" }, + checkSizeRatio: { description: "Size ratio of the inner circle (0–1)", type: "number", defaultValue: 0.8 }, + thickness: { description: "Border thickness", type: "number", defaultValue: 1 }, + group: { description: "Group name — only one radio button per group can be checked", type: "string" }, + }, + }, + + ColorPicker: { + className: "ColorPicker", + category: "Indicator", + description: "A colour picker control that lets the user choose a colour.", + isContainer: false, + properties: { + value: { + description: "Current colour as {r, g, b} object (0–1 range)", + type: "object", + defaultValue: { r: 1, g: 0, b: 0 }, + }, + }, + }, + + // ─── Slider controls ────────────────────────────────────────────── + Slider: { + className: "Slider", + category: "Input", + description: "A slider control for selecting a numeric value within a range.", + isContainer: false, + properties: { + minimum: { description: "Minimum value", type: "number", defaultValue: 0 }, + maximum: { description: "Maximum value", type: "number", defaultValue: 100 }, + value: { description: "Current value", type: "number", defaultValue: 50 }, + step: { description: "Value increment step (0 = continuous)", type: "number", defaultValue: 0 }, + isVertical: { description: "Render vertically", type: "boolean", defaultValue: false }, + displayThumb: { description: "Show the draggable thumb", type: "boolean", defaultValue: true }, + thumbWidth: { description: "Thumb width (e.g. '20px')", type: "string" }, + barOffset: { description: "Bar offset from center (e.g. '5px')", type: "string" }, + isThumbClamped: { description: "Keep thumb within bar bounds", type: "boolean", defaultValue: false }, + background: { description: "Track background color", type: "string", defaultValue: "black" }, + borderColor: { description: "Track border color", type: "string", defaultValue: "white" }, + thumbColor: { description: "Thumb color", type: "string" }, + isThumbCircle: { description: "Render thumb as a circle", type: "boolean", defaultValue: false }, + displayValueBar: { description: "Show value-filled portion of the track", type: "boolean", defaultValue: true }, + }, + }, + + ImageBasedSlider: { + className: "ImageBasedSlider", + category: "Input", + description: "A slider that uses custom images for its track and thumb.", + isContainer: false, + properties: { + minimum: { description: "Minimum value", type: "number", defaultValue: 0 }, + maximum: { description: "Maximum value", type: "number", defaultValue: 100 }, + value: { description: "Current value", type: "number", defaultValue: 50 }, + step: { description: "Value step", type: "number", defaultValue: 0 }, + isVertical: { description: "Render vertically", type: "boolean", defaultValue: false }, + displayThumb: { description: "Show thumb", type: "boolean", defaultValue: true }, + backgroundImage: { description: "URL for the track background image", type: "string" }, + valueBarImage: { description: "URL for the value bar image", type: "string" }, + thumbImage: { description: "URL for the thumb image", type: "string" }, + }, + }, + + Scrollbar: { + className: "Scrollbar", + category: "Input", + description: "A scrollbar control for selecting a numeric value, commonly used for custom scrolling interfaces.", + isContainer: false, + properties: { + minimum: { description: "Minimum value", type: "number", defaultValue: 0 }, + maximum: { description: "Maximum value", type: "number", defaultValue: 100 }, + value: { description: "Current value", type: "number", defaultValue: 50 }, + step: { description: "Value step", type: "number", defaultValue: 0 }, + isVertical: { description: "Render vertically", type: "boolean", defaultValue: false }, + thumbWidth: { description: "Thumb width (e.g. '20px')", type: "string" }, + background: { description: "Track background color", type: "string", defaultValue: "black" }, + borderColor: { description: "Track border color", type: "string", defaultValue: "white" }, + thumbColor: { description: "Thumb color", type: "string" }, + invertScrollDirection: { description: "Invert scroll direction", type: "boolean", defaultValue: false }, + }, + }, + + // ImageScrollBar is exported by GUI, but is not registered for GUI serialization, so it is not an MCP-creatable catalog entry. + + // ─── Image ──────────────────────────────────────────────────────── + Image: { + className: "Image", + category: "Image", + description: "Displays an image from a URL. Supports stretching, 9-patch, and sprite sheets.", + isContainer: false, + properties: { + source: { description: "URL of the image to display", type: "string" }, + stretch: { + description: "Stretch mode: 0=NONE, 1=FILL, 2=UNIFORM, 3=EXTEND, 4=NINE_PATCH", + type: "number", + defaultValue: 1, + }, + autoScale: { description: "Auto-scale image to fit control", type: "boolean", defaultValue: false }, + // 9-patch + sliceLeft: { description: "9-patch left slice position", type: "number" }, + sliceRight: { description: "9-patch right slice position", type: "number" }, + sliceTop: { description: "9-patch top slice position", type: "number" }, + sliceBottom: { description: "9-patch bottom slice position", type: "number" }, + // Sprite sheet + cellWidth: { description: "Sprite cell width", type: "number", defaultValue: 0 }, + cellHeight: { description: "Sprite cell height", type: "number", defaultValue: 0 }, + cellId: { description: "Current sprite cell index", type: "number", defaultValue: -1 }, + // Source crop + sourceLeft: { description: "Source crop X offset", type: "number", defaultValue: 0 }, + sourceTop: { description: "Source crop Y offset", type: "number", defaultValue: 0 }, + sourceWidth: { description: "Source crop width (0 = full image)", type: "number", defaultValue: 0 }, + sourceHeight: { description: "Source crop height (0 = full image)", type: "number", defaultValue: 0 }, + detectPointerOnOpaqueOnly: { description: "Only register pointer events over opaque pixels", type: "boolean", defaultValue: false }, + }, + }, + + // ─── Shape controls ─────────────────────────────────────────────── + Line: { + className: "Line", + category: "Shape", + description: "Draws a line between two points.", + isContainer: false, + properties: { + x1: { description: "Start X coordinate", type: "number" }, + y1: { description: "Start Y coordinate", type: "number" }, + x2: { description: "End X coordinate", type: "number" }, + y2: { description: "End Y coordinate", type: "number" }, + lineWidth: { description: "Line thickness", type: "number", defaultValue: 1 }, + dash: { description: "Dash pattern as JSON array e.g. [5, 10]", type: "object" }, + }, + }, + + MultiLine: { + className: "MultiLine", + category: "Shape", + description: "Draws a polyline made of multiple connected points.", + isContainer: false, + properties: { + lineWidth: { description: "Line thickness", type: "number", defaultValue: 1 }, + color: { description: "Line color", type: "string", defaultValue: "white" }, + dash: { description: "Dash pattern as JSON array e.g. [5, 10]", type: "object" }, + }, + }, + + // ─── Misc ──────────────────────────────────────────────────────── + DisplayGrid: { + className: "DisplayGrid", + category: "Misc", + description: "Renders a visual grid overlay (decorative, not a layout container).", + isContainer: false, + properties: { + cellWidth: { description: "Width of each grid cell", type: "number", defaultValue: 20 }, + cellHeight: { description: "Height of each grid cell", type: "number", defaultValue: 20 }, + minorLineTickness: { description: "Minor grid line thickness", type: "number", defaultValue: 1 }, + minorLineColor: { description: "Minor grid line color", type: "string", defaultValue: "DarkGray" }, + majorLineTickness: { description: "Major grid line thickness", type: "number", defaultValue: 2 }, + majorLineColor: { description: "Major grid line color", type: "string", defaultValue: "white" }, + majorLineFrequency: { description: "Show major line every N cells", type: "number", defaultValue: 5 }, + background: { description: "Background color", type: "string", defaultValue: "Black" }, + displayMajorLines: { description: "Show major lines", type: "boolean", defaultValue: true }, + displayMinorLines: { description: "Show minor lines", type: "boolean", defaultValue: true }, + }, + }, + + VirtualKeyboard: { + className: "VirtualKeyboard", + category: "Misc", + description: "On-screen virtual keyboard. Extends StackPanel.", + isContainer: true, + properties: { + background: { description: "Background fill color", type: "string" }, + isVertical: { description: "Stack direction", type: "boolean", defaultValue: true }, + defaultButtonWidth: { description: "Default key width", type: "string" }, + defaultButtonHeight: { description: "Default key height", type: "string" }, + defaultButtonColor: { description: "Default key text color", type: "string" }, + defaultButtonBackground: { description: "Default key background color", type: "string" }, + shiftButtonColor: { description: "Shift key color", type: "string" }, + }, + }, +}; + +// ─── Helpers ────────────────────────────────────────────────────────────── + +/** + * Returns a Markdown-formatted summary of all control types, grouped by category. + * @returns Markdown string with controls grouped by category + */ +export function GetControlCatalogSummary(): string { + const byCategory = new Map(); + for (const [key, info] of Object.entries(ControlRegistry)) { + const cat = info.category; + if (!byCategory.has(cat)) { + byCategory.set(cat, []); + } + byCategory.get(cat)!.push(` ${key}: ${info.description.split(".")[0]}`); + } + + const sections: string[] = []; + for (const [cat, entries] of byCategory) { + sections.push(`## ${cat}\n${entries.join("\n")}`); + } + return sections.join("\n\n"); +} + +/** + * Returns detailed info for a specific control type, or undefined if not found. + * @param typeName - the control type name to look up in the registry + * @returns the control type info, or undefined if not found + */ +export function GetControlTypeDetails(typeName: string): IControlTypeInfo | undefined { + return ControlRegistry[typeName]; +} diff --git a/packages/tools/gui-mcp-server/src/guiManager.ts b/packages/tools/gui-mcp-server/src/guiManager.ts new file mode 100644 index 00000000000..874ac85b983 --- /dev/null +++ b/packages/tools/gui-mcp-server/src/guiManager.ts @@ -0,0 +1,1165 @@ +/* eslint-disable babylonjs/available */ +/** + * GuiManager – holds an in-memory representation of one or more + * AdvancedDynamicTexture GUIs that the MCP tools build up incrementally. + * When the user is satisfied the GUI can be exported as native Babylon.js + * GUI JSON that `AdvancedDynamicTexture.parseSerializedObject()` understands. + * + * Design goals + * ──────────── + * 1. **No Babylon.js runtime dependency** – pure JSON data model. + * 2. **Mirrors the serialisation format** so export is essentially a direct dump. + * 3. **Stateful & idempotent** – controls can be added, removed, moved, etc. + */ + +import { ValidateGuiAttachmentPayload } from "@tools/mcp-server-core"; + +import { ControlRegistry, BaseControlProperties, type IControlTypeInfo } from "./catalog.js"; + +// ─── Types ──────────────────────────────────────────────────────────────── + +/** + * Serialized form of a single GUI control. + * This mirrors what `Control.serialize()` produces. + */ +export interface ISerializedControl { + /** Unique name within the GUI */ + name: string; + /** Babylon.js className string (e.g. "TextBlock", "Rectangle") */ + className: string; + /** Children (only present on containers) */ + children?: ISerializedControl[]; + /** For Grid – row definitions */ + rows?: Array<{ value: number; unit: number }>; + /** For Grid – column definitions */ + columns?: Array<{ value: number; unit: number }>; + /** For Grid children – their cell position tags */ + tags?: string[]; + /** For Button – name of the internal TextBlock child */ + textBlockName?: string; + /** For Button – name of the internal Image child */ + imageName?: string; + /** All other serialized properties (color, fontSize, text, etc.) */ + [key: string]: unknown; +} + +/** + * Represents a complete GUI texture in memory. + */ +export interface IGuiTexture { + /** Unique name */ + name: string; + /** Width of the texture in pixels */ + width: number; + /** Height of the texture in pixels */ + height: number; + /** Whether this is fullscreen GUI */ + isFullscreen: boolean; + /** Optional ideal width for adaptive scaling */ + idealWidth?: number; + /** Optional ideal height for adaptive scaling */ + idealHeight?: number; + /** The root container */ + root: ISerializedControl; + /** Next auto-incrementing control id (internal) */ + _nextId: number; + /** Flat index of all controls by name for fast lookup */ + _controlIndex: Map; + /** Map from child name → parent name for hierarchy traversal */ + _parentIndex: Map; + /** For Grid: map from child name → "row:column" */ + _gridCellIndex: Map; +} + +// ─── Manager ────────────────────────────────────────────────────────────── + +/** + * + */ +export class GuiManager { + private _textures = new Map(); + + // ── Texture lifecycle ───────────────────────────────────────────── + + /** + * Create a new GUI texture (AdvancedDynamicTexture) in memory. + * @param name - The name for the new texture + * @param options - Optional configuration for width, height, fullscreen, etc. + */ + createTexture( + name: string, + options?: { + /** + * + */ + width?: number /** + * + */; + height?: number /** + * + */; + isFullscreen?: boolean /** + * + */; + idealWidth?: number /** + * + */; + idealHeight?: number; + } + ): void { + const width = options?.width ?? 1920; + const height = options?.height ?? 1080; + const isFullscreen = options?.isFullscreen ?? true; + + const root: ISerializedControl = { + name: "root", + className: "Container", + children: [], + }; + + const tex: IGuiTexture = { + name, + width, + height, + isFullscreen, + idealWidth: options?.idealWidth, + idealHeight: options?.idealHeight, + root, + _nextId: 1, + _controlIndex: new Map([["root", root]]), + _parentIndex: new Map(), + _gridCellIndex: new Map(), + }; + + this._textures.set(name, tex); + } + + deleteTexture(name: string): boolean { + return this._textures.delete(name); + } + + /** + * Remove all GUI textures from memory, resetting the manager to its initial state. + */ + clearAll(): void { + this._textures.clear(); + } + + listTextures(): string[] { + return Array.from(this._textures.keys()); + } + + getTexture(name: string): IGuiTexture | undefined { + return this._textures.get(name); + } + + // ── Control CRUD ────────────────────────────────────────────────── + + /** + * Add a control to the GUI. + * @param textureName - The name of the target GUI texture + * @param controlType - The type of control to create + * @param controlName - Optional custom name for the control + * @param parentName - Optional parent control name (defaults to "root") + * @param properties - Optional properties to set on the control + * @param gridRow - Optional grid row index for Grid placement + * @param gridColumn - Optional grid column index for Grid placement + * @returns The created control's name, or an error string. + */ + addControl( + textureName: string, + controlType: string, + controlName?: string, + parentName?: string, + properties?: Record, + gridRow?: number, + gridColumn?: number + ): + | { + /** + * + */ + name: string; + /** + * + */ + warnings?: string[]; + } + | string { + const tex = this._textures.get(textureName); + if (!tex) { + return `GUI texture "${textureName}" not found.`; + } + + const typeInfo = ControlRegistry[controlType]; + if (!typeInfo) { + return `Unknown control type "${controlType}". Use list_control_types to see available types.`; + } + + // Generate or validate name + const name = controlName ?? `${controlType.toLowerCase()}_${tex._nextId}`; + if (tex._controlIndex.has(name)) { + return `A control named "${name}" already exists in this GUI.`; + } + + // Build the serialized control + const ctrl: ISerializedControl = { + name, + className: typeInfo.className, + }; + + // Containers get a children array + if (typeInfo.isContainer) { + ctrl.children = []; + } + + // Grid gets empty row/column definitions and tags + if (controlType === "Grid") { + ctrl.rows = []; + ctrl.columns = []; + ctrl.tags = []; + } + + // Apply user properties + if (properties) { + this._applyProperties(ctrl, typeInfo, properties); + } + + // Special handling for Button with inline text/image + if (controlType === "Button" || controlType === "FocusableButton") { + this._initButtonChildren(ctrl, tex, properties); + } + + // Determine parent + const targetParent = parentName ?? "root"; + const parent = tex._controlIndex.get(targetParent); + if (!parent) { + return `Parent "${targetParent}" not found.`; + } + if (!parent.children) { + const pType = ControlRegistry[parent.className]; + if (!pType?.isContainer) { + return `Parent "${targetParent}" (${parent.className}) is not a container and cannot hold children.`; + } + parent.children = []; + } + + // Grid cell placement + if (parent.className === "Grid" && (gridRow !== undefined || gridColumn !== undefined)) { + const row = gridRow ?? 0; + const col = gridColumn ?? 0; + const tag = `${row}:${col}`; + tex._gridCellIndex.set(name, tag); + if (!parent.tags) { + parent.tags = []; + } + parent.tags.push(tag); + } + + parent.children.push(ctrl); + tex._controlIndex.set(name, ctrl); + tex._parentIndex.set(name, targetParent); + tex._nextId++; + + // ── Warnings ────────────────────────────────────────────────── + const warnings: string[] = []; + + // Warn about Grid children without explicit cell placement + if (parent.className === "Grid" && gridRow === undefined && gridColumn === undefined) { + warnings.push( + `⚠ Control "${name}" was added to Grid "${targetParent}" without specifying gridRow/gridColumn. ` + + `It will default to cell [0, 0]. Set gridRow and gridColumn explicitly to place it correctly.` + ); + } + + // Warn about Grid children when Grid has no rows/columns defined + if (parent.className === "Grid") { + if (!parent.rows || parent.rows.length === 0) { + warnings.push(`⚠ Grid "${targetParent}" has no row definitions yet. Use add_grid_row to define rows before adding children.`); + } + if (!parent.columns || parent.columns.length === 0) { + warnings.push(`⚠ Grid "${targetParent}" has no column definitions yet. Use add_grid_column to define columns before adding children.`); + } + } + + // Warn about Button without buttonText + if ((controlType === "Button" || controlType === "FocusableButton") && (!properties || properties.buttonText === undefined)) { + warnings.push(`⚠ Button "${name}" has no buttonText. Set buttonText in properties to give it a label.`); + } + + return { name, warnings: warnings.length > 0 ? warnings : undefined }; + } + + /** + * Remove a control (and all its descendants) from the GUI. + * @param textureName - The name of the target GUI texture + * @param controlName - The name of the control to remove + * @returns Status or error message + */ + removeControl(textureName: string, controlName: string): string { + const tex = this._textures.get(textureName); + if (!tex) { + return `GUI texture "${textureName}" not found.`; + } + if (controlName === "root") { + return "Cannot remove the root container."; + } + + const ctrl = tex._controlIndex.get(controlName); + if (!ctrl) { + return `Control "${controlName}" not found.`; + } + + // Remove from parent + const parentName = tex._parentIndex.get(controlName); + if (parentName) { + const parent = tex._controlIndex.get(parentName); + if (parent?.children) { + const idx = parent.children.indexOf(ctrl); + if (idx >= 0) { + parent.children.splice(idx, 1); + // Also remove the corresponding Grid tag + if (parent.tags && parent.className === "Grid") { + parent.tags.splice(idx, 1); + } + } + } + } + + // Remove ctrl and all descendants from indices + this._removeFromIndexRecursive(tex, controlName, ctrl); + + return "OK"; + } + + /** + * Move a control to a different parent. + * @param textureName - The name of the target GUI texture + * @param controlName - The name of the control to move + * @param newParentName - The name of the new parent control + * @param gridRow - Optional grid row index for Grid placement + * @param gridColumn - Optional grid column index for Grid placement + * @returns Status or error message + */ + reparentControl(textureName: string, controlName: string, newParentName: string, gridRow?: number, gridColumn?: number): string { + const tex = this._textures.get(textureName); + if (!tex) { + return `GUI texture "${textureName}" not found.`; + } + if (controlName === "root") { + return "Cannot reparent the root container."; + } + + const ctrl = tex._controlIndex.get(controlName); + if (!ctrl) { + return `Control "${controlName}" not found.`; + } + + const newParent = tex._controlIndex.get(newParentName); + if (!newParent) { + return `New parent "${newParentName}" not found.`; + } + + const newParentType = ControlRegistry[newParent.className]; + if (!newParentType?.isContainer && !newParent.children) { + return `"${newParentName}" (${newParent.className}) is not a container.`; + } + + // Check for circular reference + if (this._isDescendantOf(tex, newParentName, controlName)) { + return `Cannot reparent: "${newParentName}" is a descendant of "${controlName}".`; + } + + // Remove from old parent + const oldParentName = tex._parentIndex.get(controlName); + if (oldParentName) { + const oldParent = tex._controlIndex.get(oldParentName); + if (oldParent?.children) { + const idx = oldParent.children.indexOf(ctrl); + if (idx >= 0) { + oldParent.children.splice(idx, 1); + if (oldParent.tags && oldParent.className === "Grid") { + oldParent.tags.splice(idx, 1); + } + } + } + } + + // Add to new parent + if (!newParent.children) { + newParent.children = []; + } + + if (newParent.className === "Grid" && (gridRow !== undefined || gridColumn !== undefined)) { + const row = gridRow ?? 0; + const col = gridColumn ?? 0; + const tag = `${row}:${col}`; + tex._gridCellIndex.set(controlName, tag); + if (!newParent.tags) { + newParent.tags = []; + } + newParent.tags.push(tag); + } else { + // Remove old grid tag if any + tex._gridCellIndex.delete(controlName); + } + + newParent.children.push(ctrl); + tex._parentIndex.set(controlName, newParentName); + + return "OK"; + } + + /** + * Set properties on a control. + * @param textureName - The name of the target GUI texture + * @param controlName - The name of the control to update + * @param properties - Key-value pairs of properties to set + * @returns Status or error message + */ + setControlProperties(textureName: string, controlName: string, properties: Record): string { + const tex = this._textures.get(textureName); + if (!tex) { + return `GUI texture "${textureName}" not found.`; + } + + const ctrl = tex._controlIndex.get(controlName); + if (!ctrl) { + return `Control "${controlName}" not found.`; + } + + const typeInfo = ControlRegistry[ctrl.className]; + this._applyProperties(ctrl, typeInfo, properties); + + // Update Button internal children if relevant + if ((ctrl.className === "Button" || ctrl.className === "FocusableButton") && ctrl.children) { + if (properties.buttonText !== undefined) { + const textChild = ctrl.children.find((c) => c.className === "TextBlock"); + if (textChild) { + textChild.text = properties.buttonText as string; + } + } + if (properties.buttonImage !== undefined) { + const imgChild = ctrl.children.find((c) => c.className === "Image"); + if (imgChild) { + imgChild.source = properties.buttonImage as string; + } + } + } + + return "OK"; + } + + // ── Grid operations ─────────────────────────────────────────────── + + /** + * Add a row definition to a Grid control. + * @param textureName - The name of the target GUI texture + * @param gridName - The name of the Grid control + * @param value - Size value (fraction 0–1 or pixel size) + * @param isPixel - If true, value is in pixels; if false, it's a fraction + * @returns Status or error message + */ + addGridRow(textureName: string, gridName: string, value: number, isPixel: boolean): string { + const tex = this._textures.get(textureName); + if (!tex) { + return `GUI texture "${textureName}" not found.`; + } + + const grid = tex._controlIndex.get(gridName); + if (!grid) { + return `Control "${gridName}" not found.`; + } + if (grid.className !== "Grid") { + return `"${gridName}" is not a Grid.`; + } + + if (!grid.rows) { + grid.rows = []; + } + grid.rows.push({ value, unit: isPixel ? 1 : 0 }); + return "OK"; + } + + /** + * Add a column definition to a Grid control. + * @param textureName - The name of the target GUI texture + * @param gridName - The name of the Grid control + * @param value - Size value (fraction 0–1 or pixel size) + * @param isPixel - If true, value is in pixels; if false, it's a fraction + * @returns Status or error message + */ + addGridColumn(textureName: string, gridName: string, value: number, isPixel: boolean): string { + const tex = this._textures.get(textureName); + if (!tex) { + return `GUI texture "${textureName}" not found.`; + } + + const grid = tex._controlIndex.get(gridName); + if (!grid) { + return `Control "${gridName}" not found.`; + } + if (grid.className !== "Grid") { + return `"${gridName}" is not a Grid.`; + } + + if (!grid.columns) { + grid.columns = []; + } + grid.columns.push({ value, unit: isPixel ? 1 : 0 }); + return "OK"; + } + + /** + * Update an existing row definition. + * @param textureName - The name of the target GUI texture + * @param gridName - The name of the Grid control + * @param index - The row index to update + * @param value - New size value (fraction 0–1 or pixel size) + * @param isPixel - If true, value is in pixels; if false, it's a fraction + * @returns Status or error message + */ + setGridRow(textureName: string, gridName: string, index: number, value: number, isPixel: boolean): string { + const tex = this._textures.get(textureName); + if (!tex) { + return `GUI texture "${textureName}" not found.`; + } + + const grid = tex._controlIndex.get(gridName); + if (!grid || grid.className !== "Grid") { + return `"${gridName}" is not a Grid.`; + } + if (!grid.rows || index < 0 || index >= grid.rows.length) { + return `Row index ${index} out of range.`; + } + + grid.rows[index] = { value, unit: isPixel ? 1 : 0 }; + return "OK"; + } + + /** + * Update an existing column definition. + * @param textureName - The name of the target GUI texture + * @param gridName - The name of the Grid control + * @param index - The column index to update + * @param value - New size value (fraction 0–1 or pixel size) + * @param isPixel - If true, value is in pixels; if false, it's a fraction + * @returns Status or error message + */ + setGridColumn(textureName: string, gridName: string, index: number, value: number, isPixel: boolean): string { + const tex = this._textures.get(textureName); + if (!tex) { + return `GUI texture "${textureName}" not found.`; + } + + const grid = tex._controlIndex.get(gridName); + if (!grid || grid.className !== "Grid") { + return `"${gridName}" is not a Grid.`; + } + if (!grid.columns || index < 0 || index >= grid.columns.length) { + return `Column index ${index} out of range.`; + } + + grid.columns[index] = { value, unit: isPixel ? 1 : 0 }; + return "OK"; + } + + /** + * Remove a row definition from a Grid. + * @param textureName - The name of the target GUI texture + * @param gridName - The name of the Grid control + * @param index - The row index to remove + * @returns Status or error message + */ + removeGridRow(textureName: string, gridName: string, index: number): string { + const tex = this._textures.get(textureName); + if (!tex) { + return `GUI texture "${textureName}" not found.`; + } + + const grid = tex._controlIndex.get(gridName); + if (!grid || grid.className !== "Grid") { + return `"${gridName}" is not a Grid.`; + } + if (!grid.rows || index < 0 || index >= grid.rows.length) { + return `Row index ${index} out of range.`; + } + + grid.rows.splice(index, 1); + return "OK"; + } + + /** + * Remove a column definition from a Grid. + * @param textureName - The name of the target GUI texture + * @param gridName - The name of the Grid control + * @param index - The column index to remove + * @returns Status or error message + */ + removeGridColumn(textureName: string, gridName: string, index: number): string { + const tex = this._textures.get(textureName); + if (!tex) { + return `GUI texture "${textureName}" not found.`; + } + + const grid = tex._controlIndex.get(gridName); + if (!grid || grid.className !== "Grid") { + return `"${gridName}" is not a Grid.`; + } + if (!grid.columns || index < 0 || index >= grid.columns.length) { + return `Column index ${index} out of range.`; + } + + grid.columns.splice(index, 1); + return "OK"; + } + + // ── Query / Describe ────────────────────────────────────────────── + + /** + * Get a human-readable description of the full GUI texture. + * @param textureName - The name of the target GUI texture + * @returns A formatted string describing the texture and its control tree + */ + describeTexture(textureName: string): string { + const tex = this._textures.get(textureName); + if (!tex) { + return `GUI texture "${textureName}" not found.`; + } + + const lines: string[] = []; + lines.push(`# GUI: ${tex.name}`); + lines.push(`Size: ${tex.width}×${tex.height} | Fullscreen: ${tex.isFullscreen}`); + if (tex.idealWidth) { + lines.push(`Ideal width: ${tex.idealWidth}`); + } + if (tex.idealHeight) { + lines.push(`Ideal height: ${tex.idealHeight}`); + } + lines.push(`Controls: ${tex._controlIndex.size}`); + lines.push(""); + lines.push("## Control Tree"); + this._describeControlTree(tex, tex.root, lines, 0); + + return lines.join("\n"); + } + + /** + * Get detailed info about a single control. + * @param textureName - The name of the target GUI texture + * @param controlName - The name of the control to describe + * @returns A formatted string with control details + */ + describeControl(textureName: string, controlName: string): string { + const tex = this._textures.get(textureName); + if (!tex) { + return `GUI texture "${textureName}" not found.`; + } + + const ctrl = tex._controlIndex.get(controlName); + if (!ctrl) { + return `Control "${controlName}" not found.`; + } + + const lines: string[] = []; + lines.push(`## ${ctrl.name} (${ctrl.className})`); + + const parentName = tex._parentIndex.get(controlName) ?? "(root)"; + lines.push(`Parent: ${parentName}`); + + const gridCell = tex._gridCellIndex.get(controlName); + if (gridCell) { + lines.push(`Grid cell: row ${gridCell.split(":")[0]}, column ${gridCell.split(":")[1]}`); + } + + // List all set properties + lines.push("\n### Properties"); + for (const [key, value] of Object.entries(ctrl)) { + if (key === "name" || key === "className" || key === "children" || key === "rows" || key === "columns" || key === "tags") { + continue; + } + lines.push(` ${key}: ${JSON.stringify(value)}`); + } + + // List children if container + if (ctrl.children && ctrl.children.length > 0) { + lines.push(`\n### Children (${ctrl.children.length})`); + for (const child of ctrl.children) { + const cell = tex._gridCellIndex.get(child.name); + const cellStr = cell ? ` [cell ${cell}]` : ""; + lines.push(` • ${child.name} (${child.className})${cellStr}`); + } + } + + // Grid definitions + if (ctrl.className === "Grid") { + if (ctrl.rows && ctrl.rows.length > 0) { + lines.push(`\n### Rows (${ctrl.rows.length})`); + ctrl.rows.forEach((r, i) => { + const row = r as { + /** + * + */ + value: number /** + * + */; + unit: number; + }; + lines.push(` [${i}] ${row.value}${row.unit === 1 ? "px" : ""}`); + }); + } + if (ctrl.columns && ctrl.columns.length > 0) { + lines.push(`\n### Columns (${ctrl.columns.length})`); + ctrl.columns.forEach((c, i) => { + const col = c as { + /** + * + */ + value: number /** + * + */; + unit: number; + }; + lines.push(` [${i}] ${col.value}${col.unit === 1 ? "px" : ""}`); + }); + } + } + + return lines.join("\n"); + } + + // ── Validation ──────────────────────────────────────────────────── + + /** + * Run basic validation checks on the GUI. + * @param textureName - The name of the target GUI texture + * @returns An array of validation issue strings + */ + validateTexture(textureName: string): string[] { + const tex = this._textures.get(textureName); + if (!tex) { + return [`GUI texture "${textureName}" not found.`]; + } + + const issues: string[] = []; + + // Check for empty GUI + if (!tex.root.children || tex.root.children.length === 0) { + issues.push("WARNING: GUI has no controls — root container is empty."); + } + + // Check Grid consistency + this._validateGrids(tex, tex.root, issues); + + // Check for name collisions (shouldn't happen with our index, but sanity check) + const names = new Set(); + this._collectNames(tex.root, names, issues); + + // Check for controls with no size (common mistake) + for (const [, ctrl] of tex._controlIndex) { + if (ctrl.name === "root") { + continue; + } + // Only warn about non-container leaf nodes that haven't set width/height + const typeInfo = ControlRegistry[ctrl.className]; + if (typeInfo && !typeInfo.isContainer && ctrl.width === undefined && ctrl.height === undefined) { + // This is fine — they inherit from parent. But if they're direct children of root... + const parent = tex._parentIndex.get(ctrl.name); + if (parent === "root") { + // Still okay — they'll stretch to root. Don't warn. + } + } + + // Warn about Buttons without text + if (ctrl.className === "Button" || ctrl.className === "FocusableButton") { + const hasTextChild = ctrl.children?.some((c) => c.className === "TextBlock"); + if (!hasTextChild) { + issues.push(`WARNING: Button "${ctrl.name}" has no text label. Set buttonText when creating it or add a TextBlock child.`); + } + } + } + + // Check consistency between index and tree + const treeNames = new Set(); + this._collectAllNames(tex.root, treeNames); + for (const [name] of tex._controlIndex) { + if (name !== "root" && !treeNames.has(name)) { + issues.push(`ERROR: Control "${name}" is in the index but not in the tree.`); + } + } + + if (issues.length === 0) { + issues.push("No issues found — GUI looks valid."); + } + + return issues; + } + + // ── Export / Import ─────────────────────────────────────────────── + + /** + * Export the GUI as Babylon.js-compatible JSON. + * This JSON can be loaded with `AdvancedDynamicTexture.parseSerializedObject()`. + * @param textureName - The name of the target GUI texture + * @returns The serialized JSON string, or null if the texture was not found + */ + exportJSON(textureName: string): string | null { + const tex = this._textures.get(textureName); + if (!tex) { + return null; + } + + // Deep-clone the root, stripping internal properties + const root = this._cloneControlForExport(tex, tex.root); + + const result: Record = { + root, + }; + + // Include texture dimensions + if (tex.width) { + result.width = tex.width; + } + if (tex.height) { + result.height = tex.height; + } + + return JSON.stringify(result, null, 2); + } + + /** + * Import a GUI JSON into memory for editing. + * @param textureName - The name to assign to the imported GUI texture + * @param json - The Babylon.js GUI JSON string to import + * @returns Status or error message + */ + importJSON(textureName: string, json: string): string { + try { + const parsed = ValidateGuiAttachmentPayload(json); + + const width = typeof parsed.width === "number" ? parsed.width : 1920; + const height = typeof parsed.height === "number" ? parsed.height : 1080; + + this.createTexture(textureName, { width, height, isFullscreen: true }); + const tex = this._textures.get(textureName)!; + + // Replace root with imported data + tex.root = parsed.root as ISerializedControl; + tex._controlIndex.clear(); + tex._parentIndex.clear(); + tex._gridCellIndex.clear(); + + // Re-index + this._indexControlTree(tex, tex.root, undefined); + + return "OK"; + } catch (e) { + return (e as Error).message; + } + } + + // ── Private helpers ─────────────────────────────────────────────── + + /** + * Apply a bag of properties to a serialized control. + * @param ctrl - The target serialized control + * @param typeInfo - The control type info from the registry, or undefined + * @param properties - Key-value pairs of properties to apply + */ + private _applyProperties(ctrl: ISerializedControl, typeInfo: IControlTypeInfo | undefined, properties: Record): void { + for (const [key, value] of Object.entries(properties)) { + // Skip special button-creation props handled separately + if (key === "buttonText" || key === "buttonImage") { + continue; + } + + // Validate against catalog if we have type info + if (typeInfo) { + const isBaseProperty = key in BaseControlProperties; + const isTypeProperty = key in typeInfo.properties; + // Allow setting even non-catalog properties — the serializer is flexible + if (!isBaseProperty && !isTypeProperty) { + // Silently accept for extensibility + } + } + + ctrl[key] = value; + } + } + + /** + * Create internal TextBlock / Image children for a Button. + * @param ctrl - The button control to initialize + * @param tex - The parent GUI texture + * @param properties - Optional properties containing buttonText and buttonImage + */ + private _initButtonChildren(ctrl: ISerializedControl, tex: IGuiTexture, properties?: Record): void { + const btnName = ctrl.name; + + // Text child + const textName = `${btnName}_text`; + const textChild: ISerializedControl = { + name: textName, + className: "TextBlock", + text: (properties?.buttonText as string) ?? "", + }; + ctrl.children!.push(textChild); + ctrl.textBlockName = textName; + tex._controlIndex.set(textName, textChild); + tex._parentIndex.set(textName, btnName); + + // Image child (only if an image URL was provided) + if (properties?.buttonImage) { + const imgName = `${btnName}_image`; + const imgChild: ISerializedControl = { + name: imgName, + className: "Image", + source: properties.buttonImage as string, + }; + ctrl.children!.push(imgChild); + ctrl.imageName = imgName; + tex._controlIndex.set(imgName, imgChild); + tex._parentIndex.set(imgName, btnName); + } + } + + /** + * Recursively remove a control and descendants from the indexes. + * @param tex - The parent GUI texture + * @param name - The name of the control to remove + * @param ctrl - The serialized control to remove + */ + private _removeFromIndexRecursive(tex: IGuiTexture, name: string, ctrl: ISerializedControl): void { + if (ctrl.children) { + for (const child of ctrl.children) { + this._removeFromIndexRecursive(tex, child.name, child); + } + } + tex._controlIndex.delete(name); + tex._parentIndex.delete(name); + tex._gridCellIndex.delete(name); + } + + /** + * Check if targetName is a descendant of ancestorName. + * @param tex - The parent GUI texture + * @param targetName - The name of the potential descendant + * @param ancestorName - The name of the potential ancestor + * @returns True if targetName is a descendant of ancestorName + */ + private _isDescendantOf(tex: IGuiTexture, targetName: string, ancestorName: string): boolean { + let current: string | undefined = targetName; + while (current) { + if (current === ancestorName) { + return true; + } + current = tex._parentIndex.get(current); + } + return false; + } + + /** + * Build a human-readable tree view of the control hierarchy. + * @param tex - The parent GUI texture + * @param ctrl - The current control to describe + * @param lines - The output lines array to append to + * @param depth - The current indentation depth + */ + private _describeControlTree(tex: IGuiTexture, ctrl: ISerializedControl, lines: string[], depth: number): void { + const indent = " ".repeat(depth); + const gridCell = tex._gridCellIndex.get(ctrl.name); + const cellStr = gridCell ? ` [cell ${gridCell}]` : ""; + + // Collect a brief summary of set properties + const propSummary: string[] = []; + for (const [key, value] of Object.entries(ctrl)) { + if (["name", "className", "children", "rows", "columns", "tags", "textBlockName", "imageName"].includes(key)) { + continue; + } + if (value === undefined || value === null) { + continue; + } + const str = typeof value === "string" ? `"${value}"` : JSON.stringify(value); + propSummary.push(`${key}=${str}`); + } + const propsStr = propSummary.length > 0 ? ` {${propSummary.join(", ")}}` : ""; + + lines.push(`${indent}${ctrl.name} (${ctrl.className})${cellStr}${propsStr}`); + + // Grid row/column defs + if (ctrl.className === "Grid") { + if (ctrl.rows && ctrl.rows.length > 0) { + const rowStrs = ctrl.rows.map((r) => { + const row = r as { + /** + * + */ + value: number /** + * + */; + unit: number; + }; + return `${row.value}${row.unit === 1 ? "px" : ""}`; + }); + lines.push(`${indent} rows: [${rowStrs.join(", ")}]`); + } + if (ctrl.columns && ctrl.columns.length > 0) { + const colStrs = ctrl.columns.map((c) => { + const col = c as { + /** + * + */ + value: number /** + * + */; + unit: number; + }; + return `${col.value}${col.unit === 1 ? "px" : ""}`; + }); + lines.push(`${indent} columns: [${colStrs.join(", ")}]`); + } + } + + if (ctrl.children) { + for (const child of ctrl.children) { + this._describeControlTree(tex, child, lines, depth + 1); + } + } + } + + /** + * Validate Grid controls for consistency. + * @param tex - The parent GUI texture + * @param ctrl - The current control to validate + * @param issues - The array to collect validation issues into + */ + private _validateGrids(tex: IGuiTexture, ctrl: ISerializedControl, issues: string[]): void { + if (ctrl.className === "Grid") { + if (!ctrl.rows || ctrl.rows.length === 0) { + issues.push(`WARNING: Grid "${ctrl.name}" has no row definitions. Add at least one row.`); + } + if (!ctrl.columns || ctrl.columns.length === 0) { + issues.push(`WARNING: Grid "${ctrl.name}" has no column definitions. Add at least one column.`); + } + + // Check that grid children have valid cell tags + if (ctrl.children && ctrl.rows && ctrl.columns) { + for (const child of ctrl.children) { + const cell = tex._gridCellIndex.get(child.name); + if (!cell) { + issues.push(`WARNING: Grid child "${child.name}" has no cell assignment.`); + } else { + const [rowStr, colStr] = cell.split(":"); + const row = parseInt(rowStr); + const col = parseInt(colStr); + if (row >= ctrl.rows.length) { + issues.push(`WARNING: Grid child "${child.name}" assigned to row ${row}, but grid has only ${ctrl.rows.length} rows.`); + } + if (col >= ctrl.columns.length) { + issues.push(`WARNING: Grid child "${child.name}" assigned to column ${col}, but grid has only ${ctrl.columns.length} columns.`); + } + } + } + } + } + + if (ctrl.children) { + for (const child of ctrl.children) { + this._validateGrids(tex, child, issues); + } + } + } + + /** + * Collect names and detect duplicates. + * @param ctrl - The current control to process + * @param seen - Set of names already seen + * @param issues - The array to collect duplicate name errors into + */ + private _collectNames(ctrl: ISerializedControl, seen: Set, issues: string[]): void { + if (seen.has(ctrl.name)) { + issues.push(`ERROR: Duplicate control name "${ctrl.name}".`); + } + seen.add(ctrl.name); + if (ctrl.children) { + for (const child of ctrl.children) { + this._collectNames(child, seen, issues); + } + } + } + + /** + * Collect all control names from the tree. + * @param ctrl - The current control to process + * @param names - Set to collect control names into + */ + private _collectAllNames(ctrl: ISerializedControl, names: Set): void { + names.add(ctrl.name); + if (ctrl.children) { + for (const child of ctrl.children) { + this._collectAllNames(child, names); + } + } + } + + /** + * Deep-clone a control for export, stripping internal properties. + * @param tex - The parent GUI texture + * @param ctrl - The control to clone + * @returns A plain object suitable for JSON serialization + */ + private _cloneControlForExport(tex: IGuiTexture, ctrl: ISerializedControl): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(ctrl)) { + if (key === "children") { + continue; + } // handled below + result[key] = value; + } + + // Grid: Babylon.js expects rowCount / columnCount for parsing + if (ctrl.className === "Grid") { + if (ctrl.rows) { + result.rowCount = ctrl.rows.length; + } + if (ctrl.columns) { + result.columnCount = ctrl.columns.length; + } + } + + if (ctrl.children && ctrl.children.length > 0) { + result.children = ctrl.children.map((child) => this._cloneControlForExport(tex, child)); + } + + return result; + } + + /** + * Re-index all controls from an imported tree. + * @param tex - The parent GUI texture + * @param ctrl - The current control to index + * @param parentName - The name of the parent control, or undefined for root + */ + private _indexControlTree(tex: IGuiTexture, ctrl: ISerializedControl, parentName: string | undefined): void { + tex._controlIndex.set(ctrl.name, ctrl); + if (parentName) { + tex._parentIndex.set(ctrl.name, parentName); + } + + // Handle Grid tags + if (ctrl.tags && ctrl.children) { + for (let i = 0; i < ctrl.children.length; i++) { + if (i < ctrl.tags.length) { + tex._gridCellIndex.set(ctrl.children[i].name, ctrl.tags[i] as string); + } + } + } + + if (ctrl.children) { + for (const child of ctrl.children) { + this._indexControlTree(tex, child, ctrl.name); + } + } + } +} diff --git a/packages/tools/gui-mcp-server/src/index.ts b/packages/tools/gui-mcp-server/src/index.ts new file mode 100644 index 00000000000..5c1d815132f --- /dev/null +++ b/packages/tools/gui-mcp-server/src/index.ts @@ -0,0 +1,1241 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-console */ +/** + * GUI MCP Server + * ────────────── + * A Model Context Protocol server that exposes tools for building Babylon.js + * GUI layouts (AdvancedDynamicTexture / 2D controls) programmatically. + * + * An AI agent (or any MCP client) can: + * • Create / manage GUI textures (AdvancedDynamicTexture) + * • Add any GUI control (TextBlock, Button, Slider, Grid, etc.) + * • Build hierarchical control trees + * • Configure Grid rows / columns + * • Set control properties + * • Reparent controls + * • Validate the GUI layout + * • Export GUI JSON (loadable via AdvancedDynamicTexture.parseSerializedObject) + * • Import existing GUI JSON for editing + * + * Transport: stdio + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod/v4"; +import { + CreateErrorResponse, + CreateJsonExportResponse, + CreateInlineJsonSchema, + CreateJsonImportResponse, + CreateJsonFileSchema, + CreateOutputFileSchema, + CreateSnippetIdSchema, + CreateTextResponse, + CreateTypedSnippetImportResponse, + McpEditorSessionController, + ParseJsonText, + RunSnippetResponse, + ResolveDefinedInput, +} from "@tools/mcp-server-core"; + +import { ControlRegistry, BaseControlProperties, GetControlCatalogSummary, GetControlTypeDetails } from "./catalog.js"; +import { GuiManager } from "./guiManager.js"; +import { LoadSnippet, SaveSnippet, type IDataSnippetResult } from "@tools/snippet-loader"; + +// ─── Singleton manager ──────────────────────────────────────────────────── +const manager = new GuiManager(); +const sessionController = new McpEditorSessionController( + { + serverName: "GUI MCP Session Server", + documentKind: "gui", + managerUnavailableMessage: "GUI manager is not available", + getDocument: (manager, session) => manager.exportJSON(session.name) ?? undefined, + setDocument: (manager, session, document) => { + const result = manager.importJSON(session.name, document); + return result && result !== "OK" ? result : undefined; + }, + }, + { + defaultPort: 3001, + statusTitle: "GUI MCP Session Server", + } +); + +/** + * Notify SSE subscribers if a session exists for the given GUI. + * @param guiName - The GUI name to check for active sessions. + */ +function _notifyIfSession(guiName: string): void { + const sessionId = sessionController.getSessionIdForName(guiName); + if (sessionId) { + sessionController.notifySessionUpdate(sessionId); + } +} + +/** + * Import GUI JSON and notify a matching live session on success. + * @param guiName - The GUI name to import into. + * @param jsonText - Serialized GUI JSON. + * @returns "OK" on success, or an error string. + */ +function _importGuiJson(guiName: string, jsonText: string): string { + const result = manager.importJSON(guiName, jsonText); + if (result === "OK") { + _notifyIfSession(guiName); + } + return result; +} + +// ─── MCP Server ─────────────────────────────────────────────────────────── +const server = new McpServer( + { + name: "babylonjs-gui", + version: "1.0.0", + }, + { + instructions: [ + "You build Babylon.js 2D GUI layouts (AdvancedDynamicTexture). Workflow: create_gui → add controls (containers first, then leaf controls inside them) → set properties → validate_gui → export_gui_json.", + "All controls must have a parent. The root container is created automatically. Use Grid for complex layouts, StackPanel for linear layouts.", + "Sizes accept '200px', '50%', or a number. Output JSON can be consumed by the Scene MCP via attach_gui.", + ].join(" "), + } +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Resources (read-only reference data) +// ═══════════════════════════════════════════════════════════════════════════ + +server.registerResource("control-catalog", "gui://control-catalog", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: `# GUI Control Catalog\n${GetControlCatalogSummary()}`, + }, + ], +})); + +server.registerResource("base-properties", "gui://base-properties", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# Base Control Properties", + "These properties are available on ALL controls:\n", + ...Object.entries(BaseControlProperties).map(([k, v]) => `• **${k}** (${v.type}): ${v.description}`), + ].join("\n"), + }, + ], +})); + +server.registerResource("enums", "gui://enums", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# GUI Enumerations Reference", + "", + "## Horizontal Alignment", + "LEFT (0), RIGHT (1), CENTER (2)", + "", + "## Vertical Alignment", + "TOP (0), BOTTOM (1), CENTER (2)", + "", + "## Text Wrapping", + "Clip (0), WordWrap (1), Ellipsis (2), WordWrapEllipsis (3), HTML (4)", + "", + "## Image Stretch", + "STRETCH_NONE (0), STRETCH_FILL (1), STRETCH_UNIFORM (2), STRETCH_EXTEND (3), STRETCH_NINE_PATCH (4)", + "", + "## Grid Definition Units", + "Fraction (0) — value is 0–1 ratio, Pixel (1) — value is in pixels", + "", + "## Size Strings", + 'Width/height accept: "200px" (pixels), "50%" (percentage), "0.5" (fraction), or a number', + ].join("\n"), + }, + ], +})); + +server.registerResource("concepts", "gui://concepts", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# GUI Concepts", + "", + "## What is a Babylon.js GUI?", + "Babylon.js GUI is a 2D interface layer (AdvancedDynamicTexture) that renders controls", + "like buttons, text, sliders, and images as an overlay on top of a 3D scene.", + "The GUI is built as a tree of controls, starting from a root container.", + "", + "## Container Hierarchy", + "Controls are organized in a parent-child tree:", + " • **Containers** (Rectangle, StackPanel, Grid, ScrollViewer, Ellipse) can hold children.", + " • **Leaf controls** (TextBlock, Image, Slider, Checkbox, ColorPicker) cannot.", + " • The root container is always named 'root' and is created automatically.", + " • Every control must be added to a container parent.", + "", + "## ⚠ Grid Layout — CRITICAL Rules", + "Grid is the most powerful layout container but has strict requirements:", + "", + "1. **Define rows and columns BEFORE adding children.**", + " Use `add_grid_row` and `add_grid_column` to define the grid structure first.", + " A Grid with no rows/columns will not display children correctly.", + "", + "2. **Specify gridRow and gridColumn when adding children to a Grid.**", + " If you omit these, the child is placed at cell [0, 0] by default.", + " This is almost never what you want — be explicit!", + "", + "3. **Row/column indices are 0-based** and must be within the defined range.", + "", + "4. **Row/column sizes** use fractions (0–1, unit=0) or pixels (unit=1).", + " Fractions are ratios of remaining space. `0.5` means 50% of the available space.", + "", + "## StackPanel Layout", + "StackPanel arranges children in a single direction:", + " • `isVertical: true` (default) — children stack top to bottom", + " • `isVertical: false` — children stack left to right", + " • `spacing` — gap between children in pixels", + " • ⚠ Children in a StackPanel should set their height (for vertical) or width (for horizontal)", + " since StackPanel does NOT stretch children to fill.", + "", + "## Size System", + "Controls accept sizes in multiple formats:", + " • `'200px'` — absolute pixels", + " • `'50%'` — percentage of parent", + " • `'0.5'` — fraction of parent (equivalent to 50%)", + " • A number — treated as pixels", + "If no width/height is set, controls stretch to fill their parent container.", + "", + "## Alignment", + "Controls are positioned within their parent using alignment:", + " • `horizontalAlignment`: LEFT (0), RIGHT (1), CENTER (2)", + " • `verticalAlignment`: TOP (0), BOTTOM (1), CENTER (2)", + " • Default is CENTER for both. Use alignment with explicit width/height.", + " • `left` and `top` provide pixel offsets from the aligned position.", + "", + "## Button Special Behavior", + "Button is a container that auto-creates internal child controls:", + " • Set `buttonText: 'Click me'` in properties — this creates an internal TextBlock.", + " • Set `buttonImage: 'icon.png'` — this creates an internal Image child.", + " • Do NOT manually add TextBlock children to a Button; use `buttonText` instead.", + " • Style the button itself with `background`, `color` (border color), `cornerRadius`, `thickness`.", + "", + "## Common Patterns", + "", + "### Simple text overlay:", + "1. create_gui, 2. add_control TextBlock to root with text, fontSize, color", + "", + "### Grid-based layout (HUD):", + "1. create_gui", + "2. add_control Grid to root", + "3. add_grid_row × N, add_grid_column × N", + "4. add_control children with explicit gridRow, gridColumn for each", + "", + "### Settings panel with sliders:", + "1. create_gui", + "2. add_control Rectangle (panel background) to root", + "3. add_control StackPanel to the rectangle, isVertical: true", + "4. For each setting: add a horizontal StackPanel row, with TextBlock + Slider inside", + "", + "## Common Mistakes", + "1. Adding children to a Grid before defining rows/columns → misplaced controls", + "2. Forgetting gridRow/gridColumn when adding to a Grid → everything stacks at [0,0]", + "3. Adding a TextBlock child to a Button manually instead of using buttonText property", + "4. Not setting height on StackPanel children → children may collapse to zero height", + "5. Using alignment without explicit size → alignment has no visible effect", + ].join("\n"), + }, + ], +})); + +// ═══════════════════════════════════════════════════════════════════════════ +// Prompts (reusable prompt templates) +// ═══════════════════════════════════════════════════════════════════════════ + +server.registerPrompt("create-hud", { description: "Step-by-step instructions for building a basic game HUD" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a game HUD with health bar, score, and minimap placeholder. Steps:", + "1. create_gui 'GameHUD' (fullscreen, 1920×1080)", + "2. Add a Grid 'mainGrid' as the layout root, parent 'root'", + "3. Add 3 rows to the grid: 0.1 (top bar), 0.8 (main area), 0.1 (bottom bar)", + "4. Add 3 columns: 0.25 (left), 0.5 (center), 0.25 (right)", + "5. Top-left: Add Rectangle 'healthBarBg' (cell 0,0), background '#333', cornerRadius 10", + "6. Inside healthBarBg: Add Rectangle 'healthBarFill', background '#00ff00', width '80%', height '60%', horizontalAlignment 0", + "7. Top-center: Add TextBlock 'scoreText' (cell 0,1), text 'Score: 0', fontSize '32px', color 'white'", + "8. Top-right: Add Rectangle 'minimapBg' (cell 0,2), background '#222', cornerRadius 5", + "9. Bottom-center: Add StackPanel 'bottomBar' (cell 2,1), isVertical false, spacing 20", + "10. Inside bottomBar: Add Button 'inventoryBtn' with buttonText 'Inventory'", + "11. Inside bottomBar: Add Button 'menuBtn' with buttonText 'Menu'", + "12. validate_gui, then export_gui_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-menu", { description: "Step-by-step instructions for building a settings/options menu" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a settings menu panel with controls. Steps:", + "1. create_gui 'SettingsMenu' (fullscreen, 1920×1080)", + "2. Add Rectangle 'panel' to root, width '400px', height '600px', background '#1a1a2e', cornerRadius 20, thickness 2, color '#e94560'", + "3. Add TextBlock 'title' to panel, text 'Settings', fontSize '36px', color 'white', height '60px', verticalAlignment 0", + "4. Add StackPanel 'settingsList' to panel, isVertical true, spacing 15, top '80px', height '450px', width '80%'", + "5. Add a StackPanel 'volumeRow' to settingsList, isVertical false, height '40px'", + "6. Inside volumeRow: TextBlock 'volumeLabel', text 'Volume', width '40%', color 'white', textHorizontalAlignment 0", + "7. Inside volumeRow: Slider 'volumeSlider', width '60%', minimum 0, maximum 100, value 75, color '#e94560'", + "8. Repeat for 'Brightness' and 'FOV' sliders", + "9. Add Checkbox row: StackPanel 'fullscreenRow', with TextBlock 'Fullscreen' + Checkbox", + "10. Add Button 'applyBtn' to panel, buttonText 'Apply', background '#e94560', height '50px', width '60%', verticalAlignment 1, top '-30px'", + "11. validate_gui, then export_gui_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-dialog", { description: "Step-by-step instructions for building a modal dialog" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a confirmation dialog. Steps:", + "1. create_gui 'ConfirmDialog' (fullscreen, 1920×1080)", + "2. Add Rectangle 'overlay' to root, width '100%', height '100%', background 'rgba(0,0,0,0.5)', thickness 0", + "3. Add Rectangle 'dialog' to overlay, width '400px', height '200px', background '#ffffff', cornerRadius 12, thickness 0", + "4. Add TextBlock 'title' to dialog, text 'Confirm', fontSize '24px', color '#333', height '50px', verticalAlignment 0", + "5. Add TextBlock 'message' to dialog, text 'Are you sure?', color '#666', textWrapping 1", + "6. Add StackPanel 'buttons' to dialog, isVertical false, spacing 20, height '50px', verticalAlignment 1, top '-20px'", + "7. Add Button 'cancelBtn' to buttons, buttonText 'Cancel', width '120px', background '#ccc', color '#333'", + "8. Add Button 'confirmBtn' to buttons, buttonText 'Confirm', width '120px', background '#4CAF50', color 'white'", + "9. validate_gui, export_gui_json", + ].join("\n"), + }, + }, + ], +})); + +// ═══════════════════════════════════════════════════════════════════════════ +// Tools +// ═══════════════════════════════════════════════════════════════════════════ + +// ── GUI Texture lifecycle ───────────────────────────────────────────── + +server.registerTool( + "create_gui", + { + description: + "Create a new empty GUI (AdvancedDynamicTexture) in memory. This is always the first step. " + + "The GUI starts with an empty root container; add controls to 'root' to begin building.", + inputSchema: { + name: z.string().optional().describe("Unique name for this GUI (e.g. 'MainHUD', 'SettingsPanel')"), + guiName: z.string().optional().describe("Alias for name — unique name for this GUI"), + width: z.number().default(1920).describe("Texture width in pixels"), + height: z.number().default(1080).describe("Texture height in pixels"), + isFullscreen: z.boolean().default(true).describe("Whether this is a fullscreen overlay GUI"), + idealWidth: z.number().optional().describe("Ideal width for adaptive scaling (optional)"), + idealHeight: z.number().optional().describe("Ideal height for adaptive scaling (optional)"), + }, + }, + async ({ name, guiName, width, height, isFullscreen, idealWidth, idealHeight }) => { + let resolvedName: string; + try { + resolvedName = ResolveDefinedInput({ + candidates: [ + { label: "name", value: name }, + { label: "guiName", value: guiName }, + ], + }); + } catch (e) { + return { content: [{ type: "text", text: (e as Error).message }], isError: true }; + } + manager.createTexture(resolvedName, { width, height, isFullscreen, idealWidth, idealHeight }); + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(resolvedName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return { + content: [ + { + type: "text", + text: `Created GUI "${resolvedName}" (${width}×${height}, fullscreen: ${isFullscreen}). The root container is named "root". Add controls to it with add_control.\n\nMCP Session URL: ${sessionUrl}`, + }, + ], + }; + } +); + +server.registerTool( + "delete_gui", + { description: "Delete a GUI texture from memory.", inputSchema: { name: z.string().describe("Name of the GUI to delete") } }, + async ({ name }) => { + const ok = manager.deleteTexture(name); + if (ok) { + sessionController.closeSessionForName(name); + } + return { content: [{ type: "text", text: ok ? `Deleted "${name}".` : `GUI "${name}" not found.` }] }; + } +); + +server.registerTool("clear_all", { description: "Remove all GUI textures from memory, resetting the server to a clean state." }, async () => { + const names = manager.listTextures(); + manager.clearAll(); + for (const name of names) { + sessionController.closeSessionForName(name); + } + return { + content: [{ type: "text", text: names.length > 0 ? `Cleared ${names.length} GUI(s): ${names.join(", ")}` : "Nothing to clear — memory was already empty." }], + }; +}); + +server.registerTool("list_guis", { description: "List all GUI textures currently in memory." }, async () => { + const names = manager.listTextures(); + return { + content: [ + { + type: "text", + text: names.length > 0 ? `GUIs in memory:\n${names.map((n) => ` • ${n}`).join("\n")}` : "No GUIs in memory.", + }, + ], + }; +}); + +server.registerTool( + "get_session_url", + { + description: "Get or create a live-session URL for a GUI. The URL can be pasted into the GUI Editor MCP session panel.", + inputSchema: { + guiName: z.string().describe("Name of the GUI"), + }, + }, + async ({ guiName }) => { + const guis = manager.listTextures(); + if (!guis.includes(guiName)) { + return CreateErrorResponse(`GUI "${guiName}" not found.`); + } + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(guiName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return CreateTextResponse(`MCP Session URL: ${sessionUrl}`); + } +); + +server.registerTool( + "start_session", + { + description: "Start a live editor session for a GUI and return its URL.", + inputSchema: { + guiName: z.string().describe("Name of the GUI"), + }, + }, + async ({ guiName }) => { + const guis = manager.listTextures(); + if (!guis.includes(guiName)) { + return CreateErrorResponse(`GUI "${guiName}" not found.`); + } + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(guiName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return CreateTextResponse(`Started GUI editor session for "${guiName}".\n\nMCP Session URL: ${sessionUrl}`); + } +); + +server.registerTool( + "close_session", + { + description: "Close the live editor session for a GUI without stopping the MCP server.", + inputSchema: { + guiName: z.string().describe("Name of the GUI"), + }, + }, + async ({ guiName }) => { + const closed = sessionController.closeSessionForName(guiName); + return CreateTextResponse(closed ? `Closed MCP session for "${guiName}".` : `No active MCP session found for "${guiName}".`); + } +); + +server.registerTool("stop_session_server", { description: "Stop the local GUI MCP HTTP/SSE session server and close all active sessions." }, async () => { + await sessionController.stopAsync(); + return CreateTextResponse("GUI MCP session server stopped."); +}); + +// ── Control operations ──────────────────────────────────────────────── + +server.registerTool( + "add_control", + { + description: + "Add a new GUI control to a GUI texture. Returns the control's name for use in further operations. " + + "The control is added as a child of the specified parent (defaults to 'root'). " + + "For Grid parents, you can specify gridRow and gridColumn to place the control in a specific cell.", + inputSchema: { + guiName: z.string().describe("Name of the GUI texture"), + controlType: z + .string() + .describe( + "The control type from the catalog (e.g. 'TextBlock', 'Rectangle', 'Button', 'Slider', 'Grid', 'StackPanel'). " + + "Use list_control_types to see all available types." + ), + controlName: z.string().optional().describe("Name for the control (must be unique within the GUI). Auto-generated if omitted."), + name: z.string().optional().describe("Alias for controlName — name for the control."), + parentName: z.string().default("root").describe("Name of the parent container to add this control to. Defaults to 'root'."), + properties: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Key-value properties to set on the control. Examples: " + + '{ text: "Hello", fontSize: "24px", color: "white" } for TextBlock, ' + + '{ background: "#333", cornerRadius: 10, thickness: 2 } for Rectangle, ' + + "{ minimum: 0, maximum: 100, value: 50 } for Slider, " + + '{ buttonText: "Click me", background: "#4CAF50" } for Button (buttonText creates the internal TextBlock), ' + + '{ buttonImage: "icon.png" } for Button with image.' + ), + // Gap 16 — convenience aliases for common control properties at top level + text: z.string().optional().describe("Shorthand for properties.text (TextBlock, Button)"), + fontSize: z.union([z.string(), z.number()]).optional().describe("Shorthand for properties.fontSize"), + color: z.string().optional().describe("Shorthand for properties.color"), + background: z.string().optional().describe("Shorthand for properties.background"), + width: z.union([z.string(), z.number()]).optional().describe("Shorthand for properties.width"), + height: z.union([z.string(), z.number()]).optional().describe("Shorthand for properties.height"), + top: z.union([z.string(), z.number()]).optional().describe("Shorthand for properties.top"), + left: z.union([z.string(), z.number()]).optional().describe("Shorthand for properties.left"), + buttonText: z.string().optional().describe("Shorthand for properties.buttonText (Button)"), + isVertical: z.boolean().optional().describe("Shorthand for properties.isVertical (StackPanel)"), + thickness: z.number().optional().describe("Shorthand for properties.thickness"), + cornerRadius: z.number().optional().describe("Shorthand for properties.cornerRadius"), + horizontalAlignment: z.number().optional().describe("Shorthand for properties.horizontalAlignment (0=left,1=right,2=center)"), + verticalAlignment: z.number().optional().describe("Shorthand for properties.verticalAlignment (0=top,1=bottom,2=center)"), + gridRow: z.number().optional().describe("Row index when adding to a Grid parent (0-based)"), + gridColumn: z.number().optional().describe("Column index when adding to a Grid parent (0-based)"), + }, + }, + async ({ + guiName, + controlType, + controlName, + name: nameAlias, + parentName, + properties, + gridRow, + gridColumn, + text, + fontSize, + color, + background, + width, + height, + top, + left, + buttonText, + isVertical, + thickness, + cornerRadius, + horizontalAlignment, + verticalAlignment, + }) => { + // Gap 17 — resolve name alias for controlName + const resolvedControlName = controlName ?? nameAlias; + // Gap 16 — merge top-level convenience properties into properties object + const mergedProps: Record = { ...((properties as Record) || {}) }; + const aliases: Record = { + text, + fontSize, + color, + background, + width, + height, + top, + left, + buttonText, + isVertical, + thickness, + cornerRadius, + horizontalAlignment, + verticalAlignment, + }; + for (const [k, v] of Object.entries(aliases)) { + if (v !== undefined && !(k in mergedProps)) { + mergedProps[k] = v; + } + } + // Gap 49 fix: auto-map text -> buttonText for Button controls + if ((controlType === "Button" || controlType === "FocusableButton") && mergedProps.text !== undefined && mergedProps.buttonText === undefined) { + mergedProps.buttonText = mergedProps.text; + delete mergedProps.text; + } + const resolvedProps = Object.keys(mergedProps).length > 0 ? mergedProps : (properties as Record); + const result = manager.addControl(guiName, controlType, resolvedControlName, parentName, resolvedProps, gridRow, gridColumn); + if (typeof result === "string") { + return { content: [{ type: "text", text: `Error: ${result}` }], isError: true }; + } + _notifyIfSession(guiName); + const cellInfo = gridRow !== undefined || gridColumn !== undefined ? ` in cell [${gridRow ?? 0}, ${gridColumn ?? 0}]` : ""; + const lines = [`Added ${controlType} "${result.name}" to "${parentName}"${cellInfo}. Use "${result.name}" to reference this control.`]; + if (result.warnings) { + lines.push("", "Warnings:", ...result.warnings); + } + return { + content: [ + { + type: "text", + text: lines.join("\n"), + }, + ], + }; + } +); + +server.registerTool( + "remove_control", + { + description: "Remove a control (and all its descendants) from the GUI. Use describe_gui to find valid control names.", + inputSchema: { + guiName: z.string().describe("Name of the GUI texture"), + controlName: z.string().describe("Name of the control to remove"), + }, + }, + async ({ guiName, controlName }) => { + const result = manager.removeControl(guiName, controlName); + if (result === "OK") { + _notifyIfSession(guiName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Removed "${controlName}" and all its children.` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "set_control_properties", + { + description: + "Set or update properties on an existing control. Use get_control_type_info to discover available properties for the control's type. " + + "Common base properties: width, height, color, fontSize, text, background, isVisible, horizontalAlignment, verticalAlignment. " + + "For Buttons, use 'buttonText' to update the internal TextBlock's text.", + inputSchema: { + guiName: z.string().describe("Name of the GUI texture"), + controlName: z.string().describe("Name of the control to modify"), + properties: z + .record(z.string(), z.unknown()) + .describe( + "Key-value properties to set. Any base Control property (width, height, color, fontSize, etc.) " + + "or type-specific property (text, source, isChecked, minimum, maximum, etc.). " + + "For Buttons, use 'buttonText' to update the internal TextBlock's text." + ), + }, + }, + async ({ guiName, controlName, properties }) => { + const result = manager.setControlProperties(guiName, controlName, properties as Record); + if (result === "OK") { + _notifyIfSession(guiName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Updated "${controlName}".` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "reparent_control", + { + description: "Move a control to a different parent container.", + inputSchema: { + guiName: z.string().describe("Name of the GUI texture"), + controlName: z.string().describe("Name of the control to move"), + newParentName: z.string().describe("Name of the new parent container"), + gridRow: z.number().optional().describe("Row index if new parent is a Grid"), + gridColumn: z.number().optional().describe("Column index if new parent is a Grid"), + }, + }, + async ({ guiName, controlName, newParentName, gridRow, gridColumn }) => { + const result = manager.reparentControl(guiName, controlName, newParentName, gridRow, gridColumn); + if (result === "OK") { + _notifyIfSession(guiName); + } + const cellInfo = gridRow !== undefined || gridColumn !== undefined ? ` in cell [${gridRow ?? 0}, ${gridColumn ?? 0}]` : ""; + return { + content: [ + { + type: "text", + text: result === "OK" ? `Moved "${controlName}" to "${newParentName}"${cellInfo}.` : `Error: ${result}`, + }, + ], + isError: result !== "OK", + }; + } +); + +// ── Grid operations ─────────────────────────────────────────────────── + +server.registerTool( + "add_grid_row", + { + description: "Add a row definition to a Grid control. Call this before placing controls in that row.", + inputSchema: { + guiName: z.string().describe("Name of the GUI texture"), + gridName: z.string().describe("Name of the Grid control"), + value: z.number().describe("Size value — fraction (0–1) if isPixel=false, or pixel count if isPixel=true"), + isPixel: z.boolean().default(false).describe("Whether the value is in pixels (true) or a fraction (false)"), + }, + }, + async ({ guiName, gridName, value, isPixel }) => { + const result = manager.addGridRow(guiName, gridName, value, isPixel); + if (result === "OK") { + _notifyIfSession(guiName); + } + const unitStr = isPixel ? `${value}px` : `${value} (fraction)`; + return { + content: [{ type: "text", text: result === "OK" ? `Added row (${unitStr}) to Grid "${gridName}".` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "add_grid_column", + { + description: "Add a column definition to a Grid control. Call this before placing controls in that column.", + inputSchema: { + guiName: z.string().describe("Name of the GUI texture"), + gridName: z.string().describe("Name of the Grid control"), + value: z.number().describe("Size value — fraction (0–1) if isPixel=false, or pixel count if isPixel=true"), + isPixel: z.boolean().default(false).describe("Whether the value is in pixels (true) or a fraction (false)"), + }, + }, + async ({ guiName, gridName, value, isPixel }) => { + const result = manager.addGridColumn(guiName, gridName, value, isPixel); + if (result === "OK") { + _notifyIfSession(guiName); + } + const unitStr = isPixel ? `${value}px` : `${value} (fraction)`; + return { + content: [{ type: "text", text: result === "OK" ? `Added column (${unitStr}) to Grid "${gridName}".` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "set_grid_row", + { + description: "Update an existing row definition on a Grid (changes the size of the row at the given index). Use describe_gui to see current grid definitions.", + inputSchema: { + guiName: z.string().describe("Name of the GUI texture"), + gridName: z.string().describe("Name of the Grid control"), + index: z.number().describe("Row index (0-based)"), + value: z.number().describe("New size value"), + isPixel: z.boolean().default(false).describe("Whether the value is in pixels"), + }, + }, + async ({ guiName, gridName, index, value, isPixel }) => { + const result = manager.setGridRow(guiName, gridName, index, value, isPixel); + if (result === "OK") { + _notifyIfSession(guiName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Updated row ${index} on Grid "${gridName}".` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "set_grid_column", + { + description: "Update an existing column definition on a Grid (changes the size of the column at the given index). Use describe_gui to see current grid definitions.", + inputSchema: { + guiName: z.string().describe("Name of the GUI texture"), + gridName: z.string().describe("Name of the Grid control"), + index: z.number().describe("Column index (0-based)"), + value: z.number().describe("New size value"), + isPixel: z.boolean().default(false).describe("Whether the value is in pixels"), + }, + }, + async ({ guiName, gridName, index, value, isPixel }) => { + const result = manager.setGridColumn(guiName, gridName, index, value, isPixel); + if (result === "OK") { + _notifyIfSession(guiName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Updated column ${index} on Grid "${gridName}".` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "remove_grid_row", + { + description: "Remove a row definition from a Grid. WARNING: this shifts subsequent row indices downward and may orphan controls that were in the removed row.", + inputSchema: { + guiName: z.string().describe("Name of the GUI texture"), + gridName: z.string().describe("Name of the Grid control"), + index: z.number().describe("Row index to remove (0-based)"), + }, + }, + async ({ guiName, gridName, index }) => { + const result = manager.removeGridRow(guiName, gridName, index); + if (result === "OK") { + _notifyIfSession(guiName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Removed row ${index} from Grid "${gridName}".` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "remove_grid_column", + { + description: "Remove a column definition from a Grid. WARNING: this shifts subsequent column indices and may orphan controls that were in the removed column.", + inputSchema: { + guiName: z.string().describe("Name of the GUI texture"), + gridName: z.string().describe("Name of the Grid control"), + index: z.number().describe("Column index to remove (0-based)"), + }, + }, + async ({ guiName, gridName, index }) => { + const result = manager.removeGridColumn(guiName, gridName, index); + if (result === "OK") { + _notifyIfSession(guiName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Removed column ${index} from Grid "${gridName}".` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +// ── Query tools ─────────────────────────────────────────────────────── + +server.registerTool( + "describe_gui", + { + description: "Get a human-readable description of the GUI, including the full control tree with properties.", + inputSchema: { + guiName: z.string().describe("Name of the GUI texture to describe"), + }, + }, + async ({ guiName }) => { + const desc = manager.describeTexture(guiName); + return { content: [{ type: "text", text: desc }] }; + } +); + +server.registerTool( + "describe_control", + { + description: "Get detailed information about a specific control, including its properties, parent, and children.", + inputSchema: { + guiName: z.string().describe("Name of the GUI texture"), + controlName: z.string().describe("Name of the control to describe"), + }, + }, + async ({ guiName, controlName }) => { + const desc = manager.describeControl(guiName, controlName); + return { content: [{ type: "text", text: desc }] }; + } +); + +server.registerTool( + "list_control_types", + { + description: "List all available GUI control types, grouped by category.", + inputSchema: { + category: z.string().optional().describe("Optionally filter by category (Container, Layout, Text, Input, Button, Indicator, Shape, Image, Misc)"), + }, + }, + async ({ category }) => { + if (category) { + const matching = Object.entries(ControlRegistry) + .filter(([, info]) => info.category.toLowerCase() === category.toLowerCase()) + .map(([key, info]) => ` ${key}: ${info.description.split(".")[0]}`) + .join("\n"); + return { + content: [ + { + type: "text", + text: matching.length > 0 ? `## ${category} Controls\n${matching}` : `No controls found in category "${category}".`, + }, + ], + }; + } + return { content: [{ type: "text", text: GetControlCatalogSummary() }] }; + } +); + +server.registerTool( + "get_control_type_info", + { + description: "Get detailed info about a specific control type — its properties, whether it's a container, and description.", + inputSchema: { + controlType: z.string().describe("The control type name (e.g. 'TextBlock', 'Grid', 'Slider')"), + }, + }, + async ({ controlType }) => { + const info = GetControlTypeDetails(controlType); + if (!info) { + return { + content: [{ type: "text", text: `Control type "${controlType}" not found. Use list_control_types to see available types.` }], + isError: true, + }; + } + + const lines: string[] = []; + lines.push(`## ${controlType}`); + lines.push(`Category: ${info.category}`); + lines.push(`Container: ${info.isContainer ? "Yes (can hold children)" : "No (leaf control)"}`); + lines.push(`Description: ${info.description}`); + + lines.push("\n### Type-Specific Properties:"); + if (Object.keys(info.properties).length === 0) { + lines.push(" (none beyond base properties)"); + } + for (const [k, v] of Object.entries(info.properties)) { + const def = v.defaultValue !== undefined ? ` [default: ${JSON.stringify(v.defaultValue)}]` : ""; + lines.push(` • ${k} (${v.type}): ${v.description}${def}`); + } + + lines.push("\n### Base Properties (available on all controls):"); + lines.push(" width, height, left, top, color, alpha, fontSize, fontFamily, fontWeight,"); + lines.push(" horizontalAlignment, verticalAlignment, paddingLeft/Right/Top/Bottom,"); + lines.push(" isVisible, isEnabled, zIndex, rotation, scaleX, scaleY, shadow*, clipChildren, clipContent"); + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } +); + +// ── Validation ──────────────────────────────────────────────────────── + +server.registerTool( + "validate_gui", + { + description: "Run validation checks on a GUI. Reports issues like empty containers, invalid Grid cell assignments, and duplicates.", + inputSchema: { + guiName: z.string().describe("Name of the GUI to validate"), + }, + }, + async ({ guiName }) => { + const issues = manager.validateTexture(guiName); + return { + content: [{ type: "text", text: issues.join("\n") }], + isError: issues.some((i) => i.startsWith("ERROR")), + }; + } +); + +// ── Export / Import ─────────────────────────────────────────────────── + +server.registerTool( + "export_gui_json", + { + description: + "Export the GUI as Babylon.js-compatible JSON. This JSON can be loaded with " + + "AdvancedDynamicTexture.parseSerializedObject() or AdvancedDynamicTexture.ParseFromFileAsync(). " + + "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: { + guiName: z.string().describe("Name of the GUI to export"), + outputFile: CreateOutputFileSchema(z), + }, + }, + async ({ guiName, outputFile }) => { + return CreateJsonExportResponse({ + jsonText: manager.exportJSON(guiName), + outputFile, + missingMessage: `GUI "${guiName}" not found.`, + fileLabel: "GUI JSON", + }); + } +); + +server.registerTool( + "import_gui_json", + { + description: + "Import existing GUI JSON into memory for editing. You can then modify controls, rearrange hierarchy, etc. " + + "Provide either the inline json string OR a jsonFile path (not both).", + inputSchema: { + guiName: z.string().describe("Name to give the imported GUI"), + json: CreateInlineJsonSchema(z, "The Babylon.js GUI JSON string to import"), + jsonFile: CreateJsonFileSchema(z, "Absolute path to a file containing the GUI JSON to import (alternative to inline json)"), + }, + }, + async ({ guiName, json, jsonFile }) => { + return CreateJsonImportResponse({ + json, + jsonFile, + fileDescription: "GUI JSON file", + importJson: (jsonText) => _importGuiJson(guiName, jsonText), + describeImported: () => manager.describeTexture(guiName), + }); + } +); + +server.registerTool( + "import_from_snippet", + { + description: + "Import a GUI layout from the Babylon.js Snippet Server by its snippet ID. " + + "The snippet is fetched, validated as a gui type, and loaded into memory for editing. " + + 'Snippet IDs look like "ABC123" or "ABC123#2" (with revision).', + inputSchema: { + guiName: z.string().describe("Name to give the imported GUI in memory"), + snippetId: CreateSnippetIdSchema(z), + }, + }, + async ({ guiName, snippetId }) => { + return await RunSnippetResponse({ + snippetId, + loadSnippet: async (requestedSnippetId: string) => (await LoadSnippet(requestedSnippetId)) as IDataSnippetResult, + createResponse: (snippetResult: IDataSnippetResult) => + CreateTypedSnippetImportResponse({ + snippetId, + snippetResult, + expectedType: "gui", + importJson: (jsonText) => _importGuiJson(guiName, jsonText), + describeImported: () => manager.describeTexture(guiName), + successMessage: `Imported snippet "${snippetId}" as "${guiName}" successfully.`, + }), + }); + } +); + +// ── Batch operations ────────────────────────────────────────────────── + +server.registerTool( + "add_controls_batch", + { + description: + "Add multiple controls at once (processed sequentially, so earlier controls can be parents for later ones). " + + "More efficient than calling add_control repeatedly. If one control fails, the rest still proceed.", + inputSchema: { + guiName: z.string().describe("Name of the GUI texture"), + controls: z + .array( + z.object({ + controlType: z.string().describe("Control type name"), + controlName: z.string().optional().describe("Name for the control"), + name: z.string().optional().describe("Alias for controlName"), + parentName: z.string().default("root").describe("Parent container name"), + properties: z.record(z.string(), z.unknown()).optional().describe("Control properties"), + // Gap 16 — convenience aliases for common control properties + text: z.string().optional().describe("Shorthand for properties.text"), + fontSize: z.union([z.string(), z.number()]).optional().describe("Shorthand for properties.fontSize"), + color: z.string().optional().describe("Shorthand for properties.color"), + background: z.string().optional().describe("Shorthand for properties.background"), + width: z.union([z.string(), z.number()]).optional().describe("Shorthand for properties.width"), + height: z.union([z.string(), z.number()]).optional().describe("Shorthand for properties.height"), + top: z.union([z.string(), z.number()]).optional().describe("Shorthand for properties.top"), + left: z.union([z.string(), z.number()]).optional().describe("Shorthand for properties.left"), + buttonText: z.string().optional().describe("Shorthand for properties.buttonText"), + isVertical: z.boolean().optional().describe("Shorthand for properties.isVertical"), + thickness: z.number().optional().describe("Shorthand for properties.thickness"), + cornerRadius: z.number().optional().describe("Shorthand for properties.cornerRadius"), + horizontalAlignment: z.number().optional().describe("Shorthand for properties.horizontalAlignment"), + verticalAlignment: z.number().optional().describe("Shorthand for properties.verticalAlignment"), + gridRow: z.number().optional().describe("Grid row index"), + gridColumn: z.number().optional().describe("Grid column index"), + }) + ) + .describe("Array of controls to add"), + }, + }, + async ({ guiName, controls }) => { + const results: string[] = []; + let didMutate = false; + for (const def of controls) { + // Gap 17 — resolve name alias + const resolvedName = def.controlName ?? def.name; + // Gap 16 — merge top-level convenience properties into properties + const mergedProps: Record = { ...((def.properties as Record) || {}) }; + const aliases: Record = { + text: def.text, + fontSize: def.fontSize, + color: def.color, + background: def.background, + width: def.width, + height: def.height, + top: def.top, + left: def.left, + buttonText: def.buttonText, + isVertical: def.isVertical, + thickness: def.thickness, + cornerRadius: def.cornerRadius, + horizontalAlignment: def.horizontalAlignment, + verticalAlignment: def.verticalAlignment, + }; + for (const [k, v] of Object.entries(aliases)) { + if (v !== undefined && !(k in mergedProps)) { + mergedProps[k] = v; + } + } + // Gap 49 fix: auto-map text -> buttonText for Button controls + if ((def.controlType === "Button" || def.controlType === "FocusableButton") && mergedProps.text !== undefined && mergedProps.buttonText === undefined) { + mergedProps.buttonText = mergedProps.text; + delete mergedProps.text; + } + const resolvedProps = Object.keys(mergedProps).length > 0 ? mergedProps : (def.properties as Record); + const result = manager.addControl(guiName, def.controlType, resolvedName, def.parentName, resolvedProps, def.gridRow, def.gridColumn); + if (typeof result === "string") { + results.push(`Error adding ${def.controlType}: ${result}`); + } else { + didMutate = true; + let line = `"${result.name}" (${def.controlType}) → "${def.parentName}"`; + if (result.warnings) { + line += `\n ⚠ ${result.warnings.join("\n ⚠ ")}`; + } + results.push(line); + } + } + if (didMutate) { + _notifyIfSession(guiName); + } + return { content: [{ type: "text", text: `Added controls:\n${results.join("\n")}` }] }; + } +); + +server.registerTool( + "setup_grid", + { + description: "Configure a Grid all at once: add multiple row and column definitions in a single call.", + inputSchema: { + guiName: z.string().describe("Name of the GUI texture"), + gridName: z.string().describe("Name of the Grid control"), + rows: z + .array( + z.object({ + value: z.number().describe("Size value"), + isPixel: z.boolean().default(false).describe("Pixel (true) or fraction (false)"), + }) + ) + .describe("Array of row definitions"), + columns: z + .array( + z.object({ + value: z.number().describe("Size value"), + isPixel: z.boolean().default(false).describe("Pixel (true) or fraction (false)"), + }) + ) + .describe("Array of column definitions"), + }, + }, + async ({ guiName, gridName, rows, columns }) => { + const results: string[] = []; + let didMutate = false; + for (const row of rows) { + const r = manager.addGridRow(guiName, gridName, row.value, row.isPixel); + if (r !== "OK") { + results.push(`Row error: ${r}`); + } else { + didMutate = true; + } + } + for (const col of columns) { + const c = manager.addGridColumn(guiName, gridName, col.value, col.isPixel); + if (c !== "OK") { + results.push(`Column error: ${c}`); + } else { + didMutate = true; + } + } + + if (didMutate) { + _notifyIfSession(guiName); + } + + if (results.length > 0) { + return { content: [{ type: "text", text: `Errors:\n${results.join("\n")}` }], isError: true }; + } + return { + content: [ + { + type: "text", + text: `Configured Grid "${gridName}": ${rows.length} rows, ${columns.length} columns.`, + }, + ], + }; + } +); + +// ── Snippet server ────────────────────────────────────────────────────── + +server.registerTool( + "save_snippet", + { + description: + "Save the GUI layout to the Babylon.js Snippet Server and return the snippet ID and version. " + + "The snippet can later be loaded in the GUI Editor via its snippet ID, or fetched with import_from_snippet. " + + "To create a new revision of an existing snippet, pass the previous snippetId.", + inputSchema: { + guiName: z.string().describe("Name of the GUI to save"), + snippetId: z.string().optional().describe('Optional existing snippet ID to create a new revision of (e.g. "ABC123" or "ABC123#1")'), + name: z.string().optional().describe("Optional human-readable title for the snippet"), + description: z.string().optional().describe("Optional description"), + tags: z.string().optional().describe("Optional comma-separated tags"), + }, + }, + async ({ guiName, snippetId, name, description, tags }) => { + const json = manager.exportJSON(guiName); + if (!json) { + return { content: [{ type: "text", text: `GUI "${guiName}" not found.` }], isError: true }; + } + try { + const result = await SaveSnippet({ type: "gui", data: ParseJsonText({ jsonText: json, jsonLabel: "GUI JSON" }) }, { snippetId, metadata: { name, description, tags } }); + return { + content: [ + { + type: "text", + text: `Saved GUI "${guiName}" to snippet server.\n\nSnippet ID: ${result.id}\nVersion: ${result.version}\nFull ID: ${result.snippetId}`, + }, + ], + }; + } catch (e) { + return { content: [{ type: "text", text: `Error saving snippet: ${(e as Error).message}` }], isError: true }; + } + } +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Start the server +// ═══════════════════════════════════════════════════════════════════════════ + +async function Main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Babylon.js GUI MCP Server running on stdio"); +} + +try { + await Main(); +} catch (err) { + console.error("Fatal error:", err); + process.exit(1); +} + +const _shutdown = () => { + void sessionController.stopAsync(); + process.exit(0); +}; +process.on("SIGINT", _shutdown); +process.on("SIGTERM", _shutdown); diff --git a/packages/tools/gui-mcp-server/test/unit/generateExamples.test.ts b/packages/tools/gui-mcp-server/test/unit/generateExamples.test.ts new file mode 100644 index 00000000000..dcc02983f27 --- /dev/null +++ b/packages/tools/gui-mcp-server/test/unit/generateExamples.test.ts @@ -0,0 +1,621 @@ +/** + * GUI MCP Server – Example GUI Generation Tests + * + * Creates 5 example GUI layouts using the GuiManager API, + * exports them to JSON, validates the output, and writes them to the + * examples/ directory. + */ + +import * as fs from "fs"; +import * as path from "path"; +import { GuiManager } from "../../src/guiManager"; + +const EXAMPLES_DIR = path.resolve(__dirname, "../../examples"); + +function writeExample(name: string, json: string): void { + fs.mkdirSync(EXAMPLES_DIR, { recursive: true }); + const filePath = path.join(EXAMPLES_DIR, `${name}.json`); + fs.writeFileSync(filePath, json, "utf-8"); +} + +function getCtrlName(result: ReturnType): string { + if (typeof result === "string") { + throw new Error(result); + } + return result.name; +} + +describe("GUI MCP Server – Example GUI Generation", () => { + // ── Example 1: Game HUD ───────────────────────────────────────────── + + it("Example: Game HUD with health bar, score, and minimap frame", () => { + const mgr = new GuiManager(); + mgr.createTexture("gameHud"); + + // Top bar with score + getCtrlName( + mgr.addControl("gameHud", "Rectangle", "topBar", "root", { + width: "1", + height: "60px", + verticalAlignment: 0, // Top + background: "rgba(0,0,0,0.6)", + thickness: 0, + }) + ); + + getCtrlName( + mgr.addControl("gameHud", "StackPanel", "topStack", "topBar", { + isVertical: false, + width: "1", + height: "1", + }) + ); + + getCtrlName( + mgr.addControl("gameHud", "TextBlock", "scoreLabel", "topStack", { + text: "Score: 12500", + color: "gold", + fontSize: "28px", + width: "200px", + }) + ); + + getCtrlName( + mgr.addControl("gameHud", "TextBlock", "levelLabel", "topStack", { + text: "Level 7", + color: "white", + fontSize: "22px", + width: "100px", + }) + ); + + // Health bar at bottom-left + getCtrlName( + mgr.addControl("gameHud", "Rectangle", "healthFrame", "root", { + width: "300px", + height: "30px", + left: "20px", + top: "-20px", + verticalAlignment: 1, // Bottom + horizontalAlignment: 0, // Left + background: "#333", + cornerRadius: 5, + }) + ); + + getCtrlName( + mgr.addControl("gameHud", "Rectangle", "healthFill", "healthFrame", { + width: "0.75", + height: "1", + horizontalAlignment: 0, // Left + background: "linear-gradient(#4CAF50, #66BB6A)", + thickness: 0, + }) + ); + + getCtrlName( + mgr.addControl("gameHud", "TextBlock", "healthText", "healthFrame", { + text: "75/100", + color: "white", + fontSize: "14px", + }) + ); + + // Minimap frame bottom-right + getCtrlName( + mgr.addControl("gameHud", "Rectangle", "minimapFrame", "root", { + width: "200px", + height: "200px", + left: "-20px", + top: "-20px", + verticalAlignment: 1, // Bottom + horizontalAlignment: 1, // Right + background: "rgba(0,0,0,0.5)", + thickness: 2, + color: "#888", + }) + ); + + const json = mgr.exportJSON("gameHud")!; + const parsed = JSON.parse(json); + expect(parsed.root.children.length).toBe(3); + + const issues = mgr.validateTexture("gameHud"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + writeExample("GameHUD", json); + }); + + // ── Example 2: Settings Menu ──────────────────────────────────────── + + it("Example: Settings Menu with sliders, checkboxes, and buttons", () => { + const mgr = new GuiManager(); + mgr.createTexture("settings"); + + // Overlay background + getCtrlName( + mgr.addControl("settings", "Rectangle", "overlay", "root", { + width: "1", + height: "1", + background: "rgba(0,0,0,0.7)", + thickness: 0, + }) + ); + + // Dialog panel + getCtrlName( + mgr.addControl("settings", "Rectangle", "dialog", "overlay", { + width: "500px", + height: "600px", + background: "#2D2D2D", + cornerRadius: 15, + thickness: 2, + color: "#555", + }) + ); + + // Title + getCtrlName( + mgr.addControl("settings", "TextBlock", "title", "dialog", { + text: "⚙ Settings", + color: "white", + fontSize: "24px", + top: "-240px", + }) + ); + + // Content stack + getCtrlName( + mgr.addControl("settings", "StackPanel", "options", "dialog", { + isVertical: true, + width: "400px", + top: "-100px", + }) + ); + + // Volume + getCtrlName( + mgr.addControl("settings", "TextBlock", "volLabel", "options", { + text: "Volume", + color: "#BBB", + fontSize: "16px", + height: "30px", + }) + ); + getCtrlName( + mgr.addControl("settings", "Slider", "volSlider", "options", { + minimum: 0, + maximum: 100, + value: 75, + step: 5, + height: "30px", + color: "#4CAF50", + }) + ); + + // Brightness + getCtrlName( + mgr.addControl("settings", "TextBlock", "brightLabel", "options", { + text: "Brightness", + color: "#BBB", + fontSize: "16px", + height: "30px", + }) + ); + getCtrlName( + mgr.addControl("settings", "Slider", "brightSlider", "options", { + minimum: 0, + maximum: 100, + value: 50, + height: "30px", + color: "#2196F3", + }) + ); + + // Fullscreen checkbox + getCtrlName( + mgr.addControl("settings", "StackPanel", "fsRow", "options", { + isVertical: false, + height: "30px", + }) + ); + getCtrlName( + mgr.addControl("settings", "Checkbox", "fsCb", "fsRow", { + isChecked: false, + width: "20px", + height: "20px", + color: "#4CAF50", + }) + ); + getCtrlName( + mgr.addControl("settings", "TextBlock", "fsLabel", "fsRow", { + text: "Fullscreen", + color: "white", + fontSize: "16px", + width: "150px", + }) + ); + + // Buttons row + getCtrlName( + mgr.addControl("settings", "StackPanel", "btnRow", "dialog", { + isVertical: false, + top: "240px", + height: "50px", + }) + ); + getCtrlName( + mgr.addControl("settings", "Button", "applyBtn", "btnRow", { + buttonText: "Apply", + width: "120px", + height: "40px", + background: "#4CAF50", + color: "white", + }) + ); + getCtrlName( + mgr.addControl("settings", "Button", "cancelBtn", "btnRow", { + buttonText: "Cancel", + width: "120px", + height: "40px", + background: "#666", + color: "white", + }) + ); + + const json = mgr.exportJSON("settings")!; + const parsed = JSON.parse(json); + expect(parsed.root.children.length).toBe(1); // overlay + expect(parsed.root.children[0].children.length).toBe(1); // dialog + const dialog = parsed.root.children[0].children[0]; + expect(dialog.children.length).toBe(3); // title, options, btnRow + + const issues = mgr.validateTexture("settings"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + writeExample("SettingsPanel", json); + }); + + // ── Example 3: Grid-based Dashboard ───────────────────────────────── + + it("Example: Grid-based Dashboard with metrics cards", () => { + const mgr = new GuiManager(); + mgr.createTexture("dashboard"); + + // 3x2 grid + getCtrlName(mgr.addControl("dashboard", "Grid", "mainGrid")); + mgr.addGridRow("dashboard", "mainGrid", 60, true); // Header + mgr.addGridRow("dashboard", "mainGrid", 0.5, false); // Row 1 + mgr.addGridRow("dashboard", "mainGrid", 0.5, false); // Row 2 + mgr.addGridColumn("dashboard", "mainGrid", 0.5, false); + mgr.addGridColumn("dashboard", "mainGrid", 0.5, false); + + // Header bar + getCtrlName( + mgr.addControl( + "dashboard", + "Rectangle", + "header", + "mainGrid", + { + background: "#1976D2", + thickness: 0, + }, + 0, + 0 + ) + ); + getCtrlName( + mgr.addControl("dashboard", "TextBlock", "headerText", "header", { + text: "📊 Dashboard", + color: "white", + fontSize: "24px", + }) + ); + + // Metric cards (4 quadrants) + const metrics = [ + { name: "users", label: "Active Users", value: "1,234", bg: "#E91E63", row: 1, col: 0 }, + { name: "orders", label: "Orders Today", value: "567", bg: "#FF9800", row: 1, col: 1 }, + { name: "revenue", label: "Revenue", value: "$12,345", bg: "#4CAF50", row: 2, col: 0 }, + { name: "errors", label: "Error Rate", value: "0.2%", bg: "#9C27B0", row: 2, col: 1 }, + ]; + + for (const m of metrics) { + getCtrlName( + mgr.addControl( + "dashboard", + "Rectangle", + `${m.name}Card`, + "mainGrid", + { + background: m.bg, + cornerRadius: 10, + thickness: 0, + width: "0.9", + height: "0.85", + }, + m.row, + m.col + ) + ); + + getCtrlName( + mgr.addControl("dashboard", "StackPanel", `${m.name}Stack`, `${m.name}Card`, { + isVertical: true, + }) + ); + + getCtrlName( + mgr.addControl("dashboard", "TextBlock", `${m.name}Value`, `${m.name}Stack`, { + text: m.value, + color: "white", + fontSize: "36px", + height: "50px", + }) + ); + + getCtrlName( + mgr.addControl("dashboard", "TextBlock", `${m.name}Label`, `${m.name}Stack`, { + text: m.label, + color: "rgba(255,255,255,0.8)", + fontSize: "16px", + height: "25px", + }) + ); + } + + const json = mgr.exportJSON("dashboard")!; + const parsed = JSON.parse(json); + const grid = parsed.root.children[0]; + expect(grid.rows.length).toBe(3); + expect(grid.columns.length).toBe(2); + // header + 4 metric cards + expect(grid.children.length).toBe(5); + + const issues = mgr.validateTexture("dashboard"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + writeExample("GridDashboard", json); + }); + + // ── Example 4: Login Form ─────────────────────────────────────────── + + it("Example: Login Form with inputs and validation", () => { + const mgr = new GuiManager(); + mgr.createTexture("loginForm"); + + // Background + getCtrlName( + mgr.addControl("loginForm", "Rectangle", "bg", "root", { + width: "1", + height: "1", + background: "#1A1A2E", + thickness: 0, + }) + ); + + // Form card + getCtrlName( + mgr.addControl("loginForm", "Rectangle", "card", "bg", { + width: "400px", + height: "450px", + background: "#16213E", + cornerRadius: 20, + thickness: 1, + color: "#333", + }) + ); + + getCtrlName( + mgr.addControl("loginForm", "StackPanel", "formStack", "card", { + isVertical: true, + width: "320px", + }) + ); + + // Logo/title + getCtrlName( + mgr.addControl("loginForm", "TextBlock", "logo", "formStack", { + text: "🔐 Welcome Back", + color: "#E94560", + fontSize: "28px", + height: "60px", + }) + ); + + // Username + getCtrlName( + mgr.addControl("loginForm", "TextBlock", "userLabel", "formStack", { + text: "Username", + color: "#AAA", + fontSize: "14px", + height: "25px", + horizontalAlignment: 0, + }) + ); + getCtrlName( + mgr.addControl("loginForm", "InputText", "userInput", "formStack", { + placeholderText: "Enter username...", + placeholderColor: "#555", + color: "white", + background: "#0F3460", + height: "40px", + width: "1", + }) + ); + + // Password + getCtrlName( + mgr.addControl("loginForm", "TextBlock", "passLabel", "formStack", { + text: "Password", + color: "#AAA", + fontSize: "14px", + height: "25px", + horizontalAlignment: 0, + }) + ); + getCtrlName( + mgr.addControl("loginForm", "InputPassword", "passInput", "formStack", { + placeholderText: "Enter password...", + placeholderColor: "#555", + color: "white", + background: "#0F3460", + height: "40px", + width: "1", + }) + ); + + // Remember me + getCtrlName( + mgr.addControl("loginForm", "StackPanel", "rememberRow", "formStack", { + isVertical: false, + height: "30px", + }) + ); + getCtrlName( + mgr.addControl("loginForm", "Checkbox", "rememberCb", "rememberRow", { + isChecked: false, + width: "20px", + height: "20px", + color: "#E94560", + }) + ); + getCtrlName( + mgr.addControl("loginForm", "TextBlock", "rememberLabel", "rememberRow", { + text: "Remember me", + color: "#AAA", + fontSize: "14px", + width: "120px", + }) + ); + + // Login button + getCtrlName( + mgr.addControl("loginForm", "Button", "loginBtn", "formStack", { + buttonText: "Log In", + width: "1", + height: "45px", + background: "#E94560", + color: "white", + cornerRadius: 8, + }) + ); + + const json = mgr.exportJSON("loginForm")!; + const parsed = JSON.parse(json); + expect(parsed.root.children.length).toBe(1); + + // Drill into form + const card = parsed.root.children[0].children[0]; + const formStack = card.children[0]; + expect(formStack.children.length).toBe(7); // logo, userLabel, userInput, passLabel, passInput, rememberRow, loginBtn + + const issues = mgr.validateTexture("loginForm"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + writeExample("LoginForm", json); + }); + + // ── Example 5: Dialog with Confirmation ───────────────────────────── + + it("Example: Confirmation Dialog with icon, message, and buttons", () => { + const mgr = new GuiManager(); + mgr.createTexture("dialog"); + + // Semi-transparent overlay + getCtrlName( + mgr.addControl("dialog", "Rectangle", "overlay", "root", { + width: "1", + height: "1", + background: "rgba(0,0,0,0.5)", + thickness: 0, + }) + ); + + // Dialog box + getCtrlName( + mgr.addControl("dialog", "Rectangle", "box", "overlay", { + width: "450px", + height: "250px", + background: "white", + cornerRadius: 12, + thickness: 0, + }) + ); + + // Icon + getCtrlName( + mgr.addControl("dialog", "TextBlock", "icon", "box", { + text: "⚠️", + fontSize: "48px", + top: "-60px", + }) + ); + + // Title + getCtrlName( + mgr.addControl("dialog", "TextBlock", "title", "box", { + text: "Delete Item?", + color: "#333", + fontSize: "22px", + top: "-10px", + }) + ); + + // Message + getCtrlName( + mgr.addControl("dialog", "TextBlock", "message", "box", { + text: "This action cannot be undone. Are you sure you want to delete this item?", + color: "#666", + fontSize: "14px", + textWrapping: 1, // WordWrap + width: "380px", + top: "30px", + }) + ); + + // Button row + getCtrlName( + mgr.addControl("dialog", "StackPanel", "btnRow", "box", { + isVertical: false, + top: "80px", + height: "45px", + }) + ); + + getCtrlName( + mgr.addControl("dialog", "Button", "deleteBtn", "btnRow", { + buttonText: "Delete", + width: "120px", + height: "40px", + background: "#F44336", + color: "white", + cornerRadius: 6, + }) + ); + + getCtrlName( + mgr.addControl("dialog", "Button", "cancelBtn", "btnRow", { + buttonText: "Cancel", + width: "120px", + height: "40px", + background: "#E0E0E0", + color: "#333", + cornerRadius: 6, + }) + ); + + const json = mgr.exportJSON("dialog")!; + const parsed = JSON.parse(json); + expect(parsed.root.children.length).toBe(1); // overlay + const box = parsed.root.children[0].children[0]; + expect(box.children.length).toBe(4); // icon, title, message, btnRow + expect(box.children[3].children.length).toBe(2); // 2 buttons + + const issues = mgr.validateTexture("dialog"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + writeExample("ConfirmationDialog", json); + }); +}); diff --git a/packages/tools/gui-mcp-server/test/unit/guiManager.test.ts b/packages/tools/gui-mcp-server/test/unit/guiManager.test.ts new file mode 100644 index 00000000000..5b6912ce04e --- /dev/null +++ b/packages/tools/gui-mcp-server/test/unit/guiManager.test.ts @@ -0,0 +1,849 @@ +/** + * GUI MCP Server – GuiManager Validation Tests + * + * Creates GUI layouts via GuiManager, exports them to JSON, + * validates the JSON structure, and exercises core operations. + */ + +import { GuiManager } from "../../src/guiManager"; +import { ControlRegistry, BaseControlProperties } from "../../src/catalog"; + +// ─── Test Helpers ───────────────────────────────────────────────────────── + +function validateGuiJSON(json: string, label: string): any { + let parsed: any; + try { + parsed = JSON.parse(json); + } catch { + throw new Error(`${label}: invalid JSON`); + } + + expect(parsed.root).toBeDefined(); + expect(parsed.root.name).toBe("root"); + expect(parsed.root.className).toBe("Container"); + expect(Array.isArray(parsed.root.children)).toBe(true); + expect(typeof parsed.width).toBe("number"); + expect(typeof parsed.height).toBe("number"); + + return parsed; +} + +function getCtrlName(result: ReturnType): string { + if (typeof result === "string") { + throw new Error(result); + } + return result.name; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +describe("GUI MCP Server – GuiManager Validation", () => { + // ── Test 1: Basic lifecycle ───────────────────────────────────────── + + it("supports create, list, delete lifecycle", () => { + const mgr = new GuiManager(); + mgr.createTexture("a"); + mgr.createTexture("b"); + + const list = mgr.listTextures(); + expect(list).toContain("a"); + expect(list).toContain("b"); + + expect(mgr.deleteTexture("a")).toBe(true); + expect(mgr.listTextures()).not.toContain("a"); + expect(mgr.deleteTexture("nonexistent")).toBe(false); + }); + + // ── Test 2: Create with options ───────────────────────────────────── + + it("creates texture with custom dimensions and fullscreen", () => { + const mgr = new GuiManager(); + mgr.createTexture("custom", { width: 800, height: 600, isFullscreen: false, idealWidth: 1024 }); + + const tex = mgr.getTexture("custom"); + expect(tex).toBeDefined(); + expect(tex!.width).toBe(800); + expect(tex!.height).toBe(600); + expect(tex!.isFullscreen).toBe(false); + expect(tex!.idealWidth).toBe(1024); + }); + + // ── Test 3: Add simple controls ───────────────────────────────────── + + it("adds controls to root and exports valid JSON", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + const textName = getCtrlName( + mgr.addControl("gui", "TextBlock", "title", "root", { + text: "Hello World", + fontSize: "32px", + color: "white", + }) + ); + expect(textName).toBe("title"); + + const rectName = getCtrlName( + mgr.addControl("gui", "Rectangle", "panel", "root", { + background: "#333", + thickness: 2, + cornerRadius: 10, + width: "400px", + height: "200px", + }) + ); + expect(rectName).toBe("panel"); + + const json = mgr.exportJSON("gui"); + expect(json).not.toBeNull(); + const parsed = validateGuiJSON(json!, "basic controls"); + expect(parsed.root.children.length).toBe(2); + + const textCtrl = parsed.root.children.find((c: any) => c.name === "title"); + expect(textCtrl).toBeDefined(); + expect(textCtrl.className).toBe("TextBlock"); + expect(textCtrl.text).toBe("Hello World"); + expect(textCtrl.fontSize).toBe("32px"); + }); + + // ── Test 4: Nested containers ─────────────────────────────────────── + + it("supports nested container hierarchy", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "Rectangle", "outer")); + getCtrlName(mgr.addControl("gui", "StackPanel", "inner", "outer", { isVertical: true })); + getCtrlName(mgr.addControl("gui", "TextBlock", "label", "inner", { text: "Nested!" })); + + const json = mgr.exportJSON("gui")!; + const parsed = JSON.parse(json); + + const outer = parsed.root.children[0]; + expect(outer.name).toBe("outer"); + expect(outer.children.length).toBe(1); + + const inner = outer.children[0]; + expect(inner.name).toBe("inner"); + expect(inner.children.length).toBe(1); + expect(inner.children[0].name).toBe("label"); + expect(inner.children[0].text).toBe("Nested!"); + }); + + // ── Test 5: Non-container rejection ───────────────────────────────── + + it("rejects adding children to non-container controls", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "TextBlock", "text")); + const result = mgr.addControl("gui", "TextBlock", "child", "text"); + expect(typeof result).toBe("string"); + expect(result).toContain("not a container"); + }); + + // ── Test 6: Button auto-children ──────────────────────────────────── + + it("creates Button with auto-generated TextBlock child", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + const btnName = getCtrlName( + mgr.addControl("gui", "Button", "myBtn", "root", { + buttonText: "Click Me", + background: "#4CAF50", + }) + ); + expect(btnName).toBe("myBtn"); + + const json = mgr.exportJSON("gui")!; + const parsed = JSON.parse(json); + const btn = parsed.root.children[0]; + + expect(btn.className).toBe("Button"); + expect(btn.background).toBe("#4CAF50"); + expect(btn.children).toBeDefined(); + expect(btn.children.length).toBe(1); + expect(btn.children[0].className).toBe("TextBlock"); + expect(btn.children[0].text).toBe("Click Me"); + }); + + // ── Test 7: Button with image ─────────────────────────────────────── + + it("creates Button with both TextBlock and Image children", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName( + mgr.addControl("gui", "Button", "imgBtn", "root", { + buttonText: "Icon Button", + buttonImage: "icon.png", + }) + ); + + const json = mgr.exportJSON("gui")!; + const parsed = JSON.parse(json); + const btn = parsed.root.children[0]; + + expect(btn.children.length).toBe(2); + const textChild = btn.children.find((c: any) => c.className === "TextBlock"); + const imgChild = btn.children.find((c: any) => c.className === "Image"); + expect(textChild).toBeDefined(); + expect(textChild.text).toBe("Icon Button"); + expect(imgChild).toBeDefined(); + expect(imgChild.source).toBe("icon.png"); + }); + + // ── Test 8: Update button text via setControlProperties ───────────── + + it("updates Button text through setControlProperties", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "Button", "btn", "root", { buttonText: "Old" })); + const result = mgr.setControlProperties("gui", "btn", { buttonText: "New" }); + expect(result).toBe("OK"); + + const json = mgr.exportJSON("gui")!; + const parsed = JSON.parse(json); + const btn = parsed.root.children[0]; + const textChild = btn.children.find((c: any) => c.className === "TextBlock"); + expect(textChild.text).toBe("New"); + }); + + // ── Test 9: Grid with rows, columns, and children ─────────────────── + + it("creates a Grid with rows, columns, and placed children", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "Grid", "grid")); + expect(mgr.addGridRow("gui", "grid", 0.5, false)).toBe("OK"); + expect(mgr.addGridRow("gui", "grid", 0.5, false)).toBe("OK"); + expect(mgr.addGridColumn("gui", "grid", 0.3, false)).toBe("OK"); + expect(mgr.addGridColumn("gui", "grid", 0.7, false)).toBe("OK"); + + getCtrlName(mgr.addControl("gui", "TextBlock", "topLeft", "grid", { text: "TL" }, 0, 0)); + getCtrlName(mgr.addControl("gui", "TextBlock", "bottomRight", "grid", { text: "BR" }, 1, 1)); + + const json = mgr.exportJSON("gui")!; + const parsed = JSON.parse(json); + const grid = parsed.root.children[0]; + + expect(grid.className).toBe("Grid"); + expect(grid.rows.length).toBe(2); + expect(grid.columns.length).toBe(2); + expect(grid.children.length).toBe(2); + + // Verify tags for cell placement + expect(grid.tags).toBeDefined(); + expect(grid.tags).toContain("0:0"); + expect(grid.tags).toContain("1:1"); + }); + + // ── Test 10: Grid row/column operations ───────────────────────────── + + it("supports set and remove grid row/column operations", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "Grid", "grid")); + mgr.addGridRow("gui", "grid", 100, true); + mgr.addGridRow("gui", "grid", 0.5, false); + mgr.addGridColumn("gui", "grid", 200, true); + + // Set (update) row + expect(mgr.setGridRow("gui", "grid", 0, 150, true)).toBe("OK"); + const tex = mgr.getTexture("gui")!; + const grid = tex._controlIndex.get("grid")!; + expect(grid.rows![0]).toEqual({ value: 150, unit: 1 }); + + // Remove column + expect(mgr.removeGridColumn("gui", "grid", 0)).toBe("OK"); + expect(grid.columns!.length).toBe(0); + + // Out-of-range errors + expect(mgr.setGridRow("gui", "grid", 99, 1, false)).toContain("out of range"); + expect(mgr.removeGridRow("gui", "grid", 99)).toContain("out of range"); + }); + + // ── Test 11: Grid operations on non-Grid ──────────────────────────── + + it("rejects grid operations on non-Grid controls", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "Rectangle", "rect")); + expect(mgr.addGridRow("gui", "rect", 0.5, false)).toContain("not a Grid"); + expect(mgr.addGridColumn("gui", "rect", 0.5, false)).toContain("not a Grid"); + }); + + // ── Test 12: Remove control ───────────────────────────────────────── + + it("removes control and cleans up indices", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "Rectangle", "panel")); + getCtrlName(mgr.addControl("gui", "TextBlock", "label", "panel")); + + expect(mgr.removeControl("gui", "panel")).toBe("OK"); + + // Both panel and its child should be gone + const tex = mgr.getTexture("gui")!; + expect(tex._controlIndex.has("panel")).toBe(false); + expect(tex._controlIndex.has("label")).toBe(false); + expect(tex.root.children!.length).toBe(0); + }); + + // ── Test 13: Cannot remove root ───────────────────────────────────── + + it("prevents removing the root container", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + expect(mgr.removeControl("gui", "root")).toContain("Cannot remove"); + }); + + // ── Test 14: Reparent control ─────────────────────────────────────── + + it("reparents control between containers", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "Rectangle", "panelA")); + getCtrlName(mgr.addControl("gui", "Rectangle", "panelB")); + getCtrlName(mgr.addControl("gui", "TextBlock", "text", "panelA", { text: "hello" })); + + // Move text from panelA to panelB + expect(mgr.reparentControl("gui", "text", "panelB")).toBe("OK"); + + const tex = mgr.getTexture("gui")!; + const panelA = tex._controlIndex.get("panelA")!; + const panelB = tex._controlIndex.get("panelB")!; + expect(panelA.children!.length).toBe(0); + expect(panelB.children!.length).toBe(1); + expect(panelB.children![0].name).toBe("text"); + }); + + // ── Test 15: Circular reparent detection ──────────────────────────── + + it("detects circular reparenting", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "Rectangle", "outer")); + getCtrlName(mgr.addControl("gui", "Rectangle", "inner", "outer")); + + // Try to move outer inside inner (circular) + const result = mgr.reparentControl("gui", "outer", "inner"); + expect(typeof result).toBe("string"); + expect(result).toContain("Cannot reparent"); + }); + + // ── Test 16: Reparent into Grid with cell placement ───────────────── + + it("reparents control into Grid with cell assignment", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "Grid", "grid")); + mgr.addGridRow("gui", "grid", 1, false); + mgr.addGridColumn("gui", "grid", 1, false); + getCtrlName(mgr.addControl("gui", "TextBlock", "label", "root", { text: "hi" })); + + expect(mgr.reparentControl("gui", "label", "grid", 0, 0)).toBe("OK"); + + const tex = mgr.getTexture("gui")!; + expect(tex._gridCellIndex.get("label")).toBe("0:0"); + }); + + // ── Test 17: Duplicate name rejection ─────────────────────────────── + + it("rejects duplicate control names", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "TextBlock", "title")); + const result = mgr.addControl("gui", "TextBlock", "title"); + expect(typeof result).toBe("string"); + expect(result).toContain("already exists"); + }); + + // ── Test 18: Auto-generated names ─────────────────────────────────── + + it("auto-generates unique control names", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + const name1 = getCtrlName(mgr.addControl("gui", "TextBlock")); + const name2 = getCtrlName(mgr.addControl("gui", "TextBlock")); + expect(name1).not.toBe(name2); + expect(name1).toContain("textblock"); + }); + + // ── Test 19: Validation catches issues ────────────────────────────── + + it("validation detects empty GUI warning", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + // Empty GUI + const issues = mgr.validateTexture("gui"); + expect(issues.some((i) => i.includes("empty"))).toBe(true); + }); + + // ── Test 19b: addControl warns about Button without buttonText ────── + + it("addControl warns when Button created without buttonText", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + const result = mgr.addControl("gui", "Button", "btn"); + expect(typeof result).not.toBe("string"); + const warnings = (result as any).warnings; + expect(warnings).toBeDefined(); + expect(warnings.some((w: string) => w.includes("no buttonText"))).toBe(true); + }); + + // ── Test 20: Validation passes on good GUI ────────────────────────── + + it("validation passes on a well-formed GUI", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "TextBlock", "title", "root", { text: "Hello" })); + getCtrlName(mgr.addControl("gui", "Button", "btn", "root", { buttonText: "OK" })); + + const issues = mgr.validateTexture("gui"); + expect(issues.some((i) => i.includes("No issues found"))).toBe(true); + }); + + // ── Test 21: Grid validation warnings ─────────────────────────────── + + it("warns about Grid children without row/column definitions", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "Grid", "grid")); + // Add child without defining rows/columns first + const result = mgr.addControl("gui", "TextBlock", "label", "grid", { text: "oops" }); + expect(typeof result).not.toBe("string"); + const warnings = (result as any).warnings; + expect(warnings).toBeDefined(); + expect(warnings.some((w: string) => w.includes("no row definitions"))).toBe(true); + }); + + // ── Test 22: Import/export round-trip ─────────────────────────────── + + it("round-trips through import and export", () => { + const mgr = new GuiManager(); + mgr.createTexture("original"); + + getCtrlName( + mgr.addControl("original", "Rectangle", "panel", "root", { + background: "#333", + width: "400px", + }) + ); + getCtrlName( + mgr.addControl("original", "TextBlock", "label", "panel", { + text: "imported", + }) + ); + + 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.root.children.length).toBe(parsed1.root.children.length); + expect(parsed2.root.children[0].name).toBe("panel"); + expect(parsed2.root.children[0].children[0].text).toBe("imported"); + }); + + it("rejects invalid GUI JSON on import", () => { + const mgr = new GuiManager(); + + expect(mgr.importJSON("bad", '{"width":512}')).toContain("Invalid GUI JSON"); + expect(mgr.importJSON("bad", "not json")).toContain("Invalid GUI JSON: parse error."); + }); + + // ── Test 23: Export strips internal properties ────────────────────── + + it("export strips internal _nextId, _controlIndex, etc.", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "TextBlock", "t", "root", { text: "hi" })); + const json = mgr.exportJSON("gui")!; + + expect(json).not.toContain("_nextId"); + expect(json).not.toContain("_controlIndex"); + expect(json).not.toContain("_parentIndex"); + expect(json).not.toContain("_gridCellIndex"); + }); + + // ── Test 24: Control registry completeness ────────────────────────── + + it("control registry has all expected control types", () => { + const expectedControls = [ + // Containers + "Container", + "Rectangle", + "Ellipse", + // Layout + "StackPanel", + "Grid", + "ScrollViewer", + // Text + "TextBlock", + // Input + "InputText", + "InputPassword", + "InputTextArea", + "Slider", + "ImageBasedSlider", + // Button + "Button", + "FocusableButton", + "ToggleButton", + // Indicator + "Checkbox", + "RadioButton", + "ColorPicker", + // Image + "Image", + // Shape + "Line", + // Misc + "DisplayGrid", + "VirtualKeyboard", + ]; + + for (const controlType of expectedControls) { + expect(ControlRegistry[controlType]).toBeDefined(); + expect(ControlRegistry[controlType].className).toBe(controlType); + } + }); + + // ── Test 25: Base properties are documented ───────────────────────── + + it("base properties cover essential control attributes", () => { + const essentialProps = [ + "width", + "height", + "left", + "top", + "horizontalAlignment", + "verticalAlignment", + "color", + "alpha", + "isVisible", + "fontSize", + "fontFamily", + "isEnabled", + "rotation", + ]; + + for (const prop of essentialProps) { + expect(BaseControlProperties[prop]).toBeDefined(); + } + }); + + // ── Test 26: Unknown control type rejection ───────────────────────── + + it("rejects unknown control types", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + const result = mgr.addControl("gui", "FancyWidget", "w"); + expect(typeof result).toBe("string"); + expect(result).toContain("Unknown control type"); + }); + + // ── Test 27: Missing texture error ────────────────────────────────── + + it("returns errors when texture not found", () => { + const mgr = new GuiManager(); + + expect(typeof mgr.addControl("nope", "TextBlock")).toBe("string"); + expect(mgr.setControlProperties("nope", "x", {})).toContain("not found"); + expect(mgr.removeControl("nope", "x")).toContain("not found"); + expect(mgr.addGridRow("nope", "g", 1, false)).toContain("not found"); + expect(mgr.exportJSON("nope")).toBeNull(); + expect(mgr.validateTexture("nope")[0]).toContain("not found"); + }); + + // ── Test 28: Describe functions ───────────────────────────────────── + + it("describe functions return useful information", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "Rectangle", "panel", "root", { background: "blue" })); + getCtrlName(mgr.addControl("gui", "TextBlock", "label", "panel", { text: "hi" })); + + const texDesc = mgr.describeTexture("gui"); + expect(texDesc).toContain("panel"); + expect(texDesc).toContain("label"); + expect(texDesc).toContain("1920"); // Default width + + const ctrlDesc = mgr.describeControl("gui", "panel"); + expect(ctrlDesc).toContain("Rectangle"); + expect(ctrlDesc).toContain("blue"); + expect(ctrlDesc).toContain("label"); // Child listed + }); + + // ── Test 29: StackPanel isVertical ────────────────────────────────── + + it("StackPanel correctly stores isVertical property", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "StackPanel", "stack", "root", { isVertical: true })); + getCtrlName(mgr.addControl("gui", "TextBlock", "a", "stack", { text: "A" })); + getCtrlName(mgr.addControl("gui", "TextBlock", "b", "stack", { text: "B" })); + + const json = mgr.exportJSON("gui")!; + const parsed = JSON.parse(json); + const stack = parsed.root.children[0]; + expect(stack.isVertical).toBe(true); + expect(stack.children.length).toBe(2); + }); + + // ── Test 30: Slider properties ────────────────────────────────────── + + it("Slider stores min/max/value/step correctly", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName( + mgr.addControl("gui", "Slider", "vol", "root", { + minimum: 0, + maximum: 100, + value: 75, + step: 1, + width: "300px", + height: "30px", + }) + ); + + const json = mgr.exportJSON("gui")!; + const parsed = JSON.parse(json); + const slider = parsed.root.children[0]; + expect(slider.minimum).toBe(0); + expect(slider.maximum).toBe(100); + expect(slider.value).toBe(75); + expect(slider.step).toBe(1); + }); + + // ── Test 31: Checkbox and RadioButton ──────────────────────────────── + + it("Checkbox and RadioButton store isChecked properly", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName( + mgr.addControl("gui", "Checkbox", "cb", "root", { + isChecked: true, + checkSizeRatio: 0.7, + width: "20px", + height: "20px", + }) + ); + + getCtrlName( + mgr.addControl("gui", "RadioButton", "rb", "root", { + isChecked: false, + group: "options", + width: "20px", + height: "20px", + }) + ); + + const json = mgr.exportJSON("gui")!; + const parsed = JSON.parse(json); + const cb = parsed.root.children.find((c: any) => c.name === "cb"); + const rb = parsed.root.children.find((c: any) => c.name === "rb"); + + expect(cb.isChecked).toBe(true); + expect(cb.checkSizeRatio).toBe(0.7); + expect(rb.isChecked).toBe(false); + expect(rb.group).toBe("options"); + }); + + // ── Test 32: Image control ────────────────────────────────────────── + + it("Image stores source and stretch properties", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName( + mgr.addControl("gui", "Image", "img", "root", { + source: "https://example.com/logo.png", + stretch: 2, // STRETCH_UNIFORM + width: "200px", + height: "200px", + }) + ); + + const json = mgr.exportJSON("gui")!; + const parsed = JSON.parse(json); + const img = parsed.root.children[0]; + expect(img.source).toBe("https://example.com/logo.png"); + expect(img.stretch).toBe(2); + }); + + // ── Test 33: InputText properties ─────────────────────────────────── + + it("InputText stores placeholder and text properties", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName( + mgr.addControl("gui", "InputText", "nameField", "root", { + text: "", + placeholderText: "Enter name...", + placeholderColor: "#999", + maxWidth: "300px", + width: "300px", + height: "40px", + }) + ); + + const json = mgr.exportJSON("gui")!; + const parsed = JSON.parse(json); + const input = parsed.root.children[0]; + expect(input.placeholderText).toBe("Enter name..."); + expect(input.placeholderColor).toBe("#999"); + }); + + // ── Test 34: ScrollViewer is a container ──────────────────────────── + + it("ScrollViewer acts as a container", () => { + const mgr = new GuiManager(); + mgr.createTexture("gui"); + + getCtrlName(mgr.addControl("gui", "ScrollViewer", "scroller", "root")); + getCtrlName(mgr.addControl("gui", "TextBlock", "content", "scroller", { text: "scroll me" })); + + const json = mgr.exportJSON("gui")!; + const parsed = JSON.parse(json); + const sv = parsed.root.children[0]; + expect(sv.className).toBe("ScrollViewer"); + expect(sv.children.length).toBe(1); + }); + + // ── Test 35: Complex GUI export structure ─────────────────────────── + + it("complex GUI with Grid, StackPanel, buttons exports correctly", () => { + const mgr = new GuiManager(); + mgr.createTexture("hud"); + + // Grid-based layout + getCtrlName(mgr.addControl("hud", "Grid", "mainGrid")); + mgr.addGridRow("hud", "mainGrid", 50, true); // header + mgr.addGridRow("hud", "mainGrid", 1, false); // body + mgr.addGridRow("hud", "mainGrid", 40, true); // footer + mgr.addGridColumn("hud", "mainGrid", 0.3, false); + mgr.addGridColumn("hud", "mainGrid", 0.7, false); + + // Header + getCtrlName( + mgr.addControl( + "hud", + "TextBlock", + "title", + "mainGrid", + { + text: "Game HUD", + fontSize: "24px", + }, + 0, + 0 + ) + ); + + // Body - stack panel + getCtrlName( + mgr.addControl( + "hud", + "StackPanel", + "sidebar", + "mainGrid", + { + isVertical: true, + }, + 1, + 0 + ) + ); + + getCtrlName(mgr.addControl("hud", "TextBlock", "hp", "sidebar", { text: "HP: 100" })); + getCtrlName( + mgr.addControl("hud", "Slider", "hpBar", "sidebar", { + minimum: 0, + maximum: 100, + value: 100, + height: "20px", + }) + ); + + // Footer button + getCtrlName( + mgr.addControl( + "hud", + "Button", + "menuBtn", + "mainGrid", + { + buttonText: "Menu", + }, + 2, + 0 + ) + ); + + const json = mgr.exportJSON("hud")!; + const parsed = validateGuiJSON(json, "complex HUD"); + + const grid = parsed.root.children[0]; + expect(grid.className).toBe("Grid"); + expect(grid.rows.length).toBe(3); + expect(grid.columns.length).toBe(2); + expect(grid.children.length).toBe(3); // title, sidebar, menuBtn + + // Validate the issues + const issues = mgr.validateTexture("hud"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + }); + + // ── clearAll ──────────────────────────────────────────────────────── + + it("clearAll removes all textures and resets state", () => { + const mgr = new GuiManager(); + mgr.createTexture("a"); + mgr.createTexture("b"); + expect(mgr.listTextures().length).toBe(2); + + mgr.clearAll(); + expect(mgr.listTextures()).toEqual([]); + expect(mgr.getTexture("a")).toBeUndefined(); + expect(mgr.getTexture("b")).toBeUndefined(); + + // Can create new textures after clear + mgr.createTexture("c"); + expect(mgr.listTextures()).toEqual(["c"]); + }); + + it("clearAll on empty manager is a no-op", () => { + const mgr = new GuiManager(); + mgr.clearAll(); + expect(mgr.listTextures()).toEqual([]); + }); +}); diff --git a/packages/tools/gui-mcp-server/test/unit/guiParse.test.ts b/packages/tools/gui-mcp-server/test/unit/guiParse.test.ts new file mode 100644 index 00000000000..b1432f61abd --- /dev/null +++ b/packages/tools/gui-mcp-server/test/unit/guiParse.test.ts @@ -0,0 +1,305 @@ +/** + * GUI MCP Server – Babylon.js Parse Validation + * + * Tests that JSON produced by the GUI MCP server can be parsed by Babylon.js's + * AdvancedDynamicTexture.parseSerializedObject() without errors. This proves + * the exported JSON structure is fully compatible with the Babylon.js GUI runtime. + */ + +// Polyfill OffscreenCanvas for Node.js (used by AbstractEngine._CreateCanvas) +if (typeof globalThis.OffscreenCanvas === "undefined") { + (globalThis as any).OffscreenCanvas = class OffscreenCanvas { + width: number; + height: number; + constructor(w: number, h: number) { + this.width = w; + this.height = h; + } + getContext() { + return { + fillRect: () => {}, + clearRect: () => {}, + getImageData: () => ({ data: new Uint8Array(0) }), + putImageData: () => {}, + measureText: () => ({ width: 0 }), + fillText: () => {}, + strokeText: () => {}, + setTransform: () => {}, + drawImage: () => {}, + save: () => {}, + restore: () => {}, + beginPath: () => {}, + moveTo: () => {}, + lineTo: () => {}, + closePath: () => {}, + stroke: () => {}, + fill: () => {}, + translate: () => {}, + scale: () => {}, + rotate: () => {}, + arc: () => {}, + rect: () => {}, + clip: () => {}, + canvas: { width: 256, height: 256 }, + lineWidth: 1, + strokeStyle: "", + fillStyle: "", + font: "", + textAlign: "", + textBaseline: "", + globalAlpha: 1, + }; + } + toBlob() {} + toDataURL() { + return ""; + } + }; +} + +import { NullEngine } from "core/Engines"; +import { Scene } from "core/scene"; +import { AdvancedDynamicTexture } from "gui/2D/advancedDynamicTexture"; + +// Side-effect imports: register ALL GUI control types via RegisterClass +import "gui/2D/controls/index"; + +import { GuiManager } from "../../src/guiManager"; + +function getCtrlName(result: ReturnType): string { + if (typeof result === "string") { + throw new Error(result); + } + return result.name; +} + +describe("GUI MCP Server – Babylon.js Parse", () => { + let engine: NullEngine; + let scene: Scene; + + beforeEach(() => { + engine = new NullEngine({ + renderHeight: 256, + renderWidth: 256, + textureSize: 256, + deterministicLockstep: false, + lockstepMaxSteps: 1, + }); + scene = new Scene(engine); + }); + + afterEach(() => { + scene.dispose(); + engine.dispose(); + }); + + // ── Helper: Build a GUI, export, and parse with Babylon.js ────────── + + function buildAndParse(name: string, builder: (mgr: GuiManager) => void): AdvancedDynamicTexture { + const mgr = new GuiManager(); + mgr.createTexture(name); + builder(mgr); + + const json = mgr.exportJSON(name); + expect(json).not.toBeNull(); + + const parsed = JSON.parse(json!); + const adt = AdvancedDynamicTexture.CreateFullscreenUI(name, true, scene); + adt.parseSerializedObject(parsed, true); + return adt; + } + + // ── Test 1: Simple TextBlock ──────────────────────────────────────── + + it("parses a simple TextBlock layout", () => { + const adt = buildAndParse("simple", (mgr) => { + getCtrlName( + mgr.addControl("simple", "TextBlock", "title", "root", { + text: "Hello Babylon", + color: "white", + fontSize: "32px", + }) + ); + }); + + const root = adt.getChildren()[0]; + expect(root).toBeDefined(); + expect(root.name).toBe("root"); + + // root is a Container, so get its children + const children = (root as any).children; + expect(children.length).toBe(1); + expect(children[0].name).toBe("title"); + expect(children[0].typeName).toBe("TextBlock"); + + adt.dispose(); + }); + + // ── Test 2: Nested container hierarchy ────────────────────────────── + + it("parses nested Rectangle > StackPanel > TextBlock", () => { + const adt = buildAndParse("nested", (mgr) => { + getCtrlName(mgr.addControl("nested", "Rectangle", "panel")); + getCtrlName(mgr.addControl("nested", "StackPanel", "stack", "panel", { isVertical: true })); + getCtrlName(mgr.addControl("nested", "TextBlock", "label", "stack", { text: "Nested!" })); + }); + + const root = adt.getChildren()[0] as any; + const panel = root.children[0]; + expect(panel.name).toBe("panel"); + expect(panel.typeName).toBe("Rectangle"); + + const stack = panel.children[0]; + expect(stack.name).toBe("stack"); + + const label = stack.children[0]; + expect(label.name).toBe("label"); + expect(label.typeName).toBe("TextBlock"); + + adt.dispose(); + }); + + // ── Test 3: Button with TextBlock child ───────────────────────────── + + it("parses Button with auto-created TextBlock child", () => { + const adt = buildAndParse("btn", (mgr) => { + getCtrlName( + mgr.addControl("btn", "Button", "myBtn", "root", { + buttonText: "Click Me", + background: "#4CAF50", + }) + ); + }); + + const root = adt.getChildren()[0] as any; + const btn = root.children[0]; + expect(btn.name).toBe("myBtn"); + expect(btn.typeName).toBe("Button"); + + const textChild = btn.children[0]; + expect(textChild).toBeDefined(); + expect(textChild.typeName).toBe("TextBlock"); + + adt.dispose(); + }); + + // ── Test 4: Grid with rows, columns, and placed controls ──────────── + + it("parses Grid with rows, columns, and cell placement", () => { + const adt = buildAndParse("grid", (mgr) => { + getCtrlName(mgr.addControl("grid", "Grid", "mainGrid")); + mgr.addGridRow("grid", "mainGrid", 0.5, false); + mgr.addGridRow("grid", "mainGrid", 0.5, false); + mgr.addGridColumn("grid", "mainGrid", 0.5, false); + mgr.addGridColumn("grid", "mainGrid", 0.5, false); + + getCtrlName(mgr.addControl("grid", "TextBlock", "topLeft", "mainGrid", { text: "TL" }, 0, 0)); + getCtrlName(mgr.addControl("grid", "TextBlock", "bottomRight", "mainGrid", { text: "BR" }, 1, 1)); + }); + + const root = adt.getChildren()[0] as any; + const grid = root.children[0]; + expect(grid.name).toBe("mainGrid"); + expect(grid.typeName).toBe("Grid"); + + // Grid should have parsed rows and columns + expect(grid.rowCount).toBe(2); + expect(grid.columnCount).toBe(2); + + // Controls should be in the grid + expect(grid.children.length).toBe(2); + + adt.dispose(); + }); + + // ── Test 5: Complex Settings Dialog ───────────────────────────────── + + it("parses a complex settings dialog with multiple control types", () => { + const adt = buildAndParse("settings", (mgr) => { + // Overlay + getCtrlName( + mgr.addControl("settings", "Rectangle", "overlay", "root", { + width: "1", + height: "1", + background: "rgba(0,0,0,0.7)", + thickness: 0, + }) + ); + + // Dialog panel + getCtrlName( + mgr.addControl("settings", "Rectangle", "dialog", "overlay", { + width: "500px", + height: "600px", + background: "#2D2D2D", + cornerRadius: 15, + }) + ); + + // Title + getCtrlName( + mgr.addControl("settings", "TextBlock", "title", "dialog", { + text: "Settings", + color: "white", + fontSize: "24px", + }) + ); + + // Stack with slider and checkbox + getCtrlName( + mgr.addControl("settings", "StackPanel", "options", "dialog", { + isVertical: true, + width: "400px", + }) + ); + + getCtrlName( + mgr.addControl("settings", "Slider", "volSlider", "options", { + minimum: 0, + maximum: 100, + value: 75, + height: "30px", + }) + ); + + getCtrlName( + mgr.addControl("settings", "Checkbox", "fsCb", "options", { + isChecked: false, + width: "20px", + height: "20px", + }) + ); + + // Button + getCtrlName( + mgr.addControl("settings", "Button", "okBtn", "dialog", { + buttonText: "OK", + width: "120px", + height: "40px", + }) + ); + }); + + const root = adt.getChildren()[0] as any; + expect(root.name).toBe("root"); + + // Traverse: root > overlay > dialog > [title, options, okBtn] + const overlay = root.children[0]; + expect(overlay.typeName).toBe("Rectangle"); + + const dialog = overlay.children[0]; + expect(dialog.typeName).toBe("Rectangle"); + expect(dialog.children.length).toBe(3); // title, options, okBtn + + // Verify the slider was parsed with correct value + const options = dialog.children[1]; + const slider = options.children[0]; + expect(slider.typeName).toBe("Slider"); + expect(slider.value).toBe(75); + + const checkbox = options.children[1]; + expect(checkbox.typeName).toBe("Checkbox"); + + adt.dispose(); + }); +}); diff --git a/packages/tools/gui-mcp-server/tsconfig.json b/packages/tools/gui-mcp-server/tsconfig.json new file mode 100644 index 00000000000..2c6df59671a --- /dev/null +++ b/packages/tools/gui-mcp-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "types": ["node"], + "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/guiEditor/src/components/mcpSession/mcpSessionComponent.tsx b/packages/tools/guiEditor/src/components/mcpSession/mcpSessionComponent.tsx new file mode 100644 index 00000000000..c07d5107bf1 --- /dev/null +++ b/packages/tools/guiEditor/src/components/mcpSession/mcpSessionComponent.tsx @@ -0,0 +1,172 @@ +import { type FunctionComponent, useCallback, useEffect, useState } from "react"; +import { type GlobalState } from "../../globalState"; +import { LogEntry } from "../log/logComponent"; +import { LineContainerComponent } from "shared-ui-components/lines/lineContainerComponent"; +import { ButtonLineComponent } from "shared-ui-components/lines/buttonLineComponent"; +import { TextInputLineComponent } from "shared-ui-components/lines/textInputLineComponent"; +import { TextLineComponent } from "shared-ui-components/lines/textLineComponent"; +import { + CloseMcpEditorSessionEventSource, + NormalizeMcpEditorSessionUrl, + OpenMcpEditorSessionEventSource, + PostMcpEditorSessionDocumentAsync, +} from "shared-ui-components/mcp/mcpEditorSessionConnection"; + +interface IMcpSessionComponentProps { + globalState: GlobalState; +} + +function SerializeGui(globalState: GlobalState): string { + globalState.workbench.removeEditorTransformation(); + const allControls = globalState.guiTexture.rootContainer.getDescendants(); + for (const control of allControls) { + globalState.workbench.removeEditorBehavior(control); + } + try { + const size = globalState.workbench.guiSize; + globalState.guiTexture.scaleTo(size.width, size.height); + return JSON.stringify(globalState.guiTexture.serializeContent()); + } finally { + for (const control of allControls) { + globalState.workbench.addEditorBehavior(control); + } + } +} + +/** + * Panel that connects to a live MCP session for bidirectional GUI sync. + * @param props - Component props. + * @returns The React element. + */ +export const McpSessionComponent: FunctionComponent = (props) => { + const { globalState } = props; + const [url, setUrl] = useState(globalState.mcpSessionUrl ?? ""); + const [connected, setConnected] = useState(globalState.mcpSessionConnected); + + useEffect(() => { + const observer = globalState.onMcpSessionStateChangedObservable.add((state) => { + setConnected(state); + }); + setConnected(globalState.mcpSessionConnected); + if (globalState.mcpSessionUrl) { + setUrl(globalState.mcpSessionUrl); + } + return () => { + globalState.onMcpSessionStateChangedObservable.remove(observer); + }; + }, [globalState]); + + const loadGuiFromJson = useCallback( + (json: unknown) => { + globalState.workbench.loadFromJson(json); + globalState.onResetRequiredObservable.notifyObservers(); + globalState.onBuiltObservable.notifyObservers(); + globalState.onPropertyGridUpdateRequiredObservable.notifyObservers(); + }, + [globalState] + ); + + const handleConnect = useCallback( + async (pushOnConnect: boolean = false) => { + const sessionUrl = NormalizeMcpEditorSessionUrl(url); + if (!sessionUrl) { + return; + } + + try { + if (pushOnConnect && globalState.guiTexture) { + await PostMcpEditorSessionDocumentAsync(sessionUrl, SerializeGui(globalState)); + } + + CloseMcpEditorSessionEventSource(globalState.mcpEventSource); + globalState.mcpEventSource = null; + + const eventSource = OpenMcpEditorSessionEventSource({ + sessionUrl, + onDocument: loadGuiFromJson, + onSessionClosed: (reason) => { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session ended: ${reason}`, false)); + globalState.mcpSessionConnected = false; + globalState.mcpEventSource = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, + onConnectionError: () => { + globalState.mcpSessionConnected = false; + globalState.mcpEventSource = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, + }); + globalState.mcpEventSource = eventSource; + + globalState.mcpSessionUrl = sessionUrl; + globalState.mcpSessionConnected = true; + globalState.onMcpSessionStateChangedObservable.notifyObservers(true); + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Connection failed - ${err}`, true)); + } + }, + [url, globalState, loadGuiFromJson] + ); + + const handleDisconnect = useCallback(() => { + CloseMcpEditorSessionEventSource(globalState.mcpEventSource); + globalState.mcpEventSource = null; + globalState.mcpSessionConnected = false; + globalState.mcpSessionUrl = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, [globalState]); + + const handlePush = useCallback(async () => { + if (!globalState.mcpSessionUrl || !globalState.guiTexture) { + return; + } + try { + const res = await PostMcpEditorSessionDocumentAsync(globalState.mcpSessionUrl, SerializeGui(globalState)); + if (!res.ok) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Push failed (${res.status})`, true)); + } + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Push failed - ${err}`, true)); + } + }, [globalState]); + + return ( + + setUrl(value)} + placeholder="http://localhost:3001/session/..." + disabled={connected} + lockObject={globalState.lockObject} + /> + + {!connected ? ( + <> + { + void handleConnect(false); + }} + /> + { + void handleConnect(true); + }} + /> + + ) : ( + <> + + { + void handlePush(); + }} + /> + + )} + + ); +}; diff --git a/packages/tools/guiEditor/src/components/propertyTab/propertyTabComponent.tsx b/packages/tools/guiEditor/src/components/propertyTab/propertyTabComponent.tsx index bae57cb0b33..3aecdb6a6e6 100644 --- a/packages/tools/guiEditor/src/components/propertyTab/propertyTabComponent.tsx +++ b/packages/tools/guiEditor/src/components/propertyTab/propertyTabComponent.tsx @@ -45,6 +45,7 @@ import { type Button } from "gui/2D/controls/button"; import { ButtonPropertyGridComponent } from "./propertyGrids/gui/buttonPropertyGridComponent"; import { GUINodeTools } from "../../guiNodeTools"; import { makeTargetsProxy } from "shared-ui-components/lines/targetsProxy"; +import { McpSessionComponent } from "../mcpSession/mcpSessionComponent"; import "./propertyTab.scss"; import adtIcon from "../../imgs/adtIcon.svg"; @@ -565,6 +566,11 @@ export class PropertyTabComponent extends React.Component 0 ? this.props.globalState.selectedControls : [this.props.globalState.workbench.trueRootContainer]; - return
{this.renderNode(nodesToRender)}
; + return ( +
+ + {this.renderNode(nodesToRender)} +
+ ); } } diff --git a/packages/tools/guiEditor/src/globalState.ts b/packages/tools/guiEditor/src/globalState.ts index f384e794e11..0dab4327478 100644 --- a/packages/tools/guiEditor/src/globalState.ts +++ b/packages/tools/guiEditor/src/globalState.ts @@ -125,6 +125,11 @@ export class GlobalState { storeEditorData: (serializationObject: any) => void; shiftKeyPressed: boolean = false; + mcpSessionUrl: string | null = null; + mcpSessionConnected: boolean = false; + mcpEventSource: EventSource | null = null; + onMcpSessionStateChangedObservable = new Observable(); + customSave?: { label: string; action: (data: string) => Promise }; customLoad?: { label: string; action: (data: string) => Promise }; public constructor() { diff --git a/packages/tools/mcp-server-core/README.md b/packages/tools/mcp-server-core/README.md new file mode 100644 index 00000000000..c725f8b804c --- /dev/null +++ b/packages/tools/mcp-server-core/README.md @@ -0,0 +1,51 @@ +# @tools/mcp-server-core + +Shared internal utilities for the Babylon.js MCP server packages. + +This package is not a standalone MCP server. It provides the common infrastructure used by the server entrypoints under `packages/tools/*-mcp-server`. + +## What It Contains + +- text handoff helpers for inline JSON vs file-backed JSON inputs +- shared JSON parsing and input validation helpers +- common MCP text response builders +- shared JSON import/export/snippet response helpers +- shared tool schema fragments for repeated Zod fields +- shared Scene-specific schema groups and attachment validation + +## Typical Usage + +The server packages consume this package from their entrypoints to avoid repeating the same MCP boilerplate: + +```ts +import { CreateJsonExportResponse, CreateJsonImportResponse, CreateOutputFileSchema, CreateJsonFileSchema } from "../../mcp-server-core/dist/index.js"; +``` + +That keeps repeated handler logic centralized while preserving clear, package-local tool definitions. + +## Build + +```bash +npm run build -w @tools/mcp-server-core +``` + +## Tests + +```bash +npx jest packages/tools/mcp-server-core/test/unit --runInBand +``` + +## Main Modules + +- `textHandoff.ts`: inline-vs-file input resolution and file writing +- `jsonValidation.ts`: shared JSON parsing +- `inputValidation.ts`: shared argument presence and alias helpers +- `response.ts`: shared MCP text responses +- `jsonToolResponses.ts`: shared import/export/snippet response builders +- `toolSchemas.ts`: shared field-level Zod schema fragments +- `sceneToolSchemas.ts`: Scene-specific grouped field fragments +- `sceneAttachmentValidation.ts`: shared scene attachment contract validation + +## Consumers + +This package is currently consumed by the Babylon.js MCP server packages for Node Material, Flow Graph, GUI, Node Geometry, Node Render Graph, Node Particle, and Scene workflows. diff --git a/packages/tools/mcp-server-core/package.json b/packages/tools/mcp-server-core/package.json new file mode 100644 index 00000000000..a28866eb9ea --- /dev/null +++ b/packages/tools/mcp-server-core/package.json @@ -0,0 +1,18 @@ +{ + "name": "@tools/mcp-server-core", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "npm run clean && npm run compile", + "clean": "rimraf dist", + "compile": "tsc -b tsconfig.build.json" + }, + "sideEffects": false +} diff --git a/packages/tools/mcp-server-core/src/editorSessionServer.ts b/packages/tools/mcp-server-core/src/editorSessionServer.ts new file mode 100644 index 00000000000..bca3988a0af --- /dev/null +++ b/packages/tools/mcp-server-core/src/editorSessionServer.ts @@ -0,0 +1,1154 @@ +import * as crypto from "node:crypto"; +import * as http from "node:http"; + +const DefaultProtocolName = "babylon-mcp-editor-session"; +const DefaultProtocolVersion = "1.0"; +const DefaultHost = "127.0.0.1"; +const DefaultPublicHostname = "localhost"; +const DefaultPort = 3001; +const DefaultPortRange = 10; +const DefaultKeepAliveIntervalMs = 15_000; +const DefaultIdleTimeoutMs = 15 * 60_000; +const DefaultConflictPolicy = "last-writer-wins"; + +/** + * A live document session shared between an MCP server and an editor. + */ +export interface IMcpEditorSession { + /** Unique session identifier used in HTTP routes. */ + id: string; + /** Graph or document kind, such as `node-material`. */ + kind: string; + /** MCP-server-local document name. */ + name: string; + /** Creation timestamp in milliseconds since epoch. */ + createdAt: number; + /** Last update timestamp in milliseconds since epoch. */ + updatedAt: number; + /** Monotonic revision number incremented on document updates. */ + revision: number; +} + +/** + * Adapter implemented by each MCP server to connect the generic session server + * to that server's graph/document manager. + */ +export interface IMcpEditorSessionAdapter { + /** Human-readable server name used in status and health output. */ + serverName: string; + /** Stable document kind handled by this MCP server. */ + documentKind: string; + /** + * Export the current JSON document for a session. + * @param session - Session whose document should be read. + * @returns The JSON document, or undefined if it is unavailable. + */ + getDocument(session: IMcpEditorSession): string | undefined | Promise; + /** + * Import a JSON document pushed by an editor. + * @param session - Session whose document should be updated. + * @param document - Raw JSON document posted by the editor. + * @returns Undefined/void on success, or an error message on failure. + */ + setDocument(session: IMcpEditorSession, document: string): string | void | Promise; +} + +/** + * Options for the shared MCP/editor session server. + */ +export interface IMcpEditorSessionServerOptions { + /** Default port to use when no port is passed to start. */ + defaultPort?: number; + /** Number of sequential ports to try. Defaults to 10. */ + portRange?: number; + /** Host interface to bind. Defaults to 127.0.0.1. */ + host?: string; + /** Hostname used in generated URLs. Defaults to localhost. */ + publicHostname?: string; + /** Protocol version reported from /health. */ + protocolVersion?: string; + /** SSE keepalive interval in milliseconds. */ + keepAliveIntervalMs?: number; + /** Idle timeout in milliseconds before the server stops itself. Set to 0 to disable. Defaults to 15 minutes. */ + idleTimeoutMs?: number; + /** Access-Control-Allow-Origin value. When omitted, local origins are reflected. */ + corsOrigin?: string; + /** Exact origins allowed when corsOrigin is omitted. Defaults to local localhost/127.0.0.1 origins. */ + allowedOrigins?: string[]; + /** Compatibility routes that should behave like /document, without leading slash. */ + legacyDocumentRoutes?: string[]; + /** Additional capability names reported from /health. */ + capabilities?: string[]; + /** Optional status-page title. */ + statusTitle?: string; + /** Stable local workspace identity reported from /health. Defaults to a hash of the current working directory. */ + workspaceId?: string; + /** Per-server owner identity reported from /health. Defaults to a random process-local value. */ + ownerId?: string; +} + +/** + * Adapter implemented by an MCP server to bind the shared session controller to + * that server's graph/document manager. + */ +export interface IMcpEditorSessionControllerAdapter { + /** Human-readable server name used in status and health output. */ + serverName: string; + /** Stable document kind handled by this MCP server. */ + documentKind: string; + /** Error returned if an editor posts a document before a manager is attached. */ + managerUnavailableMessage?: string; + /** + * Export the current JSON document for a session. + * @param manager - MCP server graph/document manager. + * @param session - Session whose document should be read. + * @returns The JSON document, or undefined if unavailable. + */ + getDocument(manager: Manager, session: IMcpEditorSession): string | undefined | Promise; + /** + * Import a JSON document pushed by an editor. + * @param manager - MCP server graph/document manager. + * @param session - Session whose document should be updated. + * @param document - Raw JSON document posted by the editor. + * @returns Undefined/void on success, or an error message on failure. + */ + setDocument(manager: Manager, session: IMcpEditorSession, document: string): string | void | Promise; +} + +/** + * Health payload returned by the shared session server. + */ +export interface IMcpEditorSessionHealth { + /** Stable protocol name. */ + protocol: string; + /** Protocol version string. */ + protocolVersion: string; + /** Human-readable server name. */ + serverName: string; + /** Bound port, or 0 when stopped. */ + port: number; + /** Bound host interface. */ + host: string; + /** Public hostname used in generated URLs. */ + publicHostname: string; + /** Document kind served by this MCP server. */ + documentKind: string; + /** Whether the HTTP server is currently listening. */ + running: boolean; + /** Number of active sessions. */ + activeSessionCount: number; + /** Supported capability names. */ + capabilities: string[]; + /** Conflict policy used when editor and MCP writes race. */ + conflictPolicy?: typeof DefaultConflictPolicy; + /** Idle timeout in milliseconds before the server stops itself. */ + idleTimeoutMs?: number; + /** Last server activity timestamp in milliseconds since epoch. */ + lastActivityAt?: number; + /** Stable local workspace identity. */ + workspaceId?: string; + /** Per-server owner identity. */ + ownerId?: string; +} + +/** + * Options for checking whether a discovered session server is compatible. + */ +export interface IMcpEditorSessionCompatibilityOptions { + /** Expected protocol version. Defaults to the current protocol version. */ + protocolVersion?: string; + /** Expected document kind. When omitted, any document kind is accepted. */ + documentKind?: string; + /** Expected server name. When omitted, any server name is accepted. */ + serverName?: string; + /** Expected local workspace identity. When omitted, any workspace identity is accepted. */ + workspaceId?: string; + /** Expected server owner identity. When omitted, any owner identity is accepted. */ + ownerId?: string; +} + +/** + * Options for discovering an existing compatible MCP editor session server. + */ +export interface IMcpEditorSessionDiscoveryOptions extends IMcpEditorSessionCompatibilityOptions { + /** First port to probe. Defaults to 3001. */ + startPort?: number; + /** Number of sequential ports to probe. Defaults to 10. */ + portRange?: number; + /** Hostname to probe. Defaults to localhost. */ + publicHostname?: string; + /** Request timeout in milliseconds. Defaults to 500. */ + timeoutMs?: number; +} + +/** + * Result of finding an existing compatible session server. + */ +export interface IMcpEditorSessionDiscoveryResult { + /** Port where the compatible server was found. */ + port: number; + /** Health payload returned by the compatible server. */ + health: IMcpEditorSessionHealth; +} + +/** + * Diagnostics payload returned by the shared session server. + */ +export interface IMcpEditorSessionDiagnostics { + /** Current server health payload. */ + health: IMcpEditorSessionHealth; + /** Machine-readable active session list. */ + sessions: Record[]; + /** Total number of open SSE client connections. */ + sseClientCount: number; + /** Server start timestamp in milliseconds since epoch, or 0 when stopped. */ + startedAt: number; + /** Server uptime in milliseconds, or 0 when stopped. */ + uptimeMs: number; + /** SSE keepalive interval in milliseconds. */ + keepAliveIntervalMs: number; + /** Idle timeout in milliseconds before the server stops itself. */ + idleTimeoutMs?: number; + /** Last server activity timestamp in milliseconds since epoch. */ + lastActivityAt?: number; +} + +/** + * Create a session URL for a known port and session id. + * @param sessionId - Session identifier. + * @param port - HTTP server port. + * @param publicHostname - Hostname to place in the URL. + * @returns A browser-usable local session URL. + */ +export function GetMcpEditorSessionUrl(sessionId: string, port: number, publicHostname: string = DefaultPublicHostname): string { + return `http://${publicHostname}:${port}/session/${sessionId}`; +} + +/** + * Check whether an unknown /health payload is compatible with this protocol. + * @param health - Candidate health payload. + * @param options - Optional compatibility constraints. + * @returns True when the payload is compatible. + */ +export function IsCompatibleMcpEditorSessionHealth(health: unknown, options: IMcpEditorSessionCompatibilityOptions = {}): health is IMcpEditorSessionHealth { + if (!IsRecord(health)) { + return false; + } + + if (health.protocol !== DefaultProtocolName || health.protocolVersion !== (options.protocolVersion ?? DefaultProtocolVersion)) { + return false; + } + + if (health.running !== true || typeof health.port !== "number" || typeof health.host !== "string" || typeof health.publicHostname !== "string") { + return false; + } + + if (typeof health.serverName !== "string" || typeof health.documentKind !== "string" || typeof health.activeSessionCount !== "number") { + return false; + } + + if (!Array.isArray(health.capabilities) || !health.capabilities.every((capability) => typeof capability === "string")) { + return false; + } + + if (health.conflictPolicy !== undefined && health.conflictPolicy !== DefaultConflictPolicy) { + return false; + } + + if (health.workspaceId !== undefined && typeof health.workspaceId !== "string") { + return false; + } + + if (health.ownerId !== undefined && typeof health.ownerId !== "string") { + return false; + } + + if (options.documentKind && health.documentKind !== options.documentKind) { + return false; + } + + if (options.serverName && health.serverName !== options.serverName) { + return false; + } + + if (options.workspaceId && health.workspaceId !== options.workspaceId) { + return false; + } + + if (options.ownerId && health.ownerId !== options.ownerId) { + return false; + } + + return true; +} + +/** + * Probe a port for an MCP editor session server /health payload. + * @param port - Port to probe. + * @param publicHostname - Hostname to probe. Defaults to localhost. + * @param timeoutMs - Request timeout in milliseconds. Defaults to 500. + * @returns The parsed health payload, or undefined if no valid health payload is returned. + */ +export async function ProbeMcpEditorSessionHealthAsync( + port: number, + publicHostname: string = DefaultPublicHostname, + timeoutMs: number = 500 +): Promise { + return await new Promise((resolve) => { + let settled = false; + const finish = (health: IMcpEditorSessionHealth | undefined) => { + if (!settled) { + settled = true; + resolve(health); + } + }; + + const request = http.get({ hostname: publicHostname, port, path: "/health", timeout: timeoutMs }, (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.on("end", () => { + if (response.statusCode !== 200) { + finish(undefined); + return; + } + + try { + const payload = JSON.parse(Buffer.concat(chunks).toString("utf-8")); + finish(IsCompatibleMcpEditorSessionHealth(payload) ? payload : undefined); + } catch { + finish(undefined); + } + }); + }); + + request.on("timeout", () => { + request.destroy(); + finish(undefined); + }); + request.on("error", () => finish(undefined)); + }); +} + +/** + * Find a compatible MCP editor session server in a local port range. + * @param options - Discovery and compatibility options. + * @returns The compatible server discovery result, or undefined if none is found. + */ +export async function FindCompatibleMcpEditorSessionServerAsync(options: IMcpEditorSessionDiscoveryOptions = {}): Promise { + const startPort = options.startPort ?? DefaultPort; + const portRange = options.portRange ?? DefaultPortRange; + const publicHostname = options.publicHostname ?? DefaultPublicHostname; + const timeoutMs = options.timeoutMs ?? 500; + + for (let portCandidate = startPort; portCandidate < startPort + portRange; portCandidate++) { + // eslint-disable-next-line no-await-in-loop + const health = await ProbeMcpEditorSessionHealthAsync(portCandidate, publicHostname, timeoutMs); + if (IsCompatibleMcpEditorSessionHealth(health, options)) { + return { port: portCandidate, health }; + } + } + + return undefined; +} + +function IsRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function CreateDefaultWorkspaceId(): string { + return crypto.createHash("sha256").update(process.cwd()).digest("hex"); +} + +function CreateDefaultOwnerId(): string { + return `${process.pid}-${crypto.randomBytes(8).toString("hex")}`; +} + +/** + * Generic local HTTP/SSE session server for MCP graph editors. + */ +export class McpEditorSessionServer { + private readonly _adapter: IMcpEditorSessionAdapter; + private readonly _defaultPort: number; + private readonly _portRange: number; + private readonly _host: string; + private readonly _publicHostname: string; + private readonly _protocolVersion: string; + private readonly _keepAliveIntervalMs: number; + private readonly _idleTimeoutMs: number; + private readonly _corsOrigin: string | undefined; + private readonly _allowedOrigins: Set | null; + private readonly _legacyDocumentRoutes: Set; + private readonly _capabilities: string[]; + private readonly _statusTitle: string; + private readonly _workspaceId: string; + private readonly _ownerId: string; + + private _server: http.Server | null = null; + private _port = 0; + private _keepAliveInterval: ReturnType | null = null; + private _idleTimeout: ReturnType | null = null; + private readonly _sessions = new Map(); + private readonly _sessionByName = new Map(); + private readonly _sseClients = new Map>(); + private _startedAt = 0; + private _lastActivityAt = 0; + + /** + * Creates a new reusable session server instance. + * @param adapter - MCP-server-specific document adapter. + * @param options - Server configuration options. + */ + public constructor(adapter: IMcpEditorSessionAdapter, options: IMcpEditorSessionServerOptions = {}) { + this._adapter = adapter; + this._defaultPort = options.defaultPort ?? DefaultPort; + this._portRange = options.portRange ?? DefaultPortRange; + this._host = options.host ?? DefaultHost; + this._publicHostname = options.publicHostname ?? DefaultPublicHostname; + this._protocolVersion = options.protocolVersion ?? DefaultProtocolVersion; + this._keepAliveIntervalMs = options.keepAliveIntervalMs ?? DefaultKeepAliveIntervalMs; + this._idleTimeoutMs = options.idleTimeoutMs ?? DefaultIdleTimeoutMs; + this._corsOrigin = options.corsOrigin; + this._allowedOrigins = options.allowedOrigins ? new Set(options.allowedOrigins) : null; + this._legacyDocumentRoutes = new Set((options.legacyDocumentRoutes ?? []).map((route) => route.replace(/^\//, ""))); + this._capabilities = [ + "sse", + "document-get", + "document-post", + "session-close", + "session-list", + "diagnostics", + "idle-timeout", + DefaultConflictPolicy, + ...(options.capabilities ?? []), + ]; + this._statusTitle = options.statusTitle ?? adapter.serverName; + this._workspaceId = options.workspaceId ?? CreateDefaultWorkspaceId(); + this._ownerId = options.ownerId ?? CreateDefaultOwnerId(); + } + + /** + * Whether the HTTP server is currently listening. + * @returns True when the server is running. + */ + public isRunning(): boolean { + return this._server !== null && this._server.listening; + } + + /** + * Start the HTTP/SSE server if it is not already running. + * @param port - Optional first port to try. + * @returns The port that is listening. + */ + public async startAsync(port: number = this._defaultPort): Promise { + if (this.isRunning()) { + return this._port; + } + + const endPort = port + this._portRange - 1; + this._port = await this._tryPortRangeAsync(port, endPort); + this._startedAt = Date.now(); + this._recordActivity(); + this._keepAliveInterval = setInterval(() => { + for (const clients of this._sseClients.values()) { + for (const response of clients) { + response.write(": ping\n\n"); + } + } + }, this._keepAliveIntervalMs); + + return this._port; + } + + /** + * Stop the HTTP/SSE server and close all active sessions. + * @param reason - Optional reason sent to connected editor clients. + * @returns Resolves when the server has stopped. + */ + public async stopAsync(reason: string = "Session server stopped"): Promise { + if (this._keepAliveInterval) { + clearInterval(this._keepAliveInterval); + this._keepAliveInterval = null; + } + + if (this._idleTimeout) { + clearTimeout(this._idleTimeout); + this._idleTimeout = null; + } + + for (const sessionId of [...this._sessions.keys()]) { + this.closeSession(sessionId, reason); + } + + if (this._idleTimeout) { + clearTimeout(this._idleTimeout); + this._idleTimeout = null; + } + + if (!this._server) { + this._port = 0; + this._startedAt = 0; + this._lastActivityAt = 0; + return; + } + + await new Promise((resolve, reject) => { + this._server!.close((error) => { + this._server = null; + this._port = 0; + this._startedAt = 0; + this._lastActivityAt = 0; + if (error) { + reject(new Error(error.message)); + return; + } + resolve(); + }); + }); + } + + /** + * Create or retrieve a session for a named document. + * @param name - MCP-server-local document name. + * @param kind - Optional document kind override. + * @returns The existing or newly created session. + */ + public createSession(name: string, kind: string = this._adapter.documentKind): IMcpEditorSession { + const sessionKey = this._getSessionKey(kind, name); + const existingSessionId = this._sessionByName.get(sessionKey); + if (existingSessionId) { + const existingSession = this._sessions.get(existingSessionId); + if (existingSession) { + return existingSession; + } + } + + const now = Date.now(); + const session: IMcpEditorSession = { + id: crypto.randomBytes(16).toString("hex"), + kind, + name, + createdAt: now, + updatedAt: now, + revision: 0, + }; + this._sessions.set(session.id, session); + this._sessionByName.set(sessionKey, session.id); + this._sseClients.set(session.id, new Set()); + this._recordActivity(); + return session; + } + + /** + * Get an active session id by document name. + * @param name - MCP-server-local document name. + * @param kind - Optional document kind override. + * @returns The session id, or undefined if no session exists. + */ + public getSessionIdForName(name: string, kind: string = this._adapter.documentKind): string | undefined { + return this._sessionByName.get(this._getSessionKey(kind, name)); + } + + /** + * Get a browser-usable URL for a session. + * @param sessionId - Session identifier. + * @param port - Optional port override. + * @returns The full local session URL. + */ + public getSessionUrl(sessionId: string, port: number = this._port): string { + return GetMcpEditorSessionUrl(sessionId, port, this._publicHostname); + } + + /** + * Notify connected editors that a session document changed. + * @param sessionId - Session identifier. + */ + public notifySessionUpdate(sessionId: string): void { + void this._sendDocumentUpdateAsync(sessionId); + } + + /** + * Close one session and disconnect its editor clients. + * @param sessionId - Session identifier. + * @param reason - Optional reason sent to clients. + * @returns True if a session was closed. + */ + public closeSession(sessionId: string, reason: string = "Session closed by MCP server"): boolean { + const session = this._sessions.get(sessionId); + if (!session) { + return false; + } + + const clients = this._sseClients.get(sessionId); + if (clients) { + for (const response of clients) { + response.write(`event: session-closed\ndata: ${JSON.stringify({ reason })}\n\n`); + response.end(); + } + clients.clear(); + } + + this._sseClients.delete(sessionId); + this._sessions.delete(sessionId); + this._sessionByName.delete(this._getSessionKey(session.kind, session.name)); + this._recordActivity(); + return true; + } + + /** + * Close a session by document name. + * @param name - MCP-server-local document name. + * @param kind - Optional document kind override. + * @returns True if a session was closed. + */ + public closeSessionForName(name: string, kind: string = this._adapter.documentKind): boolean { + const sessionId = this.getSessionIdForName(name, kind); + return sessionId ? this.closeSession(sessionId) : false; + } + + /** + * Get the current server port. + * @returns Listening port, or 0 when stopped. + */ + public getPort(): number { + return this._port; + } + + /** + * Get current health information without making an HTTP request. + * @returns The server health payload. + */ + public getHealth(): IMcpEditorSessionHealth { + return { + protocol: DefaultProtocolName, + protocolVersion: this._protocolVersion, + serverName: this._adapter.serverName, + port: this._port, + host: this._host, + publicHostname: this._publicHostname, + documentKind: this._adapter.documentKind, + running: this.isRunning(), + activeSessionCount: this._sessions.size, + capabilities: [...this._capabilities], + conflictPolicy: DefaultConflictPolicy, + idleTimeoutMs: this._idleTimeoutMs, + lastActivityAt: this._lastActivityAt, + workspaceId: this._workspaceId, + ownerId: this._ownerId, + }; + } + + /** + * Get current machine-readable diagnostics. + * @returns The server diagnostics payload. + */ + public getDiagnostics(): IMcpEditorSessionDiagnostics { + return { + health: this.getHealth(), + sessions: this._getSessionInfoList(), + sseClientCount: this._getSseClientCount(), + startedAt: this._startedAt, + uptimeMs: this._startedAt ? Date.now() - this._startedAt : 0, + keepAliveIntervalMs: this._keepAliveIntervalMs, + idleTimeoutMs: this._idleTimeoutMs, + lastActivityAt: this._lastActivityAt, + }; + } + + private _recordActivity(): void { + this._lastActivityAt = Date.now(); + this._scheduleIdleTimeout(); + } + + private _scheduleIdleTimeout(): void { + if (this._idleTimeout) { + clearTimeout(this._idleTimeout); + this._idleTimeout = null; + } + + if (this._idleTimeoutMs <= 0 || !this.isRunning()) { + return; + } + + const elapsedMs = Date.now() - this._lastActivityAt; + const delayMs = Math.max(1, this._idleTimeoutMs - elapsedMs); + const timeout = setTimeout(() => { + void this._stopIfIdleAsync(); + }, delayMs); + if (typeof timeout === "object" && "unref" in timeout) { + timeout.unref(); + } + this._idleTimeout = timeout; + } + + private async _stopIfIdleAsync(): Promise { + if (!this.isRunning() || this._idleTimeoutMs <= 0) { + return; + } + + const elapsedMs = Date.now() - this._lastActivityAt; + if (elapsedMs < this._idleTimeoutMs) { + this._scheduleIdleTimeout(); + return; + } + + await this.stopAsync("Session server stopped after idle timeout"); + } + + private _getSessionKey(kind: string, name: string): string { + return `${kind}:${name}`; + } + + private async _tryPortRangeAsync(startPort: number, endPort: number): Promise { + let lastError: unknown; + for (let portCandidate = startPort; portCandidate <= endPort; portCandidate++) { + try { + // eslint-disable-next-line no-await-in-loop + await this._startOnPortAsync(portCandidate); + return portCandidate; + } catch (error) { + lastError = error; + } + } + throw lastError ?? new Error(`Could not find an open port between ${startPort} and ${endPort}`); + } + + private async _startOnPortAsync(port: number): Promise { + await new Promise((resolve, reject) => { + const server = http.createServer((request, response) => { + void this._handleRequestAsync(request, response); + }); + server.once("error", (error: NodeJS.ErrnoException) => { + reject(new Error(error.message)); + }); + server.listen(port, this._host, () => { + this._server = server; + resolve(); + }); + }); + } + + private _setCorsHeaders(request: http.IncomingMessage, response: http.ServerResponse): void { + const allowedOrigin = this._getAllowedCorsOrigin(request.headers.origin); + if (allowedOrigin) { + response.setHeader("Access-Control-Allow-Origin", allowedOrigin); + response.setHeader("Vary", "Origin"); + } + response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "Content-Type"); + response.setHeader("Access-Control-Expose-Headers", "X-Mcp-Editor-Session-Revision"); + } + + private _getAllowedCorsOrigin(origin: string | undefined): string | undefined { + if (this._corsOrigin) { + return this._corsOrigin; + } + + if (!origin) { + return undefined; + } + + if (this._allowedOrigins) { + return this._allowedOrigins.has(origin) ? origin : undefined; + } + + return this._isLocalOrigin(origin) ? origin : undefined; + } + + private _isLocalOrigin(origin: string): boolean { + try { + const url = new URL(origin); + return (url.protocol === "http:" || url.protocol === "https:") && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"); + } catch { + return false; + } + } + + private async _handleRequestAsync(request: http.IncomingMessage, response: http.ServerResponse): Promise { + this._setCorsHeaders(request, response); + this._recordActivity(); + + if (request.method === "OPTIONS") { + response.writeHead(204); + response.end(); + return; + } + + const requestUrl = new URL(request.url ?? "/", `http://${this._publicHostname}:${this._port}`); + const pathname = requestUrl.pathname; + + if (pathname === "/" && request.method === "GET") { + this._handleStatus(response); + return; + } + + if (pathname === "/health" && request.method === "GET") { + this._writeJson(response, 200, this.getHealth()); + return; + } + + if (pathname === "/sessions" && request.method === "GET") { + this._writeJson(response, 200, this._getSessionInfoList()); + return; + } + + if (pathname === "/diagnostics" && request.method === "GET") { + this._writeJson(response, 200, this.getDiagnostics()); + return; + } + + const sessionMatch = pathname.match(/^\/session\/([^/]+)(?:\/([^/]+))?$/); + if (!sessionMatch) { + this._writeText(response, 404, "Not Found"); + return; + } + + const sessionId = sessionMatch[1]; + const route = sessionMatch[2] ?? ""; + const session = this._sessions.get(sessionId); + if (!session) { + this._writeJson(response, 404, { error: `Session "${sessionId}" not found` }); + return; + } + + if (route === "" && request.method === "GET") { + this._writeJson(response, 200, this._createSessionInfo(session)); + return; + } + + if (route === "events" && request.method === "GET") { + await this._handleSseAsync(session, response); + return; + } + + if (this._isDocumentRoute(route)) { + if (request.method === "GET") { + await this._handleGetDocumentAsync(session, response); + return; + } + + if (request.method === "POST") { + await this._handlePostDocumentAsync(session, request, response); + return; + } + } + + this._writeText(response, 404, "Not Found"); + } + + private _handleStatus(response: http.ServerResponse): void { + const lines = [this._statusTitle, "", `Protocol: ${DefaultProtocolName} ${this._protocolVersion}`, `Document kind: ${this._adapter.documentKind}`, ""]; + if (this._sessions.size === 0) { + lines.push("No active sessions."); + } else { + lines.push("Active sessions:"); + for (const session of this._sessions.values()) { + const clientCount = this._sseClients.get(session.id)?.size ?? 0; + lines.push(` ${session.id} -> ${session.name} (${clientCount} subscriber${clientCount === 1 ? "" : "s"}, revision ${session.revision})`); + } + } + this._writeText(response, 200, lines.join("\n")); + } + + private async _handleSseAsync(session: IMcpEditorSession, response: http.ServerResponse): Promise { + response.statusCode = 200; + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Connection", "keep-alive"); + response.flushHeaders(); + + const clients = this._sseClients.get(session.id)!; + clients.add(response); + response.write(": connected\n\n"); + + const document = await this._adapter.getDocument(session); + if (document) { + this._writeDocumentEvent(response, document); + } + + response.on("close", () => { + clients.delete(response); + this._recordActivity(); + }); + } + + private async _handleGetDocumentAsync(session: IMcpEditorSession, response: http.ServerResponse): Promise { + const document = await this._adapter.getDocument(session); + if (!document) { + this._writeJson(response, 404, { error: `Document "${session.name}" not found` }); + return; + } + response.statusCode = 200; + response.setHeader("Content-Type", "application/json; charset=utf-8"); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("X-Mcp-Editor-Session-Revision", session.revision.toString()); + response.end(document); + } + + private async _handlePostDocumentAsync(session: IMcpEditorSession, request: http.IncomingMessage, response: http.ServerResponse): Promise { + const document = await this._readBodyAsync(request); + try { + JSON.parse(document); + } catch { + this._writeJson(response, 400, { error: "Invalid JSON" }); + return; + } + + const result = await this._adapter.setDocument(session, document); + if (typeof result === "string" && result.length > 0) { + this._writeJson(response, 400, { error: result }); + return; + } + + const previousRevision = session.revision; + this._touchSession(session); + response.setHeader("X-Mcp-Editor-Session-Revision", session.revision.toString()); + this._writeJson(response, 200, { ok: true, previousRevision, revision: session.revision, conflictPolicy: DefaultConflictPolicy }); + await this._sendDocumentUpdateAsync(session.id, false); + } + + private async _sendDocumentUpdateAsync(sessionId: string, touchSession: boolean = true): Promise { + const session = this._sessions.get(sessionId); + if (!session) { + return; + } + + this._recordActivity(); + + if (touchSession) { + this._touchSession(session); + } + + const clients = this._sseClients.get(sessionId); + if (!clients || clients.size === 0) { + return; + } + + const document = await this._adapter.getDocument(session); + if (!document) { + return; + } + + for (const response of clients) { + this._writeDocumentEvent(response, document); + } + } + + private _touchSession(session: IMcpEditorSession): void { + session.updatedAt = Date.now(); + session.revision++; + } + + private _writeDocumentEvent(response: http.ServerResponse, document: string): void { + const compactDocument = JSON.stringify(JSON.parse(document)); + response.write(`data: ${compactDocument}\n\n`); + } + + private _isDocumentRoute(route: string): boolean { + return route === "document" || this._legacyDocumentRoutes.has(route); + } + + private _createSessionInfo(session: IMcpEditorSession): Record { + const subscriberCount = this._sseClients.get(session.id)?.size ?? 0; + const info: Record = { + sessionId: session.id, + kind: session.kind, + name: session.name, + revision: session.revision, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + conflictPolicy: DefaultConflictPolicy, + subscriberCount, + eventsUrl: `/session/${session.id}/events`, + documentUrl: `/session/${session.id}/document`, + }; + + for (const route of this._legacyDocumentRoutes) { + info[`${route}Url`] = `/session/${session.id}/${route}`; + } + + return info; + } + + private _getSessionInfoList(): Record[] { + return [...this._sessions.values()].map((session) => this._createSessionInfo(session)); + } + + private _getSseClientCount(): number { + let count = 0; + for (const clients of this._sseClients.values()) { + count += clients.size; + } + return count; + } + + private async _readBodyAsync(request: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + await new Promise((resolve, reject) => { + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", resolve); + request.on("error", reject); + }); + return Buffer.concat(chunks).toString("utf-8"); + } + + private _writeJson(response: http.ServerResponse, statusCode: number, payload: unknown): void { + response.statusCode = statusCode; + response.setHeader("Content-Type", "application/json; charset=utf-8"); + response.end(JSON.stringify(payload)); + } + + private _writeText(response: http.ServerResponse, statusCode: number, text: string): void { + response.statusCode = statusCode; + response.setHeader("Content-Type", "text/plain; charset=utf-8"); + response.end(text); + } +} + +/** + * Small reusable controller that binds a graph/document manager to the shared + * HTTP/SSE editor session server. + */ +export class McpEditorSessionController { + private readonly _adapter: IMcpEditorSessionControllerAdapter; + private readonly _options: IMcpEditorSessionServerOptions; + private _manager: Manager | null = null; + private _sessionServer: McpEditorSessionServer | null = null; + + /** + * Creates a controller for one MCP server's document kind. + * @param adapter - Manager/document adapter callbacks. + * @param options - Shared session server options. + */ + public constructor(adapter: IMcpEditorSessionControllerAdapter, options: IMcpEditorSessionServerOptions = {}) { + this._adapter = adapter; + this._options = options; + } + + /** + * Whether the session server is currently running. + * @returns True if running, false otherwise. + */ + public isRunning(): boolean { + return this._sessionServer?.isRunning() ?? false; + } + + /** + * Start the session server if not already running. + * @param manager - The MCP server graph/document manager. + * @param port - Optional first port to try. + * @returns The port the server is listening on. + */ + public async startAsync(manager: Manager, port?: number): Promise { + this._manager = manager; + return await this._getSessionServer().startAsync(port); + } + + /** + * Stop the session server. + */ + public async stopAsync(): Promise { + if (!this._sessionServer) { + return; + } + await this._sessionServer.stopAsync(); + } + + /** + * Create a new session for a document. + * @param name - MCP-server-local document name. + * @returns The new or existing session ID. + */ + public createSession(name: string): string { + return this._getSessionServer().createSession(name).id; + } + + /** + * Get the session ID for a given document, if one exists. + * @param name - MCP-server-local document name. + * @returns The session ID, or undefined if no session exists for this document. + */ + public getSessionIdForName(name: string): string | undefined { + return this._sessionServer?.getSessionIdForName(name); + } + + /** + * Get the full session URL. + * @param sessionId - The session ID. + * @param port - Optional port override. + * @returns The full URL to access this session. + */ + public getSessionUrl(sessionId: string, port?: number): string { + return this._getSessionServer().getSessionUrl(sessionId, port); + } + + /** + * Push the latest document JSON to all SSE subscribers of a session. + * @param sessionId - The session ID to notify. + */ + public notifySessionUpdate(sessionId: string): void { + this._sessionServer?.notifySessionUpdate(sessionId); + } + + /** + * Close a single session. + * @param sessionId - The session ID to close. + * @returns True if the session existed and was closed, false otherwise. + */ + public closeSession(sessionId: string): boolean { + return this._sessionServer?.closeSession(sessionId) ?? false; + } + + /** + * Close a session by document name. + * @param name - MCP-server-local document name. + * @returns True if a session was closed, false if none existed. + */ + public closeSessionForName(name: string): boolean { + return this._sessionServer?.closeSessionForName(name) ?? false; + } + + /** + * Returns the port the session server is running on. + * @returns The port number, or 0 if the server is not running. + */ + public getPort(): number { + return this._sessionServer?.getPort() ?? 0; + } + + /** + * Get current health information without making an HTTP request. + * @returns The server health payload. + */ + public getHealth(): IMcpEditorSessionHealth { + return this._getSessionServer().getHealth(); + } + + /** + * Get current machine-readable diagnostics. + * @returns The server diagnostics payload. + */ + public getDiagnostics(): IMcpEditorSessionDiagnostics { + return this._getSessionServer().getDiagnostics(); + } + + private _getSessionServer(): McpEditorSessionServer { + if (!this._sessionServer) { + this._sessionServer = new McpEditorSessionServer( + { + serverName: this._adapter.serverName, + documentKind: this._adapter.documentKind, + getDocument: (session): string | undefined | Promise => (this._manager ? this._adapter.getDocument(this._manager, session) : undefined), + setDocument: (session, document): string | void | Promise => { + if (!this._manager) { + return this._adapter.managerUnavailableMessage ?? "Document manager is not available"; + } + return this._adapter.setDocument(this._manager, session, document); + }, + }, + this._options + ); + } + + return this._sessionServer; + } +} diff --git a/packages/tools/mcp-server-core/src/index.ts b/packages/tools/mcp-server-core/src/index.ts new file mode 100644 index 00000000000..6d1d1cc6998 --- /dev/null +++ b/packages/tools/mcp-server-core/src/index.ts @@ -0,0 +1,9 @@ +export * from "./textHandoff.js"; +export * from "./jsonValidation.js"; +export * from "./inputValidation.js"; +export * from "./response.js"; +export * from "./jsonToolResponses.js"; +export * from "./toolSchemas.js"; +export * from "./sceneToolSchemas.js"; +export * from "./sceneAttachmentValidation.js"; +export * from "./editorSessionServer.js"; diff --git a/packages/tools/mcp-server-core/src/inputValidation.ts b/packages/tools/mcp-server-core/src/inputValidation.ts new file mode 100644 index 00000000000..0dbac7aae68 --- /dev/null +++ b/packages/tools/mcp-server-core/src/inputValidation.ts @@ -0,0 +1,81 @@ +/** + * A named input candidate used by the shared validation helpers. + */ +export interface IInputCandidate { + /** Friendly label shown in validation error messages. */ + label: string; + /** The raw value supplied by the caller. */ + value: T | null | undefined; +} + +/** + * Shared options for presence and alias validation. + */ +export interface IInputValidationOptions { + /** Inputs to validate in the order they should be considered. */ + candidates: IInputCandidate[]; + /** Optional custom error message to use instead of the generated one. */ + missingMessage?: string; + /** Optional custom predicate that determines whether a candidate is present. */ + isPresent?: (value: T | null | undefined) => boolean; +} + +/** + * Determine whether an input should be considered present. + * @param value - The candidate value to inspect. + * @returns True when the input is defined and, for strings, non-empty. + */ +export function IsInputProvided(value: unknown): boolean { + if (value === undefined || value === null) { + return false; + } + + if (typeof value === "string") { + return value.length > 0; + } + + return true; +} + +/** + * Require that at least one input candidate is present. + * @param options - Candidates and optional custom validation settings. + */ +export function RequireAtLeastOneInput(options: IInputValidationOptions): void { + const isPresent = options.isPresent ?? IsInputProvided; + + if (!options.candidates.some((candidate) => isPresent(candidate.value))) { + throw new Error(options.missingMessage ?? `Error: ${FormatEitherOrMessage(options.candidates.map((candidate) => candidate.label))} must be provided.`); + } +} + +/** + * Resolve the first present input candidate and throw when none are provided. + * @param options - Candidates and optional custom validation settings. + * @returns The first present candidate value. + */ +export function ResolveDefinedInput(options: IInputValidationOptions): T { + const isPresent = options.isPresent ?? IsInputProvided; + + RequireAtLeastOneInput(options); + + for (const candidate of options.candidates) { + if (isPresent(candidate.value)) { + return candidate.value as T; + } + } + + throw new Error(options.missingMessage ?? `Error: ${FormatEitherOrMessage(options.candidates.map((candidate) => candidate.label))} must be provided.`); +} + +function FormatEitherOrMessage(labels: string[]): string { + if (labels.length === 1) { + return labels[0]; + } + + if (labels.length === 2) { + return `Either ${labels[0]} or ${labels[1]}`; + } + + return `Either ${labels.slice(0, -1).join(", ")}, or ${labels[labels.length - 1]}`; +} diff --git a/packages/tools/mcp-server-core/src/jsonToolResponses.ts b/packages/tools/mcp-server-core/src/jsonToolResponses.ts new file mode 100644 index 00000000000..1e15ad7fcd9 --- /dev/null +++ b/packages/tools/mcp-server-core/src/jsonToolResponses.ts @@ -0,0 +1,239 @@ +import { ResolveInlineOrFileText, WriteTextFileEnsuringDirectory } from "./textHandoff.js"; +import { CreateErrorResponse, CreateTextResponse, type IMcpTextResponse } from "./response.js"; + +/** + * Options for exporting JSON either inline or to a file. + */ +export interface ICreateJsonExportResponseOptions { + /** JSON text to return or persist. */ + jsonText?: string | null; + /** Optional absolute path to write the JSON to. */ + outputFile?: string; + /** Error message returned when the JSON source does not exist. */ + missingMessage: string; + /** Human-readable label used in the file-written success message. */ + fileLabel: string; +} + +/** + * Options for importing JSON into an in-memory manager and describing the result. + */ +export interface ICreateJsonImportResponseOptions { + /** Inline JSON text provided directly by the caller. */ + json?: string; + /** Optional absolute path to a JSON file. */ + jsonFile?: string; + /** Human-readable description used in file-read errors. */ + fileDescription: string; + /** Import callback that returns "OK" on success or an error string on failure. */ + importJson: (jsonText: string) => string; + /** Description callback for the imported object after a successful import. */ + describeImported: () => string; + /** Success message prefix returned before the description. */ + successMessage?: string; +} + +/** + * Options for importing JSON into an in-memory manager and building a custom success summary. + */ +export interface ICreateJsonImportSummaryResponseOptions { + /** Inline JSON text provided directly by the caller. */ + json?: string; + /** Optional absolute path to a JSON file. */ + jsonFile?: string; + /** Human-readable description used in file-read errors. */ + fileDescription: string; + /** Import callback invoked with the resolved JSON text. */ + importJson: (jsonText: string) => ImportResult; + /** Builds the success text returned to the caller. */ + createSuccessText: (result: ImportResult) => string; +} + +/** + * Minimal snippet payload shape used by the MCP servers in this workspace. + */ +export interface IJsonDataSnippetResult { + /** Snippet kind identifier returned by the snippet loader. */ + type: string; + /** JSON-compatible payload for supported snippet types. */ + data?: unknown; +} + +/** + * Options for importing a typed snippet into an in-memory manager and describing the result. + */ +export interface ICreateTypedSnippetImportResponseOptions { + /** Snippet identifier requested by the caller. */ + snippetId: string; + /** Snippet payload returned by the caller's snippet loader. */ + snippetResult: IJsonDataSnippetResult; + /** Expected snippet type for the current manager. */ + expectedType: string; + /** Import callback that returns "OK" on success or an error string on failure. */ + importJson: (jsonText: string) => string; + /** Description callback for the imported object after a successful import. */ + describeImported: () => string; + /** Success message prefix returned before the description. */ + successMessage: string; +} + +/** + * Options for importing a typed snippet and building a custom success summary. + */ +export interface ICreateTypedSnippetImportSummaryResponseOptions { + /** Snippet identifier requested by the caller. */ + snippetId: string; + /** Snippet payload returned by the caller's snippet loader. */ + snippetResult: IJsonDataSnippetResult; + /** Expected snippet type for the current manager. */ + expectedType: string; + /** Import callback invoked with the snippet JSON text. */ + importJson: (jsonText: string) => ImportResult; + /** Builds the success text returned to the caller. */ + createSuccessText: (result: ImportResult) => string; +} + +/** + * Options for wrapping snippet-fetch boilerplate around a response factory. + */ +export interface IRunSnippetResponseOptions { + /** Snippet identifier requested by the caller. */ + snippetId: string; + /** Async loader used to fetch the snippet payload. */ + loadSnippet: (snippetId: string) => Promise; + /** Builds the final response from the fetched snippet payload. */ + createResponse: (snippetResult: SnippetResult) => IMcpTextResponse; +} + +/** + * Build a standard MCP response for JSON export tools. + * @param options - Export result data and output-file options. + * @returns A text response for the caller. + */ +export function CreateJsonExportResponse(options: ICreateJsonExportResponseOptions): IMcpTextResponse { + if (!options.jsonText) { + return CreateErrorResponse(options.missingMessage); + } + + if (!options.outputFile) { + return CreateTextResponse(options.jsonText); + } + + try { + WriteTextFileEnsuringDirectory(options.outputFile, options.jsonText); + return CreateTextResponse(`${options.fileLabel} written to: ${options.outputFile}`); + } catch (error) { + return CreateErrorResponse(`Error writing file: ${(error as Error).message}`); + } +} + +/** + * Build a standard MCP response for JSON import tools that load an object and then describe it. + * @param options - Import callbacks and source-text options. + * @returns A text response for the caller. + */ +export function CreateJsonImportResponse(options: ICreateJsonImportResponseOptions): IMcpTextResponse { + let jsonText: string; + + try { + jsonText = ResolveInlineOrFileText({ + inlineText: options.json, + filePath: options.jsonFile, + inlineLabel: "json", + fileLabel: "jsonFile", + fileDescription: options.fileDescription, + }).text; + } catch (error) { + return CreateErrorResponse((error as Error).message); + } + + const result = options.importJson(jsonText); + if (result !== "OK") { + return CreateErrorResponse(`Error: ${result}`); + } + + return CreateTextResponse(`${options.successMessage ?? "Imported successfully."}\n\n${options.describeImported()}`); +} + +/** + * Build a standard MCP response for JSON import tools that need a custom success summary. + * @param options - Import callbacks and source-text options. + * @returns A text response for the caller. + */ +export function CreateJsonImportSummaryResponse(options: ICreateJsonImportSummaryResponseOptions): IMcpTextResponse { + let jsonText: string; + + try { + jsonText = ResolveInlineOrFileText({ + inlineText: options.json, + filePath: options.jsonFile, + inlineLabel: "json", + fileLabel: "jsonFile", + fileDescription: options.fileDescription, + }).text; + } catch (error) { + return CreateErrorResponse((error as Error).message); + } + + try { + return CreateTextResponse(options.createSuccessText(options.importJson(jsonText))); + } catch (error) { + return CreateErrorResponse(`Error: ${(error as Error).message}`); + } +} + +/** + * Build a standard MCP response for snippet-import tools that load a typed JSON payload. + * @param options - Snippet payload and import callbacks. + * @returns A text response for the caller. + */ +export function CreateTypedSnippetImportResponse(options: ICreateTypedSnippetImportResponseOptions): IMcpTextResponse { + if (options.snippetResult.type === "unknown") { + return CreateErrorResponse(`Error: Snippet "${options.snippetId}" has an unrecognized format.`); + } + + if (options.snippetResult.type !== options.expectedType) { + return CreateErrorResponse(`Error: Snippet "${options.snippetId}" is of type "${options.snippetResult.type}", not "${options.expectedType}".`); + } + + const result = options.importJson(JSON.stringify(options.snippetResult.data)); + if (result !== "OK") { + return CreateErrorResponse(`Error importing snippet data: ${result}`); + } + + return CreateTextResponse(`${options.successMessage}\n\n${options.describeImported()}`); +} + +/** + * Build a standard MCP response for typed snippet imports that need a custom success summary. + * @param options - Snippet payload and import callbacks. + * @returns A text response for the caller. + */ +export function CreateTypedSnippetImportSummaryResponse(options: ICreateTypedSnippetImportSummaryResponseOptions): IMcpTextResponse { + if (options.snippetResult.type === "unknown") { + return CreateErrorResponse(`Error: Snippet "${options.snippetId}" has an unrecognized format.`); + } + + if (options.snippetResult.type !== options.expectedType) { + return CreateErrorResponse(`Error: Snippet "${options.snippetId}" is of type "${options.snippetResult.type}", not "${options.expectedType}".`); + } + + try { + return CreateTextResponse(options.createSuccessText(options.importJson(JSON.stringify(options.snippetResult.data)))); + } catch (error) { + return CreateErrorResponse(`Error: ${(error as Error).message}`); + } +} + +/** + * Load a snippet and map it to a response with consistent fetch-error handling. + * @param options - Snippet identifier, loader, and response factory. + * @returns The final MCP response. + */ +export async function RunSnippetResponse(options: IRunSnippetResponseOptions): Promise { + try { + return options.createResponse(await options.loadSnippet(options.snippetId)); + } catch (error) { + return CreateErrorResponse(`Error fetching snippet "${options.snippetId}": ${(error as Error).message}`); + } +} diff --git a/packages/tools/mcp-server-core/src/jsonValidation.ts b/packages/tools/mcp-server-core/src/jsonValidation.ts new file mode 100644 index 00000000000..4701a264b67 --- /dev/null +++ b/packages/tools/mcp-server-core/src/jsonValidation.ts @@ -0,0 +1,35 @@ +/** + * Options used to parse JSON text with consistent error messages. + */ +export interface IParseJsonTextOptions { + /** Raw JSON text to parse. */ + jsonText: string; + /** Friendly label used in parse error messages. */ + jsonLabel?: string; + /** Include a preview of the invalid JSON text in parse errors. */ + includePreviewInError?: boolean; + /** Maximum number of characters to include in the error preview. */ + previewLength?: number; +} + +/** + * Parse JSON text and throw a consistently formatted error when parsing fails. + * @param options - JSON parse options and error formatting controls. + * @returns Parsed JSON value. + */ +export function ParseJsonText(options: IParseJsonTextOptions): T { + const jsonLabel = options.jsonLabel ?? "JSON"; + + try { + return JSON.parse(options.jsonText) as T; + } catch (error) { + if (options.includePreviewInError) { + const previewLength = options.previewLength ?? 100; + const preview = options.jsonText.slice(0, previewLength); + const suffix = options.jsonText.length > preview.length ? "..." : ""; + throw new Error(`Invalid ${jsonLabel}: ${preview}${suffix}`, { cause: error }); + } + + throw new Error(`Invalid ${jsonLabel}: parse error.`, { cause: error }); + } +} diff --git a/packages/tools/mcp-server-core/src/response.ts b/packages/tools/mcp-server-core/src/response.ts new file mode 100644 index 00000000000..6eb1993f0c7 --- /dev/null +++ b/packages/tools/mcp-server-core/src/response.ts @@ -0,0 +1,64 @@ +/** + * Text content block used by MCP tool responses. + */ +export interface IMcpTextContent { + [key: string]: unknown; + /** MCP content type. */ + type: "text"; + /** Response text shown to the caller. */ + text: string; +} + +/** + * Minimal text response shape used by the MCP servers in this workspace. + */ +export interface IMcpTextResponse { + [key: string]: unknown; + /** Ordered response content blocks. */ + content: IMcpTextContent[]; + /** Whether the response represents an error. */ + isError?: boolean; +} + +/** + * Create a single text content block. + * @param text - Text to include in the content block. + * @returns A text content object. + */ +export function CreateTextContent(text: string): IMcpTextContent { + return { type: "text", text }; +} + +/** + * Create a standard successful MCP text response. + * @param text - Text to return to the caller. + * @returns A response with a single text block. + */ +export function CreateTextResponse(text: string): IMcpTextResponse { + return { + content: [CreateTextContent(text)], + }; +} + +/** + * Create a standard MCP error response. + * @param text - Error text to return to the caller. + * @returns An error response with a single text block. + */ +export function CreateErrorResponse(text: string): IMcpTextResponse { + return { + content: [CreateTextContent(text)], + isError: true, + }; +} + +/** + * Create a successful MCP response with multiple text blocks. + * @param texts - Ordered text blocks to include. + * @returns A response containing one text content object per entry. + */ +export function CreateTextResponses(texts: string[]): IMcpTextResponse { + return { + content: texts.map((text) => CreateTextContent(text)), + }; +} diff --git a/packages/tools/mcp-server-core/src/sceneAttachmentValidation.ts b/packages/tools/mcp-server-core/src/sceneAttachmentValidation.ts new file mode 100644 index 00000000000..8e32302fb18 --- /dev/null +++ b/packages/tools/mcp-server-core/src/sceneAttachmentValidation.ts @@ -0,0 +1,148 @@ +import { ParseJsonText } from "./jsonValidation.js"; + +type JsonObject = Record; + +/** + * Validated Flow Graph attachment payload. + */ +export interface IValidatedFlowGraphAttachmentPayload { + /** Parsed coordinator-level or graph-level JSON object. */ + parsed: JsonObject; + /** Normalized list of graph objects to inspect. */ + graphs: JsonObject[]; +} + +function ParseJsonObject(value: unknown, label: string): JsonObject { + const parsed = typeof value === "string" ? ParseJsonText({ jsonText: value, jsonLabel: label }) : value; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Invalid ${label}: expected a JSON object.`); + } + return parsed as JsonObject; +} + +/** + * Validate a GUI descriptor payload before attaching it to a scene. + * @param value - GUI JSON as text or parsed object. + * @returns The parsed GUI descriptor object. + */ +export function ValidateGuiAttachmentPayload(value: unknown): JsonObject { + const parsed = ParseJsonObject(value, "GUI JSON"); + const root = parsed.root; + if (!root || typeof root !== "object" || Array.isArray(root)) { + throw new Error("Invalid GUI JSON: must contain a 'root' object."); + } + return parsed; +} + +/** + * Validate a Node Render Graph payload before attaching it to a scene. + * @param value - NRG JSON as text or parsed object. + * @returns The parsed render graph object. + */ +export function ValidateNodeRenderGraphAttachmentPayload(value: unknown): JsonObject { + const parsed = ParseJsonObject(value, "NRG JSON"); + if (parsed.customType !== "BABYLON.NodeRenderGraph") { + throw new Error(`Invalid NRG JSON: customType must be "BABYLON.NodeRenderGraph" but got "${String(parsed.customType)}".`); + } + if (!Array.isArray(parsed.blocks)) { + throw new Error("Invalid NRG JSON: must contain a 'blocks' array."); + } + return parsed; +} + +/** + * Validate a Node Geometry payload before attaching it to a scene. + * @param value - NGE JSON as text or parsed object. + * @returns The parsed node geometry object. + */ +export function ValidateNodeGeometryAttachmentPayload(value: unknown): JsonObject { + const parsed = ParseJsonObject(value, "NGE JSON"); + if (parsed.customType !== "BABYLON.NodeGeometry") { + throw new Error(`Invalid NGE JSON: customType must be "BABYLON.NodeGeometry" but got "${String(parsed.customType)}".`); + } + if (!Array.isArray(parsed.blocks)) { + throw new Error("Invalid NGE JSON: must contain a 'blocks' array."); + } + return parsed; +} + +/** + * Validate a Flow Graph payload before attaching it to a scene. + * Accepts either coordinator-level JSON with `_flowGraphs` or graph-level JSON with `allBlocks`. + * @param value - Flow Graph JSON as text or parsed object. + * @returns The parsed payload and normalized graphs list. + */ +export function ValidateFlowGraphAttachmentPayload(value: unknown): IValidatedFlowGraphAttachmentPayload { + const parsed = ParseJsonObject(value, "Flow Graph JSON"); + + if (Array.isArray(parsed._flowGraphs)) { + if (parsed._flowGraphs.length === 0) { + throw new Error("Invalid Flow Graph JSON: '_flowGraphs' must contain at least one graph."); + } + for (const graph of parsed._flowGraphs) { + if (!graph || typeof graph !== "object" || Array.isArray(graph) || !Array.isArray((graph as JsonObject).allBlocks)) { + throw new Error("Invalid Flow Graph JSON: each coordinator entry must contain an 'allBlocks' array."); + } + } + return { parsed, graphs: parsed._flowGraphs as JsonObject[] }; + } + + if (Array.isArray(parsed.allBlocks)) { + return { parsed, graphs: [parsed] }; + } + + throw new Error("Invalid Flow Graph JSON: expected coordinator JSON with '_flowGraphs' or graph JSON with 'allBlocks'."); +} + +/** + * Validate a Node Material payload before attaching it to a scene material. + * @param value - NME JSON as text or parsed object. + * @returns The parsed node material object. + */ +export function ValidateNodeMaterialAttachmentPayload(value: unknown): JsonObject { + const parsed = ParseJsonObject(value, "NME JSON"); + if (!Array.isArray(parsed.blocks)) { + throw new Error("Invalid NME JSON: must contain a 'blocks' array."); + } + if (!Array.isArray(parsed.outputNodes)) { + throw new Error("Invalid NME JSON: must contain an 'outputNodes' array."); + } + + const mode = typeof parsed.mode === "number" ? parsed.mode : undefined; + if (mode === undefined || mode === 0) { + const blocks = parsed.blocks as Array<{ customType?: unknown }>; + const hasVertexOutput = blocks.some((block) => block.customType === "BABYLON.VertexOutputBlock"); + const hasFragmentOutput = blocks.some((block) => block.customType === "BABYLON.FragmentOutputBlock"); + if (!hasVertexOutput) { + throw new Error("Invalid NME JSON: missing VertexOutputBlock."); + } + if (!hasFragmentOutput) { + throw new Error("Invalid NME JSON: missing FragmentOutputBlock."); + } + } + + return parsed; +} + +/** + * Validate a Smart Filter payload before attaching it to a scene. + * Expected format: `{ format: "smartFilter", formatVersion: 1, blocks: [...], connections: [...] }`. + * @param value - Smart Filter JSON as text or parsed object. + * @returns The parsed smart filter object. + */ +export function ValidateSmartFilterAttachmentPayload(value: unknown): JsonObject { + const parsed = ParseJsonObject(value, "Smart Filter JSON"); + if (parsed.format !== "smartFilter") { + throw new Error(`Invalid Smart Filter JSON: format must be "smartFilter" but got "${String(parsed.format)}".`); + } + if (parsed.formatVersion !== 1) { + throw new Error(`Invalid Smart Filter JSON: formatVersion must be 1 but got ${String(parsed.formatVersion)}.`); + } + if (!Array.isArray(parsed.blocks)) { + throw new Error("Invalid Smart Filter JSON: must contain a 'blocks' array."); + } + if (!Array.isArray(parsed.connections)) { + throw new Error("Invalid Smart Filter JSON: must contain a 'connections' array."); + } + return parsed; +} diff --git a/packages/tools/mcp-server-core/src/sceneToolSchemas.ts b/packages/tools/mcp-server-core/src/sceneToolSchemas.ts new file mode 100644 index 00000000000..6dcc8032179 --- /dev/null +++ b/packages/tools/mcp-server-core/src/sceneToolSchemas.ts @@ -0,0 +1,91 @@ +import { CreateInlineJsonSchema, CreateJsonFileSchema } from "./toolSchemas.js"; + +/** + * Create the Node Material input fields used by the Scene MCP server. + * @param zodFactory - The caller's local Zod factory. + * @returns Scene Node Material JSON/snippet field schemas. + */ +export function CreateSceneNodeMaterialInputFields(zodFactory: any) { + return { + nmeJson: CreateInlineJsonSchema(zodFactory, "For NodeMaterial: the full NME JSON string exported from the Node Material MCP server") as any, + nmeJsonFile: CreateJsonFileSchema( + zodFactory, + "For NodeMaterial: absolute path to a file containing the NME JSON (alternative to inline nmeJson — avoids large payloads in context)" + ) as any, + snippetId: zodFactory.string().optional().describe("For NodeMaterial: a Babylon.js Snippet Server ID to load from"), + }; +} + +/** + * Create the Flow Graph attachment input fields used by the Scene MCP server. + * @param zodFactory - The caller's local Zod factory. + * @returns Scene Flow Graph attachment field schemas. + */ +export function CreateSceneFlowGraphAttachmentFields(zodFactory: any) { + return { + coordinatorJson: CreateInlineJsonSchema(zodFactory, "The complete Flow Graph coordinator JSON string") as any, + coordinatorJsonFile: CreateJsonFileSchema(zodFactory, "Absolute path to a file containing the Flow Graph coordinator JSON (alternative to inline coordinatorJson)") as any, + flowGraphJsonFile: CreateJsonFileSchema(zodFactory, "Alias for coordinatorJsonFile — path to the Flow Graph JSON file") as any, + flowGraphJson: CreateInlineJsonSchema(zodFactory, "Alias for coordinatorJson — the Flow Graph JSON string") as any, + }; +} + +/** + * Create the GUI attachment input fields used by the Scene MCP server. + * @param zodFactory - The caller's local Zod factory. + * @returns Scene GUI attachment field schemas. + */ +export function CreateSceneGuiAttachmentFields(zodFactory: any) { + return { + guiJson: CreateInlineJsonSchema(zodFactory, "The GUI descriptor JSON string (from the GUI MCP server's export_gui_json)") as any, + guiJsonFile: CreateJsonFileSchema(zodFactory, "Absolute path to a file containing the GUI JSON (alternative to inline guiJson)") as any, + }; +} + +/** + * Create the render graph attachment input fields used by the Scene MCP server. + * @param zodFactory - The caller's local Zod factory. + * @returns Scene render graph attachment field schemas. + */ +export function CreateSceneRenderGraphAttachmentFields(zodFactory: any) { + return { + nrgJson: CreateInlineJsonSchema(zodFactory, "The NRG JSON string (from the Node Render Graph MCP server's export_graph_json tool)") as any, + nrgJsonFile: CreateJsonFileSchema(zodFactory, "Absolute path to a file containing the NRG JSON (alternative to inline nrgJson)") as any, + }; +} + +/** + * Create the node geometry mesh input fields used by the Scene MCP server. + * @param zodFactory - The caller's local Zod factory. + * @returns Scene node geometry field schemas. + */ +export function CreateSceneNodeGeometryFields(zodFactory: any) { + return { + ngeJson: CreateInlineJsonSchema(zodFactory, "The NGE JSON string (from the Node Geometry MCP server's export_geometry_json tool)") as any, + ngeJsonFile: CreateJsonFileSchema(zodFactory, "Absolute path to a file containing the NGE JSON (alternative to inline ngeJson)") as any, + }; +} + +/** + * Create the Smart Filter attachment input fields used by the Scene MCP server. + * @param zodFactory - The caller's local Zod factory. + * @returns Scene Smart Filter attachment field schemas. + */ +export function CreateSceneSmartFilterAttachmentFields(zodFactory: any) { + return { + smartFilterJson: CreateInlineJsonSchema(zodFactory, "The Smart Filter JSON string (from the Smart Filters MCP server's export_smart_filter_json)") as any, + smartFilterJsonFile: CreateJsonFileSchema(zodFactory, "Absolute path to a file containing the Smart Filter JSON (alternative to inline smartFilterJson)") as any, + }; +} + +/** + * Create the raw scene import input fields used by the Scene MCP server. + * @param zodFactory - The caller's local Zod factory. + * @returns Scene raw import field schemas. + */ +export function CreateSceneImportFields(zodFactory: any) { + return { + json: CreateInlineJsonSchema(zodFactory, "The scene descriptor JSON string") as any, + jsonFile: CreateJsonFileSchema(zodFactory, "Absolute path to a file containing the scene descriptor JSON (alternative to inline json)") as any, + }; +} diff --git a/packages/tools/mcp-server-core/src/textHandoff.ts b/packages/tools/mcp-server-core/src/textHandoff.ts new file mode 100644 index 00000000000..41414510eb4 --- /dev/null +++ b/packages/tools/mcp-server-core/src/textHandoff.ts @@ -0,0 +1,79 @@ +import { dirname } from "node:path"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { IsInputProvided, RequireAtLeastOneInput } from "./inputValidation.js"; + +/** + * Options used to resolve text from either inline content or a file path. + */ +export interface IResolveInlineOrFileTextOptions { + /** Inline text content provided directly by the caller. */ + inlineText?: string; + /** Absolute path to a UTF-8 text file. */ + filePath?: string; + /** Friendly label for the inline parameter used in error messages. */ + inlineLabel?: string; + /** Friendly label for the file parameter used in error messages. */ + fileLabel?: string; + /** Human-readable description of the file content used in read errors. */ + fileDescription?: string; +} + +/** + * Resolved text input along with the source it came from. + */ +export interface IResolvedTextInput { + /** The resolved UTF-8 text content. */ + text: string; + /** Whether the text came from inline input or a file. */ + source: "inline" | "file"; +} + +/** + * Resolve text from either inline input or a file path. + * Exactly one source must be provided. + * @param options - Resolution options and friendly error labels. + * @returns The resolved text plus its source. + */ +export function ResolveInlineOrFileText(options: IResolveInlineOrFileTextOptions): IResolvedTextInput { + const inlineLabel = options.inlineLabel ?? "json"; + const fileLabel = options.fileLabel ?? "jsonFile"; + const fileDescription = options.fileDescription ?? "file"; + + if (IsInputProvided(options.inlineText) && IsInputProvided(options.filePath)) { + throw new Error(`Provide either ${inlineLabel} or ${fileLabel}, not both.`); + } + + if (IsInputProvided(options.inlineText)) { + return { + text: options.inlineText as string, + source: "inline", + }; + } + + RequireAtLeastOneInput({ + candidates: [ + { label: inlineLabel, value: options.inlineText }, + { label: fileLabel, value: options.filePath }, + ], + missingMessage: `Either ${inlineLabel} or ${fileLabel} must be provided.`, + }); + + try { + return { + text: readFileSync(options.filePath as string, "utf-8"), + source: "file", + }; + } catch (error) { + throw new Error(`Error reading ${fileDescription}: ${(error as Error).message}`, { cause: error }); + } +} + +/** + * Write UTF-8 text to disk after creating any missing parent directories. + * @param filePath - Absolute path to write. + * @param text - Text content to persist. + */ +export function WriteTextFileEnsuringDirectory(filePath: string, text: string): void { + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, text, "utf-8"); +} diff --git a/packages/tools/mcp-server-core/src/toolSchemas.ts b/packages/tools/mcp-server-core/src/toolSchemas.ts new file mode 100644 index 00000000000..51a7bf1ec6a --- /dev/null +++ b/packages/tools/mcp-server-core/src/toolSchemas.ts @@ -0,0 +1,49 @@ +/** + * Create the standard optional output file schema used by JSON export tools. + * @param zodFactory - The caller's local Zod factory. + * @returns An optional string schema with the shared output-file description. + */ +export function CreateOutputFileSchema(zodFactory: { string(): { optional(): { describe(description: string): Schema } } }): Schema { + return zodFactory + .string() + .optional() + .describe("Optional absolute file path. When provided, the JSON is written to this file and the path is returned instead of the full JSON."); +} + +/** + * Create an optional inline JSON string schema. + * @param zodFactory - The caller's local Zod factory. + * @param description - Field description to apply. + * @returns An optional string schema for inline JSON text. + */ +export function CreateInlineJsonSchema(zodFactory: { string(): { optional(): { describe(description: string): Schema } } }, description: string): Schema { + return zodFactory.string().optional().describe(description); +} + +/** + * Create an optional JSON file path schema. + * @param zodFactory - The caller's local Zod factory. + * @param description - Field description to apply. + * @returns An optional string schema for JSON file paths. + */ +export function CreateJsonFileSchema(zodFactory: { string(): { optional(): { describe(description: string): Schema } } }, description: string): Schema { + return zodFactory.string().optional().describe(description); +} + +/** + * Create the shared snippet ID schema used by Babylon.js snippet tools. + * @param zodFactory - The caller's local Zod factory. + * @returns A required string schema with the shared snippet-ID description. + */ +export function CreateSnippetIdSchema(zodFactory: { string(): { describe(description: string): Schema } }): Schema { + return zodFactory.string().describe('Snippet ID from the Babylon.js Snippet Server (e.g. "ABC123" or "ABC123#2")'); +} + +/** + * Create the shared optional overwrite flag used by import tools that can replace an existing graph. + * @param zodFactory - The caller's local Zod factory. + * @returns An optional boolean schema with the shared overwrite description. + */ +export function CreateOverwriteSchema(zodFactory: { boolean(): { optional(): { describe(description: string): Schema } } }): Schema { + return zodFactory.boolean().optional().describe("If true, replace any existing graph with the same name. Default: false."); +} diff --git a/packages/tools/mcp-server-core/test/unit/editorSessionServer.test.ts b/packages/tools/mcp-server-core/test/unit/editorSessionServer.test.ts new file mode 100644 index 00000000000..f442524afde --- /dev/null +++ b/packages/tools/mcp-server-core/test/unit/editorSessionServer.test.ts @@ -0,0 +1,548 @@ +import * as http from "node:http"; +import * as net from "node:net"; + +import { FindCompatibleMcpEditorSessionServerAsync, IsCompatibleMcpEditorSessionHealth, McpEditorSessionController, McpEditorSessionServer } from "../../src/index"; + +async function GetFreePortAsync(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Could not allocate a test port."))); + return; + } + const port = address.port; + server.close(() => resolve(port)); + }); + }); +} + +async function StartBlockingServerAsync(port: number): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }); +} + +async function StartIncompatibleHttpServerAsync(): Promise<{ server: http.Server; port: number }> { + return await new Promise((resolve, reject) => { + const server = http.createServer((_request, response) => { + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ protocol: "not-a-babylon-session-server" })); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Could not allocate an incompatible test server port."))); + return; + } + resolve({ server, port: address.port }); + }); + }); +} + +async function WaitForConditionAsync(predicate: () => boolean, timeoutMs: number = 1_000): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + if (predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + throw new Error("Timed out waiting for condition."); +} + +describe("editor session server", () => { + it("creates reusable sessions and reports health", async () => { + const port = await GetFreePortAsync(); + let document = JSON.stringify({ value: 1 }); + const server = new McpEditorSessionServer( + { + serverName: "Test Session Server", + documentKind: "test-document", + getDocument: () => document, + setDocument: (_session, nextDocument) => { + document = nextDocument; + }, + }, + { defaultPort: port, legacyDocumentRoutes: ["legacy"] } + ); + + try { + const firstSession = server.createSession("main"); + const secondSession = server.createSession("main"); + const listeningPort = await server.startAsync(); + const health = await (await fetch(`http://localhost:${listeningPort}/health`)).json(); + const sessionInfo = await (await fetch(`http://localhost:${listeningPort}/session/${firstSession.id}`)).json(); + const sessions = await (await fetch(`http://localhost:${listeningPort}/sessions`)).json(); + const diagnostics = await (await fetch(`http://localhost:${listeningPort}/diagnostics`)).json(); + + expect(secondSession.id).toBe(firstSession.id); + expect(health).toMatchObject({ + protocol: "babylon-mcp-editor-session", + serverName: "Test Session Server", + documentKind: "test-document", + running: true, + activeSessionCount: 1, + conflictPolicy: "last-writer-wins", + }); + expect(health.capabilities).toEqual(expect.arrayContaining(["session-list", "diagnostics"])); + expect(health.idleTimeoutMs).toBe(900000); + expect(health.lastActivityAt).toBeGreaterThanOrEqual(health.activeSessionCount > 0 ? firstSession.createdAt : 0); + expect(typeof health.workspaceId).toBe("string"); + expect(typeof health.ownerId).toBe("string"); + expect(sessionInfo).toMatchObject({ + sessionId: firstSession.id, + conflictPolicy: "last-writer-wins", + subscriberCount: 0, + documentUrl: `/session/${firstSession.id}/document`, + legacyUrl: `/session/${firstSession.id}/legacy`, + }); + expect(sessions).toEqual([expect.objectContaining({ sessionId: firstSession.id, subscriberCount: 0 })]); + expect(diagnostics).toMatchObject({ + health: { activeSessionCount: 1 }, + sessions: [expect.objectContaining({ sessionId: firstSession.id, subscriberCount: 0 })], + sseClientCount: 0, + keepAliveIntervalMs: 15000, + idleTimeoutMs: 900000, + }); + expect(diagnostics.lastActivityAt).toBeGreaterThanOrEqual(health.lastActivityAt); + expect(diagnostics.startedAt).toBeGreaterThan(0); + expect(diagnostics.uptimeMs).toBeGreaterThanOrEqual(0); + } finally { + await server.stopAsync(); + } + }); + + it("serves document routes and accepts editor pushes", async () => { + const port = await GetFreePortAsync(); + let document = JSON.stringify({ value: 1 }); + const server = new McpEditorSessionServer( + { + serverName: "Test Session Server", + documentKind: "test-document", + getDocument: () => document, + setDocument: (_session, nextDocument) => { + document = nextDocument; + }, + }, + { defaultPort: port, legacyDocumentRoutes: ["material"] } + ); + + try { + const session = server.createSession("main"); + const listeningPort = await server.startAsync(); + const documentUrl = `http://localhost:${listeningPort}/session/${session.id}/document`; + const legacyUrl = `http://localhost:${listeningPort}/session/${session.id}/material`; + + const initialDocumentResponse = await fetch(documentUrl); + await expect(initialDocumentResponse.json()).resolves.toEqual({ value: 1 }); + expect(initialDocumentResponse.headers.get("x-mcp-editor-session-revision")).toBe("0"); + await expect((await fetch(legacyUrl)).json()).resolves.toEqual({ value: 1 }); + + const postResponse = await fetch(documentUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: 2 }), + }); + const postResult = await postResponse.json(); + + expect(postResponse.ok).toBe(true); + expect(postResponse.headers.get("x-mcp-editor-session-revision")).toBe("1"); + expect(postResult).toMatchObject({ ok: true, previousRevision: 0, revision: 1, conflictPolicy: "last-writer-wins" }); + + const updatedDocumentResponse = await fetch(documentUrl); + await expect(updatedDocumentResponse.json()).resolves.toEqual({ value: 2 }); + expect(updatedDocumentResponse.headers.get("x-mcp-editor-session-revision")).toBe("1"); + } finally { + await server.stopAsync(); + } + }); + + it("reflects local CORS origins by default", async () => { + const port = await GetFreePortAsync(); + const server = new McpEditorSessionServer({ + serverName: "Test Session Server", + documentKind: "test-document", + getDocument: () => JSON.stringify({ value: 1 }), + setDocument: () => undefined, + }); + + try { + const listeningPort = await server.startAsync(port); + const response = await fetch(`http://localhost:${listeningPort}/health`, { + headers: { Origin: "http://localhost:5173" }, + }); + + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:5173"); + expect(response.headers.get("vary")).toBe("Origin"); + } finally { + await server.stopAsync(); + } + }); + + it("does not allow non-local CORS origins by default", async () => { + const port = await GetFreePortAsync(); + const server = new McpEditorSessionServer({ + serverName: "Test Session Server", + documentKind: "test-document", + getDocument: () => JSON.stringify({ value: 1 }), + setDocument: () => undefined, + }); + + try { + const listeningPort = await server.startAsync(port); + const response = await fetch(`http://localhost:${listeningPort}/health`, { + headers: { Origin: "https://example.com" }, + }); + + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + } finally { + await server.stopAsync(); + } + }); + + it("supports explicit CORS origin overrides", async () => { + const port = await GetFreePortAsync(); + const server = new McpEditorSessionServer( + { + serverName: "Test Session Server", + documentKind: "test-document", + getDocument: () => JSON.stringify({ value: 1 }), + setDocument: () => undefined, + }, + { defaultPort: port, corsOrigin: "*" } + ); + + try { + const listeningPort = await server.startAsync(); + const response = await fetch(`http://localhost:${listeningPort}/health`, { + headers: { Origin: "https://example.com" }, + }); + + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + } finally { + await server.stopAsync(); + } + }); + + it("supports explicit CORS allow lists", async () => { + const port = await GetFreePortAsync(); + const server = new McpEditorSessionServer( + { + serverName: "Test Session Server", + documentKind: "test-document", + getDocument: () => JSON.stringify({ value: 1 }), + setDocument: () => undefined, + }, + { defaultPort: port, allowedOrigins: ["https://allowed.example.com"] } + ); + + try { + const listeningPort = await server.startAsync(); + const allowedResponse = await fetch(`http://localhost:${listeningPort}/health`, { + headers: { Origin: "https://allowed.example.com" }, + }); + const blockedResponse = await fetch(`http://localhost:${listeningPort}/health`, { + headers: { Origin: "http://localhost:5173" }, + }); + + expect(allowedResponse.headers.get("access-control-allow-origin")).toBe("https://allowed.example.com"); + expect(blockedResponse.headers.get("access-control-allow-origin")).toBeNull(); + } finally { + await server.stopAsync(); + } + }); + + it("stops cleanly and clears sessions", async () => { + const port = await GetFreePortAsync(); + const server = new McpEditorSessionServer({ + serverName: "Test Session Server", + documentKind: "test-document", + getDocument: () => JSON.stringify({ value: 1 }), + setDocument: () => undefined, + }); + + const session = server.createSession("main"); + await server.startAsync(port); + expect(server.isRunning()).toBe(true); + + await server.stopAsync(); + + expect(server.isRunning()).toBe(false); + expect(server.getPort()).toBe(0); + expect(server.closeSession(session.id)).toBe(false); + }); + + it("stops itself after the configured idle timeout", async () => { + const port = await GetFreePortAsync(); + const server = new McpEditorSessionServer( + { + serverName: "Test Session Server", + documentKind: "test-document", + getDocument: () => JSON.stringify({ value: 1 }), + setDocument: () => undefined, + }, + { idleTimeoutMs: 50 } + ); + + const session = server.createSession("main"); + await server.startAsync(port); + expect(server.isRunning()).toBe(true); + + await WaitForConditionAsync(() => !server.isRunning()); + + expect(server.getPort()).toBe(0); + expect(server.closeSession(session.id)).toBe(false); + }); + + it("resets the idle timeout when session activity occurs", async () => { + const port = await GetFreePortAsync(); + const server = new McpEditorSessionServer( + { + serverName: "Test Session Server", + documentKind: "test-document", + getDocument: () => JSON.stringify({ value: 1 }), + setDocument: () => undefined, + }, + { idleTimeoutMs: 120 } + ); + + const session = server.createSession("main"); + await server.startAsync(port); + + await new Promise((resolve) => setTimeout(resolve, 80)); + server.notifySessionUpdate(session.id); + await new Promise((resolve) => setTimeout(resolve, 80)); + + expect(server.isRunning()).toBe(true); + + await WaitForConditionAsync(() => !server.isRunning()); + }); + + it("can disable idle timeout", async () => { + const port = await GetFreePortAsync(); + const server = new McpEditorSessionServer( + { + serverName: "Test Session Server", + documentKind: "test-document", + getDocument: () => JSON.stringify({ value: 1 }), + setDocument: () => undefined, + }, + { idleTimeoutMs: 0 } + ); + + try { + await server.startAsync(port); + await new Promise((resolve) => setTimeout(resolve, 80)); + + expect(server.isRunning()).toBe(true); + expect(server.getHealth().idleTimeoutMs).toBe(0); + } finally { + await server.stopAsync(); + } + }); + + it("reuses a running session server within the same process", async () => { + const port = await GetFreePortAsync(); + const server = new McpEditorSessionServer({ + serverName: "Test Session Server", + documentKind: "test-document", + getDocument: () => JSON.stringify({ value: 1 }), + setDocument: () => undefined, + }); + + try { + const firstPort = await server.startAsync(port); + const secondPort = await server.startAsync(port + 1); + const firstSession = server.createSession("main"); + const secondSession = server.createSession("main"); + + expect(secondPort).toBe(firstPort); + expect(secondSession.id).toBe(firstSession.id); + } finally { + await server.stopAsync(); + } + }); + + it("falls back to the next available port when the preferred port is occupied", async () => { + const preferredPort = await GetFreePortAsync(); + const blocker = await StartBlockingServerAsync(preferredPort); + const server = new McpEditorSessionServer( + { + serverName: "Test Session Server", + documentKind: "test-document", + getDocument: () => JSON.stringify({ value: 1 }), + setDocument: () => undefined, + }, + { defaultPort: preferredPort, portRange: 10 } + ); + + try { + const listeningPort = await server.startAsync(); + + expect(listeningPort).not.toBe(preferredPort); + expect(listeningPort).toBeGreaterThan(preferredPort); + expect(listeningPort).toBeLessThan(preferredPort + 10); + } finally { + await server.stopAsync(); + blocker.close(); + } + }); + + it("updates revisions for MCP-side notifications even without editor subscribers", async () => { + const port = await GetFreePortAsync(); + const server = new McpEditorSessionServer({ + serverName: "Test Session Server", + documentKind: "test-document", + getDocument: () => JSON.stringify({ value: 1 }), + setDocument: () => undefined, + }); + + try { + const session = server.createSession("main"); + await server.startAsync(port); + + server.notifySessionUpdate(session.id); + + expect(session.revision).toBe(1); + } finally { + await server.stopAsync(); + } + }); + + it("binds a document manager through the reusable controller", async () => { + const port = await GetFreePortAsync(); + const manager = { + document: JSON.stringify({ value: 1 }), + exportJSON: () => manager.document, + importJSON: (document: string) => { + manager.document = document; + return "OK"; + }, + }; + const controller = new McpEditorSessionController({ + serverName: "Controller Test Server", + documentKind: "controller-document", + getDocument: (manager) => manager.exportJSON(), + setDocument: (manager, _session, document) => { + const result = manager.importJSON(document); + return result === "OK" ? undefined : result; + }, + }); + + try { + const listeningPort = await controller.startAsync(manager, port); + const sessionId = controller.createSession("main"); + const sessionUrl = controller.getSessionUrl(sessionId, listeningPort); + + expect(controller.getSessionIdForName("main")).toBe(sessionId); + expect(controller.getHealth()).toMatchObject({ + serverName: "Controller Test Server", + documentKind: "controller-document", + running: true, + }); + + const postResponse = await fetch(`${sessionUrl}/document`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: 2 }), + }); + + expect(postResponse.ok).toBe(true); + expect(JSON.parse(manager.document)).toEqual({ value: 2 }); + expect(controller.closeSessionForName("main")).toBe(true); + } finally { + await controller.stopAsync(); + } + }); + + it("checks discovered health payload compatibility", () => { + const health = { + protocol: "babylon-mcp-editor-session", + protocolVersion: "1.0", + serverName: "Discovery Test Server", + port: 3001, + host: "127.0.0.1", + publicHostname: "localhost", + documentKind: "test-document", + running: true, + activeSessionCount: 0, + capabilities: ["sse", "document-get", "document-post", "session-close", "last-writer-wins"], + conflictPolicy: "last-writer-wins", + workspaceId: "workspace-a", + ownerId: "owner-a", + }; + + expect(IsCompatibleMcpEditorSessionHealth(health, { documentKind: "test-document" })).toBe(true); + expect(IsCompatibleMcpEditorSessionHealth(health, { workspaceId: "workspace-a", ownerId: "owner-a" })).toBe(true); + expect(IsCompatibleMcpEditorSessionHealth(health, { documentKind: "other-document" })).toBe(false); + expect(IsCompatibleMcpEditorSessionHealth(health, { workspaceId: "workspace-b" })).toBe(false); + expect(IsCompatibleMcpEditorSessionHealth(health, { ownerId: "owner-b" })).toBe(false); + expect(IsCompatibleMcpEditorSessionHealth({ ...health, conflictPolicy: "first-writer-wins" }, { documentKind: "test-document" })).toBe(false); + expect(IsCompatibleMcpEditorSessionHealth({ ...health, protocol: "other-protocol" }, { documentKind: "test-document" })).toBe(false); + expect(IsCompatibleMcpEditorSessionHealth({ ...health, protocolVersion: "2.0" }, { documentKind: "test-document" })).toBe(false); + }); + + it("discovers compatible running session servers by health", async () => { + const port = await GetFreePortAsync(); + const server = new McpEditorSessionServer( + { + serverName: "Discovery Test Server", + documentKind: "test-document", + getDocument: () => JSON.stringify({ value: 1 }), + setDocument: () => undefined, + }, + { defaultPort: port, workspaceId: "workspace-a", ownerId: "owner-a" } + ); + + try { + await server.startAsync(); + + const discovery = await FindCompatibleMcpEditorSessionServerAsync({ + startPort: port, + portRange: 1, + documentKind: "test-document", + workspaceId: "workspace-a", + }); + const incompatibleDiscovery = await FindCompatibleMcpEditorSessionServerAsync({ startPort: port, portRange: 1, documentKind: "other-document" }); + const wrongWorkspaceDiscovery = await FindCompatibleMcpEditorSessionServerAsync({ + startPort: port, + portRange: 1, + documentKind: "test-document", + workspaceId: "workspace-b", + }); + + expect(discovery).toMatchObject({ + port, + health: { + serverName: "Discovery Test Server", + documentKind: "test-document", + workspaceId: "workspace-a", + ownerId: "owner-a", + }, + }); + expect(incompatibleDiscovery).toBeUndefined(); + expect(wrongWorkspaceDiscovery).toBeUndefined(); + } finally { + await server.stopAsync(); + } + }); + + it("ignores incompatible services during discovery", async () => { + const { server, port } = await StartIncompatibleHttpServerAsync(); + + try { + const discovery = await FindCompatibleMcpEditorSessionServerAsync({ startPort: port, portRange: 1, documentKind: "test-document" }); + + expect(discovery).toBeUndefined(); + } finally { + server.close(); + } + }); +}); diff --git a/packages/tools/mcp-server-core/test/unit/inputValidation.test.ts b/packages/tools/mcp-server-core/test/unit/inputValidation.test.ts new file mode 100644 index 00000000000..5a221c38539 --- /dev/null +++ b/packages/tools/mcp-server-core/test/unit/inputValidation.test.ts @@ -0,0 +1,54 @@ +import { IsInputProvided, RequireAtLeastOneInput, ResolveDefinedInput } from "../../src/index"; + +describe("input validation helpers", () => { + it("treats undefined, null, and empty strings as missing", () => { + expect(IsInputProvided(undefined)).toBe(false); + expect(IsInputProvided(null)).toBe(false); + expect(IsInputProvided("")).toBe(false); + expect(IsInputProvided("value")).toBe(true); + expect(IsInputProvided(0)).toBe(true); + }); + + it("requires at least one present input", () => { + expect(() => + RequireAtLeastOneInput({ + candidates: [ + { label: "type", value: undefined }, + { label: "cameraType", value: "" }, + ], + }) + ).toThrow("Error: Either type or cameraType must be provided."); + }); + + it("allows a custom missing message", () => { + expect(() => + RequireAtLeastOneInput({ + candidates: [{ label: "nmeJson", value: undefined }], + missingMessage: "Error: NodeMaterial requires at least one source.", + }) + ).toThrow("Error: NodeMaterial requires at least one source."); + }); + + it("resolves the first present alias value", () => { + const resolved = ResolveDefinedInput({ + candidates: [ + { label: "graphName", value: undefined }, + { label: "name", value: "mainGraph" }, + ], + }); + + expect(resolved).toBe("mainGraph"); + }); + + it("supports a custom presence predicate", () => { + const resolved = ResolveDefinedInput({ + candidates: [ + { label: "bodyType", value: 0 }, + { label: "type", value: 2 }, + ], + isPresent: (value) => value !== undefined && value !== null, + }); + + expect(resolved).toBe(0); + }); +}); diff --git a/packages/tools/mcp-server-core/test/unit/jsonToolResponses.test.ts b/packages/tools/mcp-server-core/test/unit/jsonToolResponses.test.ts new file mode 100644 index 00000000000..5b93273dfef --- /dev/null +++ b/packages/tools/mcp-server-core/test/unit/jsonToolResponses.test.ts @@ -0,0 +1,199 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { + CreateJsonExportResponse, + CreateJsonImportResponse, + CreateJsonImportSummaryResponse, + CreateTextResponse, + CreateTypedSnippetImportResponse, + CreateTypedSnippetImportSummaryResponse, + RunSnippetResponse, +} from "../../src/index"; + +describe("json tool response helpers", () => { + it("returns an error when export JSON is missing", () => { + expect( + CreateJsonExportResponse({ + jsonText: undefined, + missingMessage: 'Material "test" not found.', + fileLabel: "NME JSON", + }) + ).toEqual({ + content: [{ type: "text", text: 'Material "test" not found.' }], + isError: true, + }); + }); + + it("writes exported JSON to disk when outputFile is provided", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-core-export-")); + const outputFile = path.join(tempDir, "nested", "graph.json"); + + expect( + CreateJsonExportResponse({ + jsonText: '{"ok":true}', + outputFile, + missingMessage: 'Material "test" not found.', + fileLabel: "NME JSON", + }) + ).toEqual({ + content: [{ type: "text", text: `NME JSON written to: ${outputFile}` }], + }); + + expect(fs.readFileSync(outputFile, "utf-8")).toBe('{"ok":true}'); + }); + + it("returns inline exported JSON when no outputFile is provided", () => { + expect( + CreateJsonExportResponse({ + jsonText: '{"ok":true}', + missingMessage: 'Material "test" not found.', + fileLabel: "NME JSON", + }) + ).toEqual({ + content: [{ type: "text", text: '{"ok":true}' }], + }); + }); + + it("imports JSON from inline text and appends the object description", () => { + expect( + CreateJsonImportResponse({ + json: '{"ok":true}', + fileDescription: "NME JSON file", + importJson: () => "OK", + describeImported: () => "material description", + }) + ).toEqual({ + content: [{ type: "text", text: "Imported successfully.\n\nmaterial description" }], + }); + }); + + it("returns an error when import validation fails", () => { + expect( + CreateJsonImportResponse({ + fileDescription: "GUI JSON file", + importJson: () => "OK", + describeImported: () => "gui description", + }) + ).toEqual({ + content: [{ type: "text", text: "Either json or jsonFile must be provided." }], + isError: true, + }); + }); + + it("returns an error when the import callback fails", () => { + expect( + CreateJsonImportResponse({ + json: '{"ok":false}', + fileDescription: "GUI JSON file", + importJson: () => "bad payload", + describeImported: () => "gui description", + }) + ).toEqual({ + content: [{ type: "text", text: "Error: bad payload" }], + isError: true, + }); + }); + + it("creates a custom summary response for JSON imports", () => { + expect( + CreateJsonImportSummaryResponse({ + json: '{"ok":true}', + fileDescription: "NRGE JSON file", + importJson: () => ({ blocks: [1, 2], outputNodeId: 7 }), + createSuccessText: (graph) => `Imported render graph with ${graph.blocks.length} blocks.\noutputNodeId: ${graph.outputNodeId}`, + }) + ).toEqual({ + content: [{ type: "text", text: "Imported render graph with 2 blocks.\noutputNodeId: 7" }], + }); + }); + + it("returns an error when custom-summary import throws", () => { + expect( + CreateJsonImportSummaryResponse({ + json: '{"ok":true}', + fileDescription: "NRGE JSON file", + importJson: () => { + throw new Error("bad graph"); + }, + createSuccessText: () => "unreachable", + }) + ).toEqual({ + content: [{ type: "text", text: "Error: bad graph" }], + isError: true, + }); + }); + + it("returns an error for snippets with the wrong type", () => { + expect( + CreateTypedSnippetImportResponse({ + snippetId: "ABC123", + snippetResult: { type: "gui", data: {} }, + expectedType: "nodeMaterial", + importJson: () => "OK", + describeImported: () => "material description", + successMessage: 'Imported snippet "ABC123" as "mat" successfully.', + }) + ).toEqual({ + content: [{ type: "text", text: 'Error: Snippet "ABC123" is of type "gui", not "nodeMaterial".' }], + isError: true, + }); + }); + + it("imports typed snippet JSON and appends the object description", () => { + expect( + CreateTypedSnippetImportResponse({ + snippetId: "ABC123", + snippetResult: { type: "nodeMaterial", data: { editorData: { locations: [] } } }, + expectedType: "nodeMaterial", + importJson: () => "OK", + describeImported: () => "material description", + successMessage: 'Imported snippet "ABC123" as "mat" successfully.', + }) + ).toEqual({ + content: [{ type: "text", text: 'Imported snippet "ABC123" as "mat" successfully.\n\nmaterial description' }], + }); + }); + + it("creates a custom summary response for typed snippet imports", () => { + expect( + CreateTypedSnippetImportSummaryResponse({ + snippetId: "ABC123", + snippetResult: { type: "nodeRenderGraph", data: { blocks: [1, 2, 3] } }, + expectedType: "nodeRenderGraph", + importJson: () => ({ blocks: [1, 2, 3], outputNodeId: null }), + createSuccessText: (graph) => `Imported render graph with ${graph.blocks.length} blocks.\noutputNodeId: ${graph.outputNodeId ?? "(not set)"}`, + }) + ).toEqual({ + content: [{ type: "text", text: "Imported render graph with 3 blocks.\noutputNodeId: (not set)" }], + }); + }); + + it("loads a snippet and delegates to the response factory", async () => { + await expect( + RunSnippetResponse({ + snippetId: "ABC123", + loadSnippet: async () => ({ type: "nodeMaterial", data: { ok: true } }), + createResponse: (snippetResult) => CreateTextResponse(`Loaded ${snippetResult.type}`), + }) + ).resolves.toEqual({ + content: [{ type: "text", text: "Loaded nodeMaterial" }], + }); + }); + + it("returns a consistent error when snippet loading fails", async () => { + await expect( + RunSnippetResponse({ + snippetId: "ABC123", + loadSnippet: async () => { + throw new Error("network down"); + }, + createResponse: () => CreateTextResponse("unreachable"), + }) + ).resolves.toEqual({ + content: [{ type: "text", text: 'Error fetching snippet "ABC123": network down' }], + isError: true, + }); + }); +}); diff --git a/packages/tools/mcp-server-core/test/unit/jsonValidation.test.ts b/packages/tools/mcp-server-core/test/unit/jsonValidation.test.ts new file mode 100644 index 00000000000..dd4d98ace84 --- /dev/null +++ b/packages/tools/mcp-server-core/test/unit/jsonValidation.test.ts @@ -0,0 +1,44 @@ +import { ParseJsonText } from "../../src/index"; + +describe("json validation helpers", () => { + it("parses valid JSON text", () => { + const parsed = ParseJsonText<{ enabled: boolean }>({ + jsonText: '{"enabled":true}', + jsonLabel: "test JSON", + }); + + expect(parsed).toEqual({ enabled: true }); + }); + + it("throws a consistent parse error by default", () => { + expect(() => + ParseJsonText({ + jsonText: "not-json", + jsonLabel: "GUI JSON", + }) + ).toThrow("Invalid GUI JSON: parse error."); + }); + + it("includes an input preview when requested", () => { + expect(() => + ParseJsonText({ + jsonText: "{bad json payload}", + jsonLabel: "GUI JSON", + includePreviewInError: true, + previewLength: 8, + }) + ).toThrow("Invalid GUI JSON: {bad jso..."); + }); + + it("preserves the original parse error as the cause", () => { + try { + ParseJsonText({ + jsonText: "nope", + jsonLabel: "GUI JSON", + }); + fail("Expected ParseJsonText to throw."); + } catch (error) { + expect((error as Error).cause).toBeInstanceOf(Error); + } + }); +}); diff --git a/packages/tools/mcp-server-core/test/unit/response.test.ts b/packages/tools/mcp-server-core/test/unit/response.test.ts new file mode 100644 index 00000000000..6abc1d8a386 --- /dev/null +++ b/packages/tools/mcp-server-core/test/unit/response.test.ts @@ -0,0 +1,29 @@ +import { CreateErrorResponse, CreateTextContent, CreateTextResponse, CreateTextResponses } from "../../src/index"; + +describe("response helpers", () => { + it("creates a text content block", () => { + expect(CreateTextContent("hello")).toEqual({ type: "text", text: "hello" }); + }); + + it("creates a success text response", () => { + expect(CreateTextResponse("done")).toEqual({ + content: [{ type: "text", text: "done" }], + }); + }); + + it("creates an error response", () => { + expect(CreateErrorResponse("bad")).toEqual({ + content: [{ type: "text", text: "bad" }], + isError: true, + }); + }); + + it("creates multi-block responses", () => { + expect(CreateTextResponses(["first", "second"])).toEqual({ + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + }); + }); +}); diff --git a/packages/tools/mcp-server-core/test/unit/sceneAttachmentValidation.test.ts b/packages/tools/mcp-server-core/test/unit/sceneAttachmentValidation.test.ts new file mode 100644 index 00000000000..2199a7f3518 --- /dev/null +++ b/packages/tools/mcp-server-core/test/unit/sceneAttachmentValidation.test.ts @@ -0,0 +1,118 @@ +import { + ValidateFlowGraphAttachmentPayload, + ValidateGuiAttachmentPayload, + ValidateNodeGeometryAttachmentPayload, + ValidateNodeMaterialAttachmentPayload, + ValidateNodeRenderGraphAttachmentPayload, + ValidateSmartFilterAttachmentPayload, +} from "../../src/index"; + +describe("scene attachment validation helpers", () => { + it("accepts GUI JSON with a root object", () => { + expect(ValidateGuiAttachmentPayload({ width: 100, root: {} })).toEqual({ width: 100, root: {} }); + }); + + it("rejects GUI JSON without a root object", () => { + expect(() => ValidateGuiAttachmentPayload({})).toThrow("Invalid GUI JSON: must contain a 'root' object."); + }); + + it("accepts NRG JSON with the expected customType and blocks", () => { + expect(ValidateNodeRenderGraphAttachmentPayload({ customType: "BABYLON.NodeRenderGraph", blocks: [] })).toEqual({ + customType: "BABYLON.NodeRenderGraph", + blocks: [], + }); + }); + + it("rejects NRG JSON with the wrong customType", () => { + expect(() => ValidateNodeRenderGraphAttachmentPayload({ customType: "BABYLON.OtherGraph", blocks: [] })).toThrow( + 'Invalid NRG JSON: customType must be "BABYLON.NodeRenderGraph" but got "BABYLON.OtherGraph".' + ); + }); + + it("accepts NGE JSON with the expected customType and blocks", () => { + expect(ValidateNodeGeometryAttachmentPayload({ customType: "BABYLON.NodeGeometry", blocks: [] })).toEqual({ + customType: "BABYLON.NodeGeometry", + blocks: [], + }); + }); + + it("accepts coordinator-level Flow Graph JSON and normalizes graphs", () => { + const result = ValidateFlowGraphAttachmentPayload({ + _flowGraphs: [{ allBlocks: [], executionContexts: [] }], + dispatchEventsSynchronously: false, + }); + + expect(result.graphs).toHaveLength(1); + expect(result.graphs[0].allBlocks).toEqual([]); + }); + + it("accepts graph-level Flow Graph JSON", () => { + const result = ValidateFlowGraphAttachmentPayload({ allBlocks: [], executionContexts: [] }); + expect(result.graphs).toHaveLength(1); + }); + + it("rejects empty Flow Graph coordinators", () => { + expect(() => ValidateFlowGraphAttachmentPayload({ _flowGraphs: [] })).toThrow("Invalid Flow Graph JSON: '_flowGraphs' must contain at least one graph."); + }); + + it("accepts Node Material JSON with both output blocks", () => { + expect( + ValidateNodeMaterialAttachmentPayload({ + mode: 0, + blocks: [{ customType: "BABYLON.VertexOutputBlock" }, { customType: "BABYLON.FragmentOutputBlock" }], + outputNodes: [1, 2], + }) + ).toEqual({ + mode: 0, + blocks: [{ customType: "BABYLON.VertexOutputBlock" }, { customType: "BABYLON.FragmentOutputBlock" }], + outputNodes: [1, 2], + }); + }); + + it("rejects Node Material JSON missing required output blocks", () => { + expect(() => + ValidateNodeMaterialAttachmentPayload({ + mode: 0, + blocks: [{ customType: "BABYLON.VertexOutputBlock" }], + outputNodes: [1], + }) + ).toThrow("Invalid NME JSON: missing FragmentOutputBlock."); + }); + + it("accepts valid Smart Filter JSON", () => { + const sfJson = { format: "smartFilter", formatVersion: 1, blocks: [], connections: [] }; + expect(ValidateSmartFilterAttachmentPayload(sfJson)).toEqual(sfJson); + }); + + it("accepts Smart Filter JSON with blocks and connections", () => { + const sfJson = { + format: "smartFilter", + formatVersion: 1, + blocks: [{ name: "outputBlock", blockType: "OutputBlock", uniqueId: 1 }], + connections: [{ outputBlock: 1, outputConnectionPoint: "output", inputBlock: 2, inputConnectionPoint: "input" }], + }; + expect(ValidateSmartFilterAttachmentPayload(sfJson)).toEqual(sfJson); + }); + + it("rejects Smart Filter JSON with wrong format", () => { + expect(() => ValidateSmartFilterAttachmentPayload({ format: "notSmartFilter", formatVersion: 1, blocks: [], connections: [] })).toThrow('format must be "smartFilter"'); + }); + + it("rejects Smart Filter JSON with wrong formatVersion", () => { + expect(() => ValidateSmartFilterAttachmentPayload({ format: "smartFilter", formatVersion: 99, blocks: [], connections: [] })).toThrow("formatVersion must be 1"); + }); + + it("rejects Smart Filter JSON without blocks array", () => { + expect(() => ValidateSmartFilterAttachmentPayload({ format: "smartFilter", formatVersion: 1, connections: [] })).toThrow("must contain a 'blocks' array"); + }); + + it("rejects Smart Filter JSON without connections array", () => { + expect(() => ValidateSmartFilterAttachmentPayload({ format: "smartFilter", formatVersion: 1, blocks: [] })).toThrow("must contain a 'connections' array"); + }); + + it("parses Smart Filter JSON from string", () => { + const jsonStr = '{"format":"smartFilter","formatVersion":1,"blocks":[],"connections":[]}'; + const result = ValidateSmartFilterAttachmentPayload(jsonStr); + expect(result).toEqual({ format: "smartFilter", formatVersion: 1, blocks: [], connections: [] }); + }); +}); diff --git a/packages/tools/mcp-server-core/test/unit/sceneToolSchemas.test.ts b/packages/tools/mcp-server-core/test/unit/sceneToolSchemas.test.ts new file mode 100644 index 00000000000..bfdd9c87846 --- /dev/null +++ b/packages/tools/mcp-server-core/test/unit/sceneToolSchemas.test.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +import { + CreateSceneFlowGraphAttachmentFields, + CreateSceneGuiAttachmentFields, + CreateSceneImportFields, + CreateSceneNodeGeometryFields, + CreateSceneNodeMaterialInputFields, + CreateSceneRenderGraphAttachmentFields, +} from "../../src/index"; + +describe("scene tool schema helpers", () => { + it("creates the scene node material helper fields", () => { + const fields: any = CreateSceneNodeMaterialInputFields(z); + + expect(fields.nmeJson.description).toBe("For NodeMaterial: the full NME JSON string exported from the Node Material MCP server"); + expect(fields.nmeJsonFile.description).toBe( + "For NodeMaterial: absolute path to a file containing the NME JSON (alternative to inline nmeJson — avoids large payloads in context)" + ); + expect(fields.snippetId.description).toBe("For NodeMaterial: a Babylon.js Snippet Server ID to load from"); + expect(fields.nmeJson.safeParse("{}").success).toBe(true); + expect(fields.snippetId.safeParse(undefined).success).toBe(true); + }); + + it("creates the scene flow graph attachment helper fields", () => { + const fields: any = CreateSceneFlowGraphAttachmentFields(z); + + expect(fields.coordinatorJson.description).toBe("The complete Flow Graph coordinator JSON string"); + expect(fields.coordinatorJsonFile.description).toBe("Absolute path to a file containing the Flow Graph coordinator JSON (alternative to inline coordinatorJson)"); + expect(fields.flowGraphJson.description).toBe("Alias for coordinatorJson — the Flow Graph JSON string"); + expect(fields.flowGraphJsonFile.description).toBe("Alias for coordinatorJsonFile — path to the Flow Graph JSON file"); + }); + + it("creates the scene GUI/render graph/node geometry attachment helper fields", () => { + const guiFields: any = CreateSceneGuiAttachmentFields(z); + const renderGraphFields: any = CreateSceneRenderGraphAttachmentFields(z); + const geometryFields: any = CreateSceneNodeGeometryFields(z); + + expect(guiFields.guiJsonFile.description).toBe("Absolute path to a file containing the GUI JSON (alternative to inline guiJson)"); + expect(renderGraphFields.nrgJson.description).toBe("The NRG JSON string (from the Node Render Graph MCP server's export_graph_json tool)"); + expect(geometryFields.ngeJsonFile.description).toBe("Absolute path to a file containing the NGE JSON (alternative to inline ngeJson)"); + }); + + it("creates the raw scene import helper fields", () => { + const fields: any = CreateSceneImportFields(z); + + expect(fields.json.description).toBe("The scene descriptor JSON string"); + expect(fields.jsonFile.description).toBe("Absolute path to a file containing the scene descriptor JSON (alternative to inline json)"); + expect(fields.jsonFile.safeParse("/tmp/scene.json").success).toBe(true); + }); +}); diff --git a/packages/tools/mcp-server-core/test/unit/textHandoff.test.ts b/packages/tools/mcp-server-core/test/unit/textHandoff.test.ts new file mode 100644 index 00000000000..060724e77cd --- /dev/null +++ b/packages/tools/mcp-server-core/test/unit/textHandoff.test.ts @@ -0,0 +1,72 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { ResolveInlineOrFileText, WriteTextFileEnsuringDirectory } from "../../src/index"; + +describe("text handoff helpers", () => { + it("returns inline text when only inline text is provided", () => { + const result = ResolveInlineOrFileText({ + inlineText: "hello", + inlineLabel: "json", + fileLabel: "jsonFile", + }); + + expect(result).toEqual({ text: "hello", source: "inline" }); + }); + + it("reads text from a file when only filePath is provided", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-core-")); + const filePath = path.join(tempDir, "input.json"); + fs.writeFileSync(filePath, "from-file", "utf-8"); + + const result = ResolveInlineOrFileText({ + filePath, + inlineLabel: "json", + fileLabel: "jsonFile", + fileDescription: "JSON file", + }); + + expect(result).toEqual({ text: "from-file", source: "file" }); + }); + + it("throws when both inline text and filePath are provided", () => { + expect(() => + ResolveInlineOrFileText({ + inlineText: "hello", + filePath: "/tmp/file.json", + inlineLabel: "json", + fileLabel: "jsonFile", + }) + ).toThrow("Provide either json or jsonFile, not both."); + }); + + it("throws when neither inline text nor filePath is provided", () => { + expect(() => + ResolveInlineOrFileText({ + inlineLabel: "json", + fileLabel: "jsonFile", + }) + ).toThrow("Either json or jsonFile must be provided."); + }); + + it("throws a descriptive read error for missing files", () => { + expect(() => + ResolveInlineOrFileText({ + filePath: "/definitely/missing.json", + inlineLabel: "json", + fileLabel: "jsonFile", + fileDescription: "JSON file", + }) + ).toThrow(/Error reading JSON file:/); + }); + + it("writes text and creates parent directories", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-core-write-")); + const nestedFilePath = path.join(tempDir, "nested", "deeper", "output.json"); + + WriteTextFileEnsuringDirectory(nestedFilePath, "saved-content"); + + expect(fs.readFileSync(nestedFilePath, "utf-8")).toBe("saved-content"); + }); +}); diff --git a/packages/tools/mcp-server-core/test/unit/toolSchemas.test.ts b/packages/tools/mcp-server-core/test/unit/toolSchemas.test.ts new file mode 100644 index 00000000000..969d113d673 --- /dev/null +++ b/packages/tools/mcp-server-core/test/unit/toolSchemas.test.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +import { CreateInlineJsonSchema, CreateJsonFileSchema, CreateOutputFileSchema, CreateOverwriteSchema, CreateSnippetIdSchema } from "../../src/index"; + +describe("tool schema helpers", () => { + it("creates the shared output file schema", () => { + const schema = CreateOutputFileSchema(z); + + expect(schema.description).toBe("Optional absolute file path. When provided, the JSON is written to this file and the path is returned instead of the full JSON."); + expect(schema.safeParse(undefined).success).toBe(true); + expect(schema.safeParse("/tmp/output.json").success).toBe(true); + }); + + it("creates described inline and file JSON schemas", () => { + const inlineSchema = CreateInlineJsonSchema(z, "The NME JSON string to import"); + const fileSchema = CreateJsonFileSchema(z, "Absolute path to a file containing the NME JSON to import (alternative to inline json)"); + + expect(inlineSchema.description).toBe("The NME JSON string to import"); + expect(fileSchema.description).toBe("Absolute path to a file containing the NME JSON to import (alternative to inline json)"); + expect(inlineSchema.safeParse("{}").success).toBe(true); + expect(fileSchema.safeParse("/tmp/input.json").success).toBe(true); + }); + + it("creates the shared snippet id schema", () => { + const schema = CreateSnippetIdSchema(z); + + expect(schema.description).toBe('Snippet ID from the Babylon.js Snippet Server (e.g. "ABC123" or "ABC123#2")'); + expect(schema.safeParse("ABC123#2").success).toBe(true); + expect(schema.safeParse(undefined).success).toBe(false); + }); + + it("creates the shared overwrite schema", () => { + const schema = CreateOverwriteSchema(z); + + expect(schema.description).toBe("If true, replace any existing graph with the same name. Default: false."); + expect(schema.safeParse(true).success).toBe(true); + expect(schema.safeParse(undefined).success).toBe(true); + }); +}); diff --git a/packages/tools/mcp-server-core/tsconfig.build.json b/packages/tools/mcp-server-core/tsconfig.build.json new file mode 100644 index 00000000000..1d2976a644a --- /dev/null +++ b/packages/tools/mcp-server-core/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declarationDir": "./dist" + }, + "include": ["./src/**/*"] +} diff --git a/packages/tools/mcp-server-core/tsconfig.json b/packages/tools/mcp-server-core/tsconfig.json new file mode 100644 index 00000000000..ea56794f863 --- /dev/null +++ b/packages/tools/mcp-server-core/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../tsconfig.json" +} diff --git a/packages/tools/nge-mcp-server/README.md b/packages/tools/nge-mcp-server/README.md new file mode 100644 index 00000000000..5d15b2bb580 --- /dev/null +++ b/packages/tools/nge-mcp-server/README.md @@ -0,0 +1,42 @@ +# @tools/nge-mcp-server + +MCP server for AI-driven Babylon.js Node Geometry authoring. + +## Provides + +- create and manage in-memory Node Geometry graphs +- add blocks, connect ports, and set block properties +- inspect graph structure and validate geometry graphs +- export and import NGE JSON +- import from and save to Babylon.js snippets + +## Typical Workflow + +```text +create_geometry -> add_block -> connect_blocks -> set_block_properties -> validate_geometry -> export_geometry_json +``` + +The exported JSON can be used directly in Scene MCP to create a mesh at runtime. + +## Binary + +```bash +babylonjs-node-geometry +``` + +## Build And Run + +```bash +npm run build -w @tools/nge-mcp-server +npm run start -w @tools/nge-mcp-server +``` + +## Integration + +Exported NGE JSON can be consumed by the Scene MCP server through `add_node_geometry_mesh`, either inline or via `ngeJsonFile`. + +## Related Files + +- `src/index.ts`: MCP tool registration +- `src/geometryGraph.ts`: graph manager and serialization logic +- `src/blockRegistry.ts`: Node Geometry block catalog diff --git a/packages/tools/nge-mcp-server/examples/BooleanCSG.json b/packages/tools/nge-mcp-server/examples/BooleanCSG.json new file mode 100644 index 00000000000..412be8ef713 --- /dev/null +++ b/packages/tools/nge-mcp-server/examples/BooleanCSG.json @@ -0,0 +1,164 @@ +{ + "customType": "BABYLON.NodeGeometry", + "outputNodeId": 4, + "blocks": [ + { + "customType": "BABYLON.BoxBlock", + "id": 1, + "name": "box", + "inputs": [ + { + "name": "size", + "displayName": "size" + }, + { + "name": "width", + "displayName": "width" + }, + { + "name": "height", + "displayName": "height" + }, + { + "name": "depth", + "displayName": "depth" + }, + { + "name": "subdivisions", + "displayName": "subdivisions" + }, + { + "name": "subdivisionsX", + "displayName": "subdivisionsX" + }, + { + "name": "subdivisionsY", + "displayName": "subdivisionsY" + }, + { + "name": "subdivisionsZ", + "displayName": "subdivisionsZ" + } + ], + "outputs": [ + { + "name": "geometry", + "displayName": "geometry" + } + ], + "evaluateContext": false + }, + { + "customType": "BABYLON.SphereBlock", + "id": 2, + "name": "sphere", + "inputs": [ + { + "name": "segments", + "displayName": "segments" + }, + { + "name": "diameter", + "displayName": "diameter" + }, + { + "name": "diameterX", + "displayName": "diameterX" + }, + { + "name": "diameterY", + "displayName": "diameterY" + }, + { + "name": "diameterZ", + "displayName": "diameterZ" + }, + { + "name": "arc", + "displayName": "arc" + }, + { + "name": "slice", + "displayName": "slice" + } + ], + "outputs": [ + { + "name": "geometry", + "displayName": "geometry" + } + ], + "evaluateContext": false + }, + { + "customType": "BABYLON.BooleanGeometryBlock", + "id": 3, + "name": "csg", + "inputs": [ + { + "name": "geometry0", + "displayName": "geometry0", + "inputName": "geometry0", + "targetBlockId": 1, + "targetConnectionName": "geometry" + }, + { + "name": "geometry1", + "displayName": "geometry1", + "inputName": "geometry1", + "targetBlockId": 2, + "targetConnectionName": "geometry" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "evaluateContext": false, + "operation": 1, + "useOldCSGEngine": false + }, + { + "customType": "BABYLON.GeometryOutputBlock", + "id": 4, + "name": "output", + "inputs": [ + { + "name": "geometry", + "displayName": "geometry", + "inputName": "geometry", + "targetBlockId": 3, + "targetConnectionName": "output" + } + ], + "outputs": [] + } + ], + "comment": "Subtract a sphere from a box (CSG operation).", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 0, + "y": 0 + }, + { + "blockId": 2, + "x": 0, + "y": 180 + }, + { + "blockId": 3, + "x": 340, + "y": 0 + }, + { + "blockId": 4, + "x": 680, + "y": 0 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nge-mcp-server/examples/MathPipeline.json b/packages/tools/nge-mcp-server/examples/MathPipeline.json new file mode 100644 index 00000000000..753cee2a1fb --- /dev/null +++ b/packages/tools/nge-mcp-server/examples/MathPipeline.json @@ -0,0 +1,371 @@ +{ + "customType": "BABYLON.NodeGeometry", + "outputNodeId": 11, + "blocks": [ + { + "customType": "BABYLON.GridBlock", + "id": 1, + "name": "grid", + "inputs": [ + { + "name": "width", + "displayName": "width" + }, + { + "name": "height", + "displayName": "height" + }, + { + "name": "subdivisions", + "displayName": "subdivisions" + }, + { + "name": "subdivisionsX", + "displayName": "subdivisionsX" + }, + { + "name": "subdivisionsY", + "displayName": "subdivisionsY" + } + ], + "outputs": [ + { + "name": "geometry", + "displayName": "geometry" + } + ], + "evaluateContext": false + }, + { + "customType": "BABYLON.GeometryInputBlock", + "id": 2, + "name": "positions", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 8, + "contextualValue": 1, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true + }, + { + "customType": "BABYLON.VectorConverterBlock", + "id": 3, + "name": "decompose", + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz ", + "displayName": "xyz " + }, + { + "name": "xy ", + "displayName": "xy " + }, + { + "name": "zw ", + "displayName": "zw " + }, + { + "name": "x ", + "displayName": "x " + }, + { + "name": "y ", + "displayName": "y " + }, + { + "name": "z ", + "displayName": "z " + }, + { + "name": "w ", + "displayName": "w " + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.GeometryInputBlock", + "id": 4, + "name": "frequency", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": 5, + "valueType": "number" + }, + { + "customType": "BABYLON.MathBlock", + "id": 5, + "name": "xTimesFreq", + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 3, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 2 + }, + { + "customType": "BABYLON.GeometryTrigonometryBlock", + "id": 6, + "name": "sin", + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 1 + }, + { + "customType": "BABYLON.GeometryInputBlock", + "id": 7, + "name": "amplitude", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": 0.3, + "valueType": "number" + }, + { + "customType": "BABYLON.MathBlock", + "id": 8, + "name": "sinTimesAmp", + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 7, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 2 + }, + { + "customType": "BABYLON.SetPositionsBlock", + "id": 9, + "name": "deform", + "inputs": [ + { + "name": "geometry", + "displayName": "geometry", + "inputName": "geometry", + "targetBlockId": 1, + "targetConnectionName": "geometry" + }, + { + "name": "positions", + "displayName": "positions", + "inputName": "positions", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "evaluateContext": true + }, + { + "customType": "BABYLON.ComputeNormalsBlock", + "id": 10, + "name": "normals", + "inputs": [ + { + "name": "geometry", + "displayName": "geometry", + "inputName": "geometry", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.GeometryOutputBlock", + "id": 11, + "name": "output", + "inputs": [ + { + "name": "geometry", + "displayName": "geometry", + "inputName": "geometry", + "targetBlockId": 10, + "targetConnectionName": "output" + } + ], + "outputs": [] + } + ], + "comment": "Sin-wave deformation along the Y axis of a grid.", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 1360, + "y": 0 + }, + { + "blockId": 2, + "x": 0, + "y": 0 + }, + { + "blockId": 3, + "x": 340, + "y": 0 + }, + { + "blockId": 4, + "x": 340, + "y": 180 + }, + { + "blockId": 5, + "x": 680, + "y": 0 + }, + { + "blockId": 6, + "x": 1020, + "y": 0 + }, + { + "blockId": 7, + "x": 1020, + "y": 180 + }, + { + "blockId": 8, + "x": 1360, + "y": 180 + }, + { + "blockId": 9, + "x": 1700, + "y": 0 + }, + { + "blockId": 10, + "x": 2040, + "y": 0 + }, + { + "blockId": 11, + "x": 2380, + "y": 0 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nge-mcp-server/examples/NoiseTerrain.json b/packages/tools/nge-mcp-server/examples/NoiseTerrain.json new file mode 100644 index 00000000000..9d4375de58c --- /dev/null +++ b/packages/tools/nge-mcp-server/examples/NoiseTerrain.json @@ -0,0 +1,243 @@ +{ + "customType": "BABYLON.NodeGeometry", + "outputNodeId": 8, + "blocks": [ + { + "customType": "BABYLON.GridBlock", + "id": 1, + "name": "plane", + "inputs": [ + { + "name": "width", + "displayName": "width" + }, + { + "name": "height", + "displayName": "height" + }, + { + "name": "subdivisions", + "displayName": "subdivisions" + }, + { + "name": "subdivisionsX", + "displayName": "subdivisionsX" + }, + { + "name": "subdivisionsY", + "displayName": "subdivisionsY" + } + ], + "outputs": [ + { + "name": "geometry", + "displayName": "geometry" + } + ], + "evaluateContext": false + }, + { + "customType": "BABYLON.GeometryInputBlock", + "id": 2, + "name": "positions", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 8, + "contextualValue": 1, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true + }, + { + "customType": "BABYLON.NoiseBlock", + "id": 3, + "name": "noise", + "inputs": [ + { + "name": "offset", + "displayName": "offset" + }, + { + "name": "scale", + "displayName": "scale" + }, + { + "name": "octaves", + "displayName": "octaves" + }, + { + "name": "roughness", + "displayName": "roughness" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.GeometryInputBlock", + "id": 4, + "name": "heightScale", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": 2, + "valueType": "number" + }, + { + "customType": "BABYLON.MathBlock", + "id": 5, + "name": "heightMul", + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 2 + }, + { + "customType": "BABYLON.SetPositionsBlock", + "id": 6, + "name": "setPositions", + "inputs": [ + { + "name": "geometry", + "displayName": "geometry", + "inputName": "geometry", + "targetBlockId": 1, + "targetConnectionName": "geometry" + }, + { + "name": "positions", + "displayName": "positions", + "inputName": "positions", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "evaluateContext": true + }, + { + "customType": "BABYLON.ComputeNormalsBlock", + "id": 7, + "name": "normals", + "inputs": [ + { + "name": "geometry", + "displayName": "geometry", + "inputName": "geometry", + "targetBlockId": 6, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.GeometryOutputBlock", + "id": 8, + "name": "output", + "inputs": [ + { + "name": "geometry", + "displayName": "geometry", + "inputName": "geometry", + "targetBlockId": 7, + "targetConnectionName": "output" + } + ], + "outputs": [] + } + ], + "comment": "Deform a plane with Perlin noise to create terrain.", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 680, + "y": 0 + }, + { + "blockId": 2, + "x": 0, + "y": 0 + }, + { + "blockId": 3, + "x": 340, + "y": 0 + }, + { + "blockId": 4, + "x": 340, + "y": 180 + }, + { + "blockId": 5, + "x": 680, + "y": 180 + }, + { + "blockId": 6, + "x": 1020, + "y": 0 + }, + { + "blockId": 7, + "x": 1360, + "y": 0 + }, + { + "blockId": 8, + "x": 1700, + "y": 0 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nge-mcp-server/examples/ScatteredInstances.json b/packages/tools/nge-mcp-server/examples/ScatteredInstances.json new file mode 100644 index 00000000000..b3e5b0a062d --- /dev/null +++ b/packages/tools/nge-mcp-server/examples/ScatteredInstances.json @@ -0,0 +1,260 @@ +{ + "customType": "BABYLON.NodeGeometry", + "outputNodeId": 7, + "blocks": [ + { + "customType": "BABYLON.BoxBlock", + "id": 1, + "name": "baseMesh", + "inputs": [ + { + "name": "size", + "displayName": "size" + }, + { + "name": "width", + "displayName": "width" + }, + { + "name": "height", + "displayName": "height" + }, + { + "name": "depth", + "displayName": "depth" + }, + { + "name": "subdivisions", + "displayName": "subdivisions" + }, + { + "name": "subdivisionsX", + "displayName": "subdivisionsX" + }, + { + "name": "subdivisionsY", + "displayName": "subdivisionsY" + }, + { + "name": "subdivisionsZ", + "displayName": "subdivisionsZ" + } + ], + "outputs": [ + { + "name": "geometry", + "displayName": "geometry" + } + ], + "evaluateContext": false + }, + { + "customType": "BABYLON.SphereBlock", + "id": 2, + "name": "instanceMesh", + "inputs": [ + { + "name": "segments", + "displayName": "segments" + }, + { + "name": "diameter", + "displayName": "diameter" + }, + { + "name": "diameterX", + "displayName": "diameterX" + }, + { + "name": "diameterY", + "displayName": "diameterY" + }, + { + "name": "diameterZ", + "displayName": "diameterZ" + }, + { + "name": "arc", + "displayName": "arc" + }, + { + "name": "slice", + "displayName": "slice" + } + ], + "outputs": [ + { + "name": "geometry", + "displayName": "geometry" + } + ], + "evaluateContext": false + }, + { + "customType": "BABYLON.GeometryInputBlock", + "id": 3, + "name": "scaleFactor", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": 0.05, + "valueType": "number" + }, + { + "customType": "BABYLON.ScalingBlock", + "id": 4, + "name": "scale", + "inputs": [ + { + "name": "scale", + "displayName": "scale" + } + ], + "outputs": [ + { + "name": "matrix", + "displayName": "matrix" + } + ] + }, + { + "customType": "BABYLON.GeometryInputBlock", + "id": 5, + "name": "count", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 1, + "contextualValue": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": 200, + "valueType": "number" + }, + { + "customType": "BABYLON.InstantiateOnFacesBlock", + "id": 6, + "name": "scatter", + "inputs": [ + { + "name": "geometry", + "displayName": "geometry", + "inputName": "geometry", + "targetBlockId": 1, + "targetConnectionName": "geometry" + }, + { + "name": "instance", + "displayName": "instance", + "inputName": "instance", + "targetBlockId": 2, + "targetConnectionName": "geometry" + }, + { + "name": "count", + "displayName": "count", + "inputName": "count", + "targetBlockId": 5, + "targetConnectionName": "output" + }, + { + "name": "matrix", + "displayName": "matrix", + "inputName": "matrix", + "targetBlockId": 4, + "targetConnectionName": "matrix" + }, + { + "name": "offset", + "displayName": "offset" + }, + { + "name": "rotation", + "displayName": "rotation" + }, + { + "name": "scaling", + "displayName": "scaling" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "evaluateContext": true + }, + { + "customType": "BABYLON.GeometryOutputBlock", + "id": 7, + "name": "output", + "inputs": [ + { + "name": "geometry", + "displayName": "geometry", + "inputName": "geometry", + "targetBlockId": 6, + "targetConnectionName": "output" + } + ], + "outputs": [] + } + ], + "comment": "Scatter small spheres across a box's faces.", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 340, + "y": 0 + }, + { + "blockId": 2, + "x": 340, + "y": 180 + }, + { + "blockId": 3, + "x": 0, + "y": 0 + }, + { + "blockId": 4, + "x": 340, + "y": 360 + }, + { + "blockId": 5, + "x": 340, + "y": 540 + }, + { + "blockId": 6, + "x": 680, + "y": 0 + }, + { + "blockId": 7, + "x": 1020, + "y": 0 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nge-mcp-server/examples/SimpleBox.json b/packages/tools/nge-mcp-server/examples/SimpleBox.json new file mode 100644 index 00000000000..9eb9b67cdb5 --- /dev/null +++ b/packages/tools/nge-mcp-server/examples/SimpleBox.json @@ -0,0 +1,82 @@ +{ + "customType": "BABYLON.NodeGeometry", + "outputNodeId": 2, + "blocks": [ + { + "customType": "BABYLON.BoxBlock", + "id": 1, + "name": "box", + "inputs": [ + { + "name": "size", + "displayName": "size" + }, + { + "name": "width", + "displayName": "width" + }, + { + "name": "height", + "displayName": "height" + }, + { + "name": "depth", + "displayName": "depth" + }, + { + "name": "subdivisions", + "displayName": "subdivisions" + }, + { + "name": "subdivisionsX", + "displayName": "subdivisionsX" + }, + { + "name": "subdivisionsY", + "displayName": "subdivisionsY" + }, + { + "name": "subdivisionsZ", + "displayName": "subdivisionsZ" + } + ], + "outputs": [ + { + "name": "geometry", + "displayName": "geometry" + } + ], + "evaluateContext": false + }, + { + "customType": "BABYLON.GeometryOutputBlock", + "id": 2, + "name": "output", + "inputs": [ + { + "name": "geometry", + "displayName": "geometry", + "inputName": "geometry", + "targetBlockId": 1, + "targetConnectionName": "geometry" + } + ], + "outputs": [] + } + ], + "comment": "Minimal box geometry – one BoxBlock piped to the output.", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 0, + "y": 0 + }, + { + "blockId": 2, + "x": 340, + "y": 0 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nge-mcp-server/package.json b/packages/tools/nge-mcp-server/package.json new file mode 100644 index 00000000000..ce2e78b98b3 --- /dev/null +++ b/packages/tools/nge-mcp-server/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tools/nge-mcp-server", + "version": "1.0.0", + "private": true, + "description": "MCP server for AI-driven Node Geometry Editor 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", + "@tools/snippet-loader": "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/nge-mcp-server/rollup.config.mjs b/packages/tools/nge-mcp-server/rollup.config.mjs new file mode 100644 index 00000000000..b6a4d32812a --- /dev/null +++ b/packages/tools/nge-mcp-server/rollup.config.mjs @@ -0,0 +1,3 @@ +import { createConfig } from "../rollup.config.mcp.mjs"; + +export default createConfig(); diff --git a/packages/tools/nge-mcp-server/src/blockRegistry.ts b/packages/tools/nge-mcp-server/src/blockRegistry.ts new file mode 100644 index 00000000000..88a1f64cdaf --- /dev/null +++ b/packages/tools/nge-mcp-server/src/blockRegistry.ts @@ -0,0 +1,1297 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * Complete registry of all Node Geometry block types available in Babylon.js. + * Each entry describes the block's class name, category, target, and its inputs/outputs. + */ + +/** + * Describes a single input or output connection point on a block. + */ +export interface IConnectionPointInfo { + /** Name of the connection point (e.g. "geometry", "output") */ + name: string; + /** Data type of the connection point (e.g. "Float", "Vector3", "Geometry") */ + type: string; + /** Whether the connection is optional */ + isOptional?: boolean; +} + +/** + * Describes a block type in the NGE catalog. + */ +export interface IBlockTypeInfo { + /** The Babylon.js class name for this block */ + className: string; + /** Category for grouping (e.g. "Source", "Math", "Set", "Instance") */ + category: string; + /** Human-readable description of what this block does */ + description: string; + /** List of input connection points */ + inputs: IConnectionPointInfo[]; + /** List of output connection points */ + outputs: IConnectionPointInfo[]; + /** Extra properties that can be configured on the block */ + properties?: Record; + /** + * Default property values to bake into newly created blocks of this type. + * These are REQUIRED by the Babylon deserialiser – omitting them can cause + * build-time crashes. + */ + defaultSerializedProperties?: Record; +} + +/** + * Full catalog of block types. This is the canonical reference an AI agent uses + * to know which blocks exist and what ports they have. + */ +export const BlockRegistry: Record = { + // ═══════════════════════════════════════════════════════════════════════ + // Input + // ═══════════════════════════════════════════════════════════════════════ + GeometryInputBlock: { + className: "GeometryInputBlock", + category: "Input", + description: + "Provides input values to the geometry graph. Can be configured as a contextual source " + + "(Positions, Normals, UV, VertexID, etc.) or a constant value (Float, Int, Vector2, Vector3, Vector4, Matrix).", + inputs: [], + outputs: [{ name: "output", type: "AutoDetect" }], + properties: { + type: "NodeGeometryBlockConnectionPointTypes — the data type (Int, Float, Vector2, Vector3, Vector4, Matrix)", + contextualValue: + "NodeGeometryContextualSources — None, Positions, Normals, Tangents, UV, UV2, UV3, UV4, UV5, UV6, Colors, VertexID, FaceID, GeometryID, CollectionID, LoopID, InstanceID, LatticeID, LatticeControl", + value: "The actual constant value (number, Vector2, Vector3, Vector4, Matrix)", + min: "number — minimum value for inspector slider", + max: "number — maximum value for inspector slider", + groupInInspector: "string — group name in the inspector", + displayInInspector: "boolean — whether to show in Inspector", + }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Output + // ═══════════════════════════════════════════════════════════════════════ + GeometryOutputBlock: { + className: "GeometryOutputBlock", + category: "Output", + description: "The final output block that produces the geometry. Every Node Geometry graph must have exactly one.", + inputs: [{ name: "geometry", type: "Geometry" }], + outputs: [], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Sources (Geometry Primitives) + // ═══════════════════════════════════════════════════════════════════════ + BoxBlock: { + className: "BoxBlock", + category: "Source", + description: "Generates box geometry with configurable size and subdivisions.", + inputs: [ + { name: "size", type: "Float", isOptional: true }, + { name: "width", type: "Float", isOptional: true }, + { name: "height", type: "Float", isOptional: true }, + { name: "depth", type: "Float", isOptional: true }, + { name: "subdivisions", type: "Int", isOptional: true }, + { name: "subdivisionsX", type: "Int", isOptional: true }, + { name: "subdivisionsY", type: "Int", isOptional: true }, + { name: "subdivisionsZ", type: "Int", isOptional: true }, + ], + outputs: [{ name: "geometry", type: "Geometry" }], + properties: { + evaluateContext: "boolean — whether to re-evaluate inputs per context (default: false)", + }, + defaultSerializedProperties: { evaluateContext: false }, + }, + + SphereBlock: { + className: "SphereBlock", + category: "Source", + description: "Generates sphere geometry with configurable segments, diameter, arc, and slice.", + inputs: [ + { name: "segments", type: "Int", isOptional: true }, + { name: "diameter", type: "Float", isOptional: true }, + { name: "diameterX", type: "Float", isOptional: true }, + { name: "diameterY", type: "Float", isOptional: true }, + { name: "diameterZ", type: "Float", isOptional: true }, + { name: "arc", type: "Float", isOptional: true }, + { name: "slice", type: "Float", isOptional: true }, + ], + outputs: [{ name: "geometry", type: "Geometry" }], + properties: { + evaluateContext: "boolean — whether to re-evaluate inputs per context (default: false)", + }, + defaultSerializedProperties: { evaluateContext: false }, + }, + + CylinderBlock: { + className: "CylinderBlock", + category: "Source", + description: "Generates cylinder geometry with configurable height, diameter, subdivisions, tessellation, and arc.", + inputs: [ + { name: "height", type: "Float", isOptional: true }, + { name: "diameter", type: "Float", isOptional: true }, + { name: "diameterTop", type: "Float", isOptional: true }, + { name: "diameterBottom", type: "Float", isOptional: true }, + { name: "subdivisions", type: "Int", isOptional: true }, + { name: "tessellation", type: "Int", isOptional: true }, + { name: "arc", type: "Float", isOptional: true }, + ], + outputs: [{ name: "geometry", type: "Geometry" }], + properties: { + evaluateContext: "boolean — whether to re-evaluate inputs per context (default: false)", + }, + defaultSerializedProperties: { evaluateContext: false }, + }, + + PlaneBlock: { + className: "PlaneBlock", + category: "Source", + description: "Generates plane geometry with configurable size and subdivisions.", + inputs: [ + { name: "size", type: "Float", isOptional: true }, + { name: "width", type: "Float", isOptional: true }, + { name: "height", type: "Float", isOptional: true }, + { name: "subdivisions", type: "Int", isOptional: true }, + { name: "subdivisionsX", type: "Int", isOptional: true }, + { name: "subdivisionsY", type: "Int", isOptional: true }, + ], + outputs: [{ name: "geometry", type: "Geometry" }], + properties: { + evaluateContext: "boolean — whether to re-evaluate inputs per context (default: false)", + }, + defaultSerializedProperties: { evaluateContext: false }, + }, + + TorusBlock: { + className: "TorusBlock", + category: "Source", + description: "Generates torus geometry with configurable diameter, thickness, and tessellation.", + inputs: [ + { name: "diameter", type: "Float", isOptional: true }, + { name: "thickness", type: "Float", isOptional: true }, + { name: "tessellation", type: "Int", isOptional: true }, + ], + outputs: [{ name: "geometry", type: "Geometry" }], + properties: { + evaluateContext: "boolean — whether to re-evaluate inputs per context (default: false)", + }, + defaultSerializedProperties: { evaluateContext: false }, + }, + + DiscBlock: { + className: "DiscBlock", + category: "Source", + description: "Generates disc geometry with configurable radius, tessellation, and arc.", + inputs: [ + { name: "radius", type: "Float", isOptional: true }, + { name: "tessellation", type: "Int", isOptional: true }, + { name: "arc", type: "Float", isOptional: true }, + ], + outputs: [{ name: "geometry", type: "Geometry" }], + properties: { + evaluateContext: "boolean — whether to re-evaluate inputs per context (default: false)", + }, + defaultSerializedProperties: { evaluateContext: false }, + }, + + CapsuleBlock: { + className: "CapsuleBlock", + category: "Source", + description: "Generates capsule geometry with configurable height, radius, tessellation, and subdivisions.", + inputs: [ + { name: "height", type: "Float", isOptional: true }, + { name: "radius", type: "Float", isOptional: true }, + { name: "tessellation", type: "Int", isOptional: true }, + { name: "subdivisions", type: "Int", isOptional: true }, + ], + outputs: [{ name: "geometry", type: "Geometry" }], + properties: { + evaluateContext: "boolean — whether to re-evaluate inputs per context (default: false)", + }, + defaultSerializedProperties: { evaluateContext: false }, + }, + + IcoSphereBlock: { + className: "IcoSphereBlock", + category: "Source", + description: "Generates icosphere geometry with configurable radius and subdivisions.", + inputs: [ + { name: "radius", type: "Float", isOptional: true }, + { name: "radiusX", type: "Float", isOptional: true }, + { name: "radiusY", type: "Float", isOptional: true }, + { name: "radiusZ", type: "Float", isOptional: true }, + { name: "subdivisions", type: "Int", isOptional: true }, + ], + outputs: [{ name: "geometry", type: "Geometry" }], + properties: { + evaluateContext: "boolean — whether to re-evaluate inputs per context (default: false)", + }, + defaultSerializedProperties: { evaluateContext: false }, + }, + + GridBlock: { + className: "GridBlock", + category: "Source", + description: "Generates a flat grid geometry with configurable width, height, and subdivisions.", + inputs: [ + { name: "width", type: "Float", isOptional: true }, + { name: "height", type: "Float", isOptional: true }, + { name: "subdivisions", type: "Int", isOptional: true }, + { name: "subdivisionsX", type: "Int", isOptional: true }, + { name: "subdivisionsY", type: "Int", isOptional: true }, + ], + outputs: [{ name: "geometry", type: "Geometry" }], + properties: { + evaluateContext: "boolean — whether to re-evaluate inputs per context (default: false)", + }, + defaultSerializedProperties: { evaluateContext: false }, + }, + + NullBlock: { + className: "NullBlock", + category: "Source", + description: "Outputs a null (empty) geometry and a zero vector. Useful as a placeholder.", + inputs: [], + outputs: [ + { name: "geometry", type: "Geometry" }, + { name: "vector", type: "Vector3" }, + ], + }, + + MeshBlock: { + className: "MeshBlock", + category: "Source", + description: "Provides geometry from an existing mesh. The mesh is set programmatically or via cached data.", + inputs: [], + outputs: [{ name: "geometry", type: "Geometry" }], + properties: { + serializedCachedData: "boolean — whether cached mesh data is embedded (default: false)", + reverseWindingOrder: "boolean — whether to reverse face winding (default: false)", + }, + defaultSerializedProperties: { serializedCachedData: false, reverseWindingOrder: false }, + }, + + PointListBlock: { + className: "PointListBlock", + category: "Source", + description: "Generates geometry from an explicit list of points.", + inputs: [], + outputs: [{ name: "geometry", type: "Geometry" }], + properties: { + points: "Vector3[] — array of 3D points, serialised as flat arrays", + }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Geometry Operations + // ═══════════════════════════════════════════════════════════════════════ + GeometryTransformBlock: { + className: "GeometryTransformBlock", + category: "Geometry", + description: + "Transforms geometry or a vector using a matrix, or individual translation/rotation/scaling vectors. " + + "When used per-vertex (evaluateContext=true), it transforms each vertex position.", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "matrix", type: "Matrix", isOptional: true }, + { name: "translation", type: "Vector3", isOptional: true }, + { name: "rotation", type: "Vector3", isOptional: true }, + { name: "scaling", type: "Vector3", isOptional: true }, + { name: "pivot", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + evaluateContext: "boolean — re-evaluate per context/vertex (default: true)", + }, + defaultSerializedProperties: { evaluateContext: true }, + }, + + MergeGeometryBlock: { + className: "MergeGeometryBlock", + category: "Geometry", + description: "Merges up to 5 geometry inputs into a single geometry.", + inputs: [ + { name: "geometry0", type: "Geometry" }, + { name: "geometry1", type: "Geometry", isOptional: true }, + { name: "geometry2", type: "Geometry", isOptional: true }, + { name: "geometry3", type: "Geometry", isOptional: true }, + { name: "geometry4", type: "Geometry", isOptional: true }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: false)", + }, + defaultSerializedProperties: { evaluateContext: false }, + }, + + BooleanGeometryBlock: { + className: "BooleanGeometryBlock", + category: "Geometry", + description: "Performs CSG boolean operations (Intersect, Subtract, Union) between two geometries.", + inputs: [ + { name: "geometry0", type: "Geometry" }, + { name: "geometry1", type: "Geometry" }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + operation: "BooleanGeometryOperations — Intersect (0), Subtract (1), Union (2). Default: Intersect", + evaluateContext: "boolean (default: false)", + useOldCSGEngine: "boolean — use legacy CSG engine (default: false)", + }, + defaultSerializedProperties: { evaluateContext: false, operation: 0, useOldCSGEngine: false }, + }, + + ComputeNormalsBlock: { + className: "ComputeNormalsBlock", + category: "Geometry", + description: "Recomputes normals for a geometry based on its face topology.", + inputs: [{ name: "geometry", type: "Geometry" }], + outputs: [{ name: "output", type: "Geometry" }], + }, + + CleanGeometryBlock: { + className: "CleanGeometryBlock", + category: "Geometry", + description: "Cleans geometry by removing degenerate triangles, duplicates, etc.", + inputs: [{ name: "geometry", type: "Geometry" }], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + }, + defaultSerializedProperties: { evaluateContext: true }, + }, + + ExtrudeGeometryBlock: { + className: "ExtrudeGeometryBlock", + category: "Geometry", + description: "Extrudes a geometry along its average face normal by a given depth. Supports configurable cap modes (no cap, start, end, or both).", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "depth", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean — re-evaluate per context/vertex (default: false)", + cap: "ExtrudeGeometryCap — NoCap (0), CapStart (1), CapEnd (2), CapAll (3). Default: CapAll (3)", + }, + defaultSerializedProperties: { evaluateContext: false, cap: 3 }, + }, + + BevelBlock: { + className: "BevelBlock", + category: "Geometry", + description: "Bevels geometry edges by a configurable amount, segment count, and angle threshold.", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "amount", type: "Float", isOptional: true }, + { name: "segments", type: "Int", isOptional: true }, + { name: "angle", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean — whether to re-evaluate inputs per context (default: false)", + }, + defaultSerializedProperties: { evaluateContext: false }, + }, + + SubdivideBlock: { + className: "SubdivideBlock", + category: "Geometry", + description: "Subdivides geometry faces. Supports flat and Loop subdivision.", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "level", type: "Int", isOptional: true }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + flatOnly: "boolean — flat subdivision only (default: false)", + loopWeight: "number — Loop subdivision weight 0-1 (default: 1.0)", + }, + defaultSerializedProperties: { flatOnly: false, loopWeight: 1.0 }, + }, + + GeometryOptimizeBlock: { + className: "GeometryOptimizeBlock", + category: "Geometry", + description: "Optimizes geometry by merging close vertices and optionally removing duplicate faces.", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "selector", type: "Int", isOptional: true }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + epsilon: "number — merge distance threshold", + optimizeFaces: "boolean — also optimize faces (default: false)", + }, + defaultSerializedProperties: { evaluateContext: true, optimizeFaces: false }, + }, + + BoundingBlock: { + className: "BoundingBlock", + category: "Geometry", + description: "Computes the axis-aligned bounding box of a geometry, outputting min and max vectors.", + inputs: [{ name: "geometry", type: "Geometry" }], + outputs: [ + { name: "min", type: "Vector3" }, + { name: "max", type: "Vector3" }, + ], + }, + + GeometryInfoBlock: { + className: "GeometryInfoBlock", + category: "Geometry", + description: "Provides metadata about a geometry: vertex count, face count, geometry ID, collection ID.", + inputs: [{ name: "geometry", type: "Geometry" }], + outputs: [ + { name: "output", type: "Geometry" }, + { name: "id", type: "Int" }, + { name: "collectionId", type: "Int" }, + { name: "verticesCount", type: "Int" }, + { name: "facesCount", type: "Int" }, + ], + }, + + GeometryCollectionBlock: { + className: "GeometryCollectionBlock", + category: "Geometry", + description: "Collects up to 10 geometries into a collection, assigning each a unique collection ID.", + inputs: [ + { name: "geometry0", type: "Geometry", isOptional: true }, + { name: "geometry1", type: "Geometry", isOptional: true }, + { name: "geometry2", type: "Geometry", isOptional: true }, + { name: "geometry3", type: "Geometry", isOptional: true }, + { name: "geometry4", type: "Geometry", isOptional: true }, + { name: "geometry5", type: "Geometry", isOptional: true }, + { name: "geometry6", type: "Geometry", isOptional: true }, + { name: "geometry7", type: "Geometry", isOptional: true }, + { name: "geometry8", type: "Geometry", isOptional: true }, + { name: "geometry9", type: "Geometry", isOptional: true }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + }, + defaultSerializedProperties: { evaluateContext: true }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Set Blocks (attribute modification) + // ═══════════════════════════════════════════════════════════════════════ + SetPositionsBlock: { + className: "SetPositionsBlock", + category: "Set", + description: "Overrides vertex positions in a geometry. Runs per-vertex with contextual source access.", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "positions", type: "Vector3" }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + }, + defaultSerializedProperties: { evaluateContext: true }, + }, + + SetNormalsBlock: { + className: "SetNormalsBlock", + category: "Set", + description: "Overrides vertex normals in a geometry.", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "normals", type: "Vector3" }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + }, + defaultSerializedProperties: { evaluateContext: true }, + }, + + SetColorsBlock: { + className: "SetColorsBlock", + category: "Set", + description: "Overrides vertex colors in a geometry. Accepts Vector3 (RGB) or Vector4 (RGBA).", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "colors", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + }, + defaultSerializedProperties: { evaluateContext: true }, + }, + + SetUVsBlock: { + className: "SetUVsBlock", + category: "Set", + description: "Overrides UV coordinates in a geometry. Can target UV1–UV6.", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "uvs", type: "Vector2" }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + textureCoordinateIndex: "number — UV channel 0–5 (UV1–UV6). Default: 0", + }, + defaultSerializedProperties: { evaluateContext: true, textureCoordinateIndex: 0 }, + }, + + SetTangentsBlock: { + className: "SetTangentsBlock", + category: "Set", + description: "Overrides vertex tangents in a geometry.", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "tangents", type: "Vector4" }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + }, + defaultSerializedProperties: { evaluateContext: true }, + }, + + SetMaterialIDBlock: { + className: "SetMaterialIDBlock", + category: "Set", + description: "Assigns a material ID to each face in a geometry. Used for multi-material support.", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "id", type: "Int", isOptional: true }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + }, + defaultSerializedProperties: { evaluateContext: true }, + }, + + AggregatorBlock: { + className: "AggregatorBlock", + category: "Set", + description: "Aggregates values across geometry vertices using Max, Min, or Sum operations.", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "source", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + aggregation: "Aggregations — Max (0), Min (1), Sum (2). Default: Sum", + evaluateContext: "boolean (default: true)", + }, + defaultSerializedProperties: { evaluateContext: true, aggregation: 2 }, + }, + + LatticeBlock: { + className: "LatticeBlock", + category: "Set", + description: "Applies a lattice deformation to geometry. Control points modify vertex positions.", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "controls", type: "Vector3" }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + resolutionX: "number — lattice resolution X (default: 3, range: 1–10)", + resolutionY: "number — lattice resolution Y (default: 3, range: 1–10)", + resolutionZ: "number — lattice resolution Z (default: 3, range: 1–10)", + }, + defaultSerializedProperties: { evaluateContext: true, resolutionX: 3, resolutionY: 3, resolutionZ: 3 }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Instancing + // ═══════════════════════════════════════════════════════════════════════ + // InstantiateBaseBlock is abstract; expose only the concrete instancing blocks. + InstantiateBlock: { + className: "InstantiateBlock", + category: "Instance", + description: "Creates instances of geometry at specified positions with custom transform per instance.", + inputs: [ + { name: "instance", type: "Geometry", isOptional: true }, + { name: "count", type: "Int", isOptional: true }, + { name: "matrix", type: "Matrix", isOptional: true }, + { name: "position", type: "Vector3", isOptional: true }, + { name: "rotation", type: "Vector3", isOptional: true }, + { name: "scaling", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + }, + defaultSerializedProperties: { evaluateContext: true }, + }, + + InstantiateLinearBlock: { + className: "InstantiateLinearBlock", + category: "Instance", + description: "Creates instances arranged in a line along a direction vector.", + inputs: [ + { name: "instance", type: "Geometry", isOptional: true }, + { name: "count", type: "Int", isOptional: true }, + { name: "direction", type: "Vector3", isOptional: true }, + { name: "rotation", type: "Vector3", isOptional: true }, + { name: "scaling", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + }, + defaultSerializedProperties: { evaluateContext: true }, + }, + + InstantiateRadialBlock: { + className: "InstantiateRadialBlock", + category: "Instance", + description: "Creates instances arranged radially in a circle or arc.", + inputs: [ + { name: "instance", type: "Geometry", isOptional: true }, + { name: "count", type: "Int", isOptional: true }, + { name: "radius", type: "Int", isOptional: true }, + { name: "angleStart", type: "Float", isOptional: true }, + { name: "angleEnd", type: "Float", isOptional: true }, + { name: "transform", type: "Vector3", isOptional: true }, + { name: "rotation", type: "Vector3", isOptional: true }, + { name: "scaling", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + }, + defaultSerializedProperties: { evaluateContext: true }, + }, + + InstantiateOnFacesBlock: { + className: "InstantiateOnFacesBlock", + category: "Instance", + description: "Scatters instances randomly across the faces of a source geometry.", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "instance", type: "Geometry", isOptional: true }, + { name: "count", type: "Int", isOptional: true }, + { name: "matrix", type: "Matrix", isOptional: true }, + { name: "offset", type: "Vector3", isOptional: true }, + { name: "rotation", type: "Vector3", isOptional: true }, + { name: "scaling", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + }, + defaultSerializedProperties: { evaluateContext: true }, + }, + + InstantiateOnVerticesBlock: { + className: "InstantiateOnVerticesBlock", + category: "Instance", + description: "Places instances on the vertices of a source geometry with optional density filtering.", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "instance", type: "Geometry", isOptional: true }, + { name: "density", type: "Float", isOptional: true }, + { name: "matrix", type: "Matrix", isOptional: true }, + { name: "offset", type: "Vector3", isOptional: true }, + { name: "rotation", type: "Vector3", isOptional: true }, + { name: "scaling", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + removeDuplicatedPositions: "boolean — deduplicate vertices before instancing (default: true)", + }, + defaultSerializedProperties: { evaluateContext: true, removeDuplicatedPositions: true }, + }, + + InstantiateOnVolumeBlock: { + className: "InstantiateOnVolumeBlock", + category: "Instance", + description: "Scatters instances inside the volume of a source geometry.", + inputs: [ + { name: "geometry", type: "Geometry" }, + { name: "instance", type: "Geometry", isOptional: true }, + { name: "count", type: "Int", isOptional: true }, + { name: "matrix", type: "Matrix", isOptional: true }, + { name: "offset", type: "Vector3", isOptional: true }, + { name: "rotation", type: "Vector3", isOptional: true }, + { name: "scaling", type: "Vector3", isOptional: true }, + { name: "gridSize", type: "Int", isOptional: true }, + ], + outputs: [{ name: "output", type: "Geometry" }], + properties: { + evaluateContext: "boolean (default: true)", + gridMode: "boolean — use grid-based placement (default: false)", + }, + defaultSerializedProperties: { evaluateContext: true, gridMode: false }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Matrices + // ═══════════════════════════════════════════════════════════════════════ + TranslationBlock: { + className: "TranslationBlock", + category: "Matrix", + description: "Creates a translation matrix from a Vector3 input.", + inputs: [{ name: "translation", type: "Vector3" }], + outputs: [{ name: "matrix", type: "Matrix" }], + }, + + ScalingBlock: { + className: "ScalingBlock", + category: "Matrix", + description: "Creates a scaling matrix from a Vector3 input.", + inputs: [{ name: "scale", type: "Vector3" }], + outputs: [{ name: "matrix", type: "Matrix" }], + }, + + RotationXBlock: { + className: "RotationXBlock", + category: "Matrix", + description: "Creates a rotation matrix around the X axis from an angle (in radians).", + inputs: [{ name: "angle", type: "Float", isOptional: true }], + outputs: [{ name: "matrix", type: "Matrix" }], + }, + + RotationYBlock: { + className: "RotationYBlock", + category: "Matrix", + description: "Creates a rotation matrix around the Y axis from an angle (in radians).", + inputs: [{ name: "angle", type: "Float", isOptional: true }], + outputs: [{ name: "matrix", type: "Matrix" }], + }, + + RotationZBlock: { + className: "RotationZBlock", + category: "Matrix", + description: "Creates a rotation matrix around the Z axis from an angle (in radians).", + inputs: [{ name: "angle", type: "Float", isOptional: true }], + outputs: [{ name: "matrix", type: "Matrix" }], + }, + + AlignBlock: { + className: "AlignBlock", + category: "Matrix", + description: "Creates a rotation matrix that aligns a source direction to a target direction.", + inputs: [ + { name: "source", type: "Vector3", isOptional: true }, + { name: "target", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "matrix", type: "Matrix" }], + }, + + MatrixComposeBlock: { + className: "MatrixComposeBlock", + category: "Matrix", + description: "Multiplies two matrices together (matrix0 × matrix1).", + inputs: [ + { name: "matrix0", type: "Matrix" }, + { name: "matrix1", type: "Matrix" }, + ], + outputs: [{ name: "output", type: "Matrix" }], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Math + // ═══════════════════════════════════════════════════════════════════════ + MathBlock: { + className: "MathBlock", + category: "Math", + description: "Performs basic math: Add, Subtract, Multiply, Divide, Max, Min. Works on scalars and vectors.", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + operation: "MathBlockOperations — Add (0), Subtract (1), Multiply (2), Divide (3), Max (4), Min (5). Default: Add", + }, + defaultSerializedProperties: { operation: 0 }, + }, + + GeometryTrigonometryBlock: { + className: "GeometryTrigonometryBlock", + category: "Math", + description: + "Applies a trigonometric or unary math function: Cos, Sin, Abs, Exp, Round, Floor, Ceiling, Sqrt, Log, " + + "Tan, ArcTan, ArcCos, ArcSin, Sign, Negate, OneMinus, Reciprocal, ToDegrees, ToRadians, Fract, Exp2.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + operation: + "GeometryTrigonometryBlockOperations — Cos (0), Sin (1), Abs (2), Exp (3), Round (4), Floor (5), " + + "Ceiling (6), Sqrt (7), Log (8), Tan (9), ArcTan (10), ArcCos (11), ArcSin (12), Sign (13), " + + "Negate (14), OneMinus (15), Reciprocal (16), ToDegrees (17), ToRadians (18), Fract (19), Exp2 (20). Default: Cos", + }, + defaultSerializedProperties: { operation: 0 }, + }, + + ConditionBlock: { + className: "ConditionBlock", + category: "Math", + description: "Conditional block: if left right then ifTrue else ifFalse.", + inputs: [ + { name: "left", type: "Float" }, + { name: "right", type: "Float", isOptional: true }, + { name: "ifTrue", type: "AutoDetect", isOptional: true }, + { name: "ifFalse", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + test: "ConditionBlockTests — Equal (0), NotEqual (1), LessThan (2), GreaterThan (3), LessOrEqual (4), GreaterOrEqual (5), Xor (6), Or (7), And (8). Default: Equal", + epsilon: "number — comparison epsilon (default: 0)", + }, + defaultSerializedProperties: { test: 0, epsilon: 0 }, + }, + + RandomBlock: { + className: "RandomBlock", + category: "Math", + description: "Generates random values between min and max. Supports scalars and vectors.", + inputs: [ + { name: "min", type: "AutoDetect" }, + { name: "max", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + lockMode: "RandomBlockLocks — None (0), LoopID (1), InstanceID (2), Once (3). Default: None", + }, + defaultSerializedProperties: { lockMode: 0 }, + }, + + NoiseBlock: { + className: "NoiseBlock", + category: "Math", + description: "Generates Perlin-like noise with configurable offset, scale, octaves, and roughness.", + inputs: [ + { name: "offset", type: "Vector3", isOptional: true }, + { name: "scale", type: "Float", isOptional: true }, + { name: "octaves", type: "Float", isOptional: true }, + { name: "roughness", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "Float" }], + }, + + GeometryClampBlock: { + className: "GeometryClampBlock", + category: "Math", + description: "Clamps a value between min and max bounds.", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "min", type: "Float", isOptional: true }, + { name: "max", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + GeometryLerpBlock: { + className: "GeometryLerpBlock", + category: "Math", + description: "Linear interpolation between left and right using a gradient (0–1).", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + { name: "gradient", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + GeometryNLerpBlock: { + className: "GeometryNLerpBlock", + category: "Math", + description: "Normalised linear interpolation — same as lerp but normalizes the result (useful for vectors).", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + { name: "gradient", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + GeometrySmoothStepBlock: { + className: "GeometrySmoothStepBlock", + category: "Math", + description: "Hermite smooth interpolation between edge0 and edge1.", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "edge0", type: "Float", isOptional: true }, + { name: "edge1", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + GeometryStepBlock: { + className: "GeometryStepBlock", + category: "Math", + description: "Step function: returns 0 if value < edge, else 1.", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "edge", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Vector Math + // ═══════════════════════════════════════════════════════════════════════ + GeometryDotBlock: { + className: "GeometryDotBlock", + category: "Vector", + description: "Computes the dot product of two vectors.", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "Float" }], + }, + + GeometryCrossBlock: { + className: "GeometryCrossBlock", + category: "Vector", + description: "Computes the cross product of two Vector3s.", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "Vector3" }], + }, + + GeometryLengthBlock: { + className: "GeometryLengthBlock", + category: "Vector", + description: "Computes the length (magnitude) of a vector.", + inputs: [{ name: "value", type: "AutoDetect" }], + outputs: [{ name: "output", type: "Float" }], + }, + + GeometryDistanceBlock: { + className: "GeometryDistanceBlock", + category: "Vector", + description: "Computes the distance between two vectors.", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "Float" }], + }, + + NormalizeVectorBlock: { + className: "NormalizeVectorBlock", + category: "Vector", + description: "Normalizes a vector to unit length.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + GeometryModBlock: { + className: "GeometryModBlock", + category: "Math", + description: "Modulo operation (left % right).", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + GeometryPowBlock: { + className: "GeometryPowBlock", + category: "Math", + description: "Power function (value ^ power).", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "power", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + GeometryArcTan2Block: { + className: "GeometryArcTan2Block", + category: "Math", + description: "Computes atan2(x, y) — the two-argument arctangent.", + inputs: [ + { name: "x", type: "AutoDetect" }, + { name: "y", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Color Operations + // ═══════════════════════════════════════════════════════════════════════ + GeometryReplaceColorBlock: { + className: "GeometryReplaceColorBlock", + category: "Color", + description: "Replaces values near a reference color with a replacement color.", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "reference", type: "AutoDetect" }, + { name: "distance", type: "Float", isOptional: true }, + { name: "replacement", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + GeometryPosterizeBlock: { + className: "GeometryPosterizeBlock", + category: "Color", + description: "Reduces color to a set number of discrete steps (posterization effect).", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "steps", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + GeometryDesaturateBlock: { + className: "GeometryDesaturateBlock", + category: "Color", + description: "Desaturates a color by mixing it towards its luminance value.", + inputs: [ + { name: "color", type: "Vector3" }, + { name: "level", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "Vector3" }], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // UV / Mapping + // ═══════════════════════════════════════════════════════════════════════ + MappingBlock: { + className: "MappingBlock", + category: "Mapping", + description: "Generates UV coordinates from position and normal using Spherical, Cylindrical, or Cubic projection.", + inputs: [ + { name: "position", type: "Vector3" }, + { name: "normal", type: "Vector3" }, + { name: "center", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "uv", type: "Vector2" }], + properties: { + mapping: "MappingTypes — Spherical (0), Cylindrical (1), Cubic (2). Default: Spherical", + }, + defaultSerializedProperties: { mapping: 0 }, + }, + + MapRangeBlock: { + className: "MapRangeBlock", + category: "Math", + description: "Remaps a value from one range [fromMin, fromMax] to another [toMin, toMax].", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "fromMin", type: "Float", isOptional: true }, + { name: "fromMax", type: "Float", isOptional: true }, + { name: "toMin", type: "Float", isOptional: true }, + { name: "toMax", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + GeometryRotate2dBlock: { + className: "GeometryRotate2dBlock", + category: "Math", + description: "Rotates a Vector2 by an angle (in radians).", + inputs: [ + { name: "input", type: "Vector2" }, + { name: "angle", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "Vector2" }], + }, + + GeometryEaseBlock: { + className: "GeometryEaseBlock", + category: "Math", + description: "Applies an easing function to the input. Supports Sine, Quad, Cubic, Quart, Quint, Expo, Circ, Back, Elastic variants (In/Out/InOut).", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + type: + "GeometryEaseBlockTypes — EaseInSine (0), EaseOutSine (1), EaseInOutSine (2), EaseInQuad (3), EaseOutQuad (4), " + + "EaseInOutQuad (5), EaseInCubic (6), EaseOutCubic (7), EaseInOutCubic (8), EaseInQuart (9), EaseOutQuart (10), " + + "EaseInOutQuart (11), EaseInQuint (12), EaseOutQuint (13), EaseInOutQuint (14), EaseInExpo (15), EaseOutExpo (16), " + + "EaseInOutExpo (17), EaseInCirc (18), EaseOutCirc (19), EaseInOutCirc (20), EaseInBack (21), EaseOutBack (22), " + + "EaseInOutBack (23), EaseInElastic (24), EaseOutElastic (25), EaseInOutElastic (26). Default: EaseInOutSine", + }, + defaultSerializedProperties: { type: 2 }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Converters / Utility + // ═══════════════════════════════════════════════════════════════════════ + VectorConverterBlock: { + className: "VectorConverterBlock", + category: "Converter", + description: "Splits vectors into components and/or assembles components into vectors.", + inputs: [ + { name: "xyzw", type: "Vector4", isOptional: true }, + { name: "xyz ", type: "Vector3", isOptional: true }, + { name: "xy ", type: "Vector2", isOptional: true }, + { name: "zw ", type: "Vector2", isOptional: true }, + { name: "x ", type: "Float", isOptional: true }, + { name: "y ", type: "Float", isOptional: true }, + { name: "z ", type: "Float", isOptional: true }, + { name: "w ", type: "Float", isOptional: true }, + ], + outputs: [ + { name: "xyzw", type: "Vector4" }, + { name: "xyz", type: "Vector3" }, + { name: "xy", type: "Vector2" }, + { name: "zw", type: "Vector2" }, + { name: "x", type: "Float" }, + { name: "y", type: "Float" }, + { name: "z", type: "Float" }, + { name: "w", type: "Float" }, + ], + }, + + IntFloatConverterBlock: { + className: "IntFloatConverterBlock", + category: "Converter", + description: "Converts between Int and Float types.", + inputs: [ + { name: "float ", type: "Float", isOptional: true }, + { name: "int ", type: "Int", isOptional: true }, + ], + outputs: [ + { name: "float", type: "Float" }, + { name: "int", type: "Int" }, + ], + }, + + DebugBlock: { + className: "DebugBlock", + category: "Utility", + description: "Pass-through block that logs values during graph evaluation. Useful for debugging.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + GeometryElbowBlock: { + className: "GeometryElbowBlock", + category: "Utility", + description: "Simple pass-through (reroute) block. Useful for organizing graph layout.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + GeometryInterceptorBlock: { + className: "GeometryInterceptorBlock", + category: "Utility", + description: "Pass-through that triggers an observable when traversed. Useful for runtime interception.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Teleport + // ═══════════════════════════════════════════════════════════════════════ + TeleportInBlock: { + className: "TeleportInBlock", + category: "Teleport", + description: "Teleport entry — sends data to linked TeleportOutBlock endpoints without visible wires.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [], + }, + + TeleportOutBlock: { + className: "TeleportOutBlock", + category: "Teleport", + description: "Teleport exit — receives data from a linked TeleportInBlock.", + inputs: [], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + entryPoint: "number — block ID of the linked TeleportInBlock", + }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Textures + // ═══════════════════════════════════════════════════════════════════════ + GeometryTextureBlock: { + className: "GeometryTextureBlock", + category: "Texture", + description: "Provides texture data for use with GeometryTextureFetchBlock. The texture is loaded programmatically.", + inputs: [], + outputs: [{ name: "texture", type: "Texture" }], + properties: { + serializedCachedData: "boolean — whether to embed cached pixel data (default: false)", + }, + defaultSerializedProperties: { serializedCachedData: false }, + }, + + GeometryCurveBlock: { + className: "GeometryCurveBlock", + category: "Math", + description: "Applies an easing/curve function to the input. Same curves as GeometryEaseBlock but serialised as curveType.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + curveType: + "GeometryCurveBlockTypes — EaseInSine (0), EaseOutSine (1), EaseInOutSine (2), EaseInQuad (3), EaseOutQuad (4), " + + "EaseInOutQuad (5), EaseInCubic (6), EaseOutCubic (7), EaseInOutCubic (8), EaseInQuart (9), EaseOutQuart (10), " + + "EaseInOutQuart (11), EaseInQuint (12), EaseOutQuint (13), EaseInOutQuint (14), EaseInExpo (15), EaseOutExpo (16), " + + "EaseInOutExpo (17), EaseInCirc (18), EaseOutCirc (19), EaseInOutCirc (20), EaseInBack (21), EaseOutBack (22), " + + "EaseInOutBack (23), EaseInElastic (24), EaseOutElastic (25), EaseInOutElastic (26). Default: EaseInOutSine", + }, + defaultSerializedProperties: { curveType: 2 }, + }, + + GeometryTextureFetchBlock: { + className: "GeometryTextureFetchBlock", + category: "Texture", + description: "Samples a texture at given UV coordinates, outputting RGBA components.", + inputs: [ + { name: "texture", type: "Texture" }, + { name: "coordinates", type: "Vector2" }, + ], + outputs: [ + { name: "rgba", type: "Vector4" }, + { name: "rgb", type: "Vector3" }, + { name: "r", type: "Float" }, + { name: "g", type: "Float" }, + { name: "b", type: "Float" }, + { name: "a", type: "Float" }, + ], + properties: { + clampCoordinates: "boolean — clamp UV to 0-1 range (default: true)", + interpolation: "boolean — bilinear interpolation (default: true)", + }, + defaultSerializedProperties: { clampCoordinates: true, interpolation: true }, + }, +}; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/** + * Return a Markdown-formatted summary of the block catalog, grouped by category. + * @returns Markdown string summarizing available block types and their descriptions. + */ +export function GetBlockCatalogSummary(): string { + const categories = new Map(); + for (const [key, info] of Object.entries(BlockRegistry)) { + const cat = info.category; + if (!categories.has(cat)) { + categories.set(cat, []); + } + categories.get(cat)!.push(` ${key}: ${info.description.split(".")[0]}`); + } + + const lines: string[] = []; + for (const [cat, entries] of categories) { + lines.push(`## ${cat}`); + lines.push(...entries); + lines.push(""); + } + return lines.join("\n"); +} + +/** + * Return the full info for a specific block type. + * @param blockType The block type name (e.g., "SetPositionsBlock"). + * @returns The IBlockTypeInfo for the specified block type, or undefined if not found. + */ +export function GetBlockTypeDetails(blockType: string): IBlockTypeInfo | undefined { + return BlockRegistry[blockType]; +} diff --git a/packages/tools/nge-mcp-server/src/geometryGraph.ts b/packages/tools/nge-mcp-server/src/geometryGraph.ts new file mode 100644 index 00000000000..ba220cca288 --- /dev/null +++ b/packages/tools/nge-mcp-server/src/geometryGraph.ts @@ -0,0 +1,1150 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * GeometryGraphManager – holds an in-memory representation of a Node Geometry + * graph that the MCP tools build up incrementally. When the user is satisfied, + * the graph can be serialised to the NGE JSON format that Babylon.js understands. + * + * Design goals + * ──────────── + * 1. **No Babylon.js runtime dependency** – the MCP server must remain a light, + * standalone process. We therefore work purely with a JSON data model that + * mirrors the serialisation format NodeGeometry.serialize() produces. + * 2. **Idempotent & stateful** – the manager stores the current graph in memory + * so an AI agent can add blocks, connect them, tweak properties, and finally + * export. Multiple geometry graphs can coexist (keyed by name). + */ + +import { ValidateNodeGeometryAttachmentPayload } from "@tools/mcp-server-core"; + +import { BlockRegistry, type IBlockTypeInfo } from "./blockRegistry.js"; + +// ─── Types ──────────────────────────────────────────────────────────────── + +/** + * Serialized form of a single connection point (input or output) on a block. + */ +export interface ISerializedConnectionPoint { + /** Name of the connection point */ + name: string; + /** Display name shown in the NGE editor */ + displayName?: string; + /** The input-side name this feeds into on the target block */ + inputName?: string; + /** ID of the block this connection links to */ + targetBlockId?: number; + /** Name of the output on the linked block */ + targetConnectionName?: string; + /** Whether exposed on the NGE editor frame */ + isExposedOnFrame?: boolean; + /** Position when exposed on frame */ + exposedPortPosition?: number; +} + +/** + * Serialized form of a single Node Geometry block. + */ +export interface ISerializedBlock { + /** The Babylon.js class identifier (e.g. "BABYLON.BoxBlock") */ + customType: string; + /** Unique block identifier within the geometry */ + id: number; + /** Human-friendly block name */ + name: string; + /** Input connection points */ + inputs: ISerializedConnectionPoint[]; + /** Output connection points */ + outputs: ISerializedConnectionPoint[]; + /** Block-specific values (e.g. evaluateContext, operation, type, value …) */ + [key: string]: unknown; +} + +/** + * Serialized form of a complete Node Geometry. + */ +export interface ISerializedGeometry { + /** Mark the format */ + customType: string; + /** Block ID of the single output node (GeometryOutputBlock) */ + outputNodeId: number; + /** All blocks in the geometry graph */ + blocks: ISerializedBlock[]; + /** NGE editor layout data */ + editorData?: { + /** Block positions in the editor */ + locations: Array<{ /** Block ID */ blockId: number; /** X coordinate */ x: number; /** Y coordinate */ y: number }>; + }; + /** Optional comment / description */ + comment?: string; +} + +// ─── Enum mappings ──────────────────────────────────────────────────────── + +/** Mapping from human-readable type names to NodeGeometryBlockConnectionPointTypes enum values */ +export const ConnectionPointTypes: Record = { + Int: 0x0001, + Float: 0x0002, + Vector2: 0x0004, + Vector3: 0x0008, + Vector4: 0x0010, + Matrix: 0x0020, + Geometry: 0x0040, + Texture: 0x0080, + AutoDetect: 0x0400, + BasedOnInput: 0x0800, + Undefined: 0x1000, +}; + +/** Mapping from human-readable contextual source names to NodeGeometryContextualSources enum values */ +export const ContextualSources: Record = { + None: 0, + Positions: 1, + Normals: 2, + Tangents: 3, + UV: 4, + UV2: 5, + UV3: 6, + UV4: 7, + UV5: 8, + UV6: 9, + Colors: 10, + VertexID: 11, + FaceID: 12, + GeometryID: 13, + CollectionID: 14, + LoopID: 15, + InstanceID: 16, + LatticeID: 17, + LatticeControl: 18, +}; + +/** + * Auto-derive the connection point type from a contextual source. + * When `contextualValue` is set, the input block's output type is determined by the source. + */ +const ContextualSourceToType: Record = { + [ContextualSources.None]: ConnectionPointTypes.AutoDetect, + [ContextualSources.Positions]: ConnectionPointTypes.Vector3, + [ContextualSources.Normals]: ConnectionPointTypes.Vector3, + [ContextualSources.Tangents]: ConnectionPointTypes.Vector3, + [ContextualSources.UV]: ConnectionPointTypes.Vector2, + [ContextualSources.UV2]: ConnectionPointTypes.Vector2, + [ContextualSources.UV3]: ConnectionPointTypes.Vector2, + [ContextualSources.UV4]: ConnectionPointTypes.Vector2, + [ContextualSources.UV5]: ConnectionPointTypes.Vector2, + [ContextualSources.UV6]: ConnectionPointTypes.Vector2, + [ContextualSources.Colors]: ConnectionPointTypes.Vector4, + [ContextualSources.VertexID]: ConnectionPointTypes.Int, + [ContextualSources.FaceID]: ConnectionPointTypes.Int, + [ContextualSources.GeometryID]: ConnectionPointTypes.Int, + [ContextualSources.CollectionID]: ConnectionPointTypes.Int, + [ContextualSources.LoopID]: ConnectionPointTypes.Int, + [ContextualSources.InstanceID]: ConnectionPointTypes.Int, + [ContextualSources.LatticeID]: ConnectionPointTypes.Int, + [ContextualSources.LatticeControl]: ConnectionPointTypes.Vector3, +}; + +// ─── Block Enum Properties ──────────────────────────────────────────────── + +/** MathBlockOperations */ +const MathBlockOperations: Record = { + Add: 0, + Subtract: 1, + Multiply: 2, + Divide: 3, + Max: 4, + Min: 5, +}; + +/** GeometryTrigonometryBlockOperations */ +const TrigonometryOperations: Record = { + Cos: 0, + Sin: 1, + Abs: 2, + Exp: 3, + Round: 4, + Floor: 5, + Ceiling: 6, + Sqrt: 7, + Log: 8, + Tan: 9, + ArcTan: 10, + ArcCos: 11, + ArcSin: 12, + Sign: 13, + Negate: 14, + OneMinus: 15, + Reciprocal: 16, + ToDegrees: 17, + ToRadians: 18, + Fract: 19, + Exp2: 20, +}; + +/** ConditionBlockTests */ +const ConditionBlockTests: Record = { + Equal: 0, + NotEqual: 1, + LessThan: 2, + GreaterThan: 3, + LessOrEqual: 4, + GreaterOrEqual: 5, + Xor: 6, + Or: 7, + And: 8, +}; + +/** BooleanGeometryOperations */ +const BooleanGeometryOperations: Record = { + Intersect: 0, + Subtract: 1, + Union: 2, +}; + +/** RandomBlockLocks */ +const RandomBlockLocks: Record = { + None: 0, + LoopID: 1, + InstanceID: 2, + Once: 3, +}; + +/** Aggregations */ +const Aggregations: Record = { + Max: 0, + Min: 1, + Sum: 2, +}; + +/** MappingTypes */ +const MappingTypes: Record = { + Spherical: 0, + Cylindrical: 1, + Cubic: 2, +}; + +/** GeometryEaseBlockTypes / GeometryCurveBlockTypes */ +const EaseTypes: Record = { + EaseInSine: 0, + EaseOutSine: 1, + EaseInOutSine: 2, + EaseInQuad: 3, + EaseOutQuad: 4, + EaseInOutQuad: 5, + EaseInCubic: 6, + EaseOutCubic: 7, + EaseInOutCubic: 8, + EaseInQuart: 9, + EaseOutQuart: 10, + EaseInOutQuart: 11, + EaseInQuint: 12, + EaseOutQuint: 13, + EaseInOutQuint: 14, + EaseInExpo: 15, + EaseOutExpo: 16, + EaseInOutExpo: 17, + EaseInCirc: 18, + EaseOutCirc: 19, + EaseInOutCirc: 20, + EaseInBack: 21, + EaseOutBack: 22, + EaseInOutBack: 23, + EaseInElastic: 24, + EaseOutElastic: 25, + EaseInOutElastic: 26, +}; + +/** + * Maps block type names to their property→enum-map pairs. + * When a property value is a string, we look up the numeric equivalent here. + */ +const BlockEnumProperties: Record>> = { + MathBlock: { operation: MathBlockOperations }, + GeometryTrigonometryBlock: { operation: TrigonometryOperations }, + ConditionBlock: { test: ConditionBlockTests }, + BooleanGeometryBlock: { operation: BooleanGeometryOperations }, + RandomBlock: { lockMode: RandomBlockLocks }, + AggregatorBlock: { aggregation: Aggregations }, + MappingBlock: { mapping: MappingTypes }, + GeometryEaseBlock: { type: EaseTypes }, + GeometryCurveBlock: { curveType: EaseTypes }, +}; + +// ─── Manager ────────────────────────────────────────────────────────────── + +/** + * Holds in-memory representations of Node Geometry graphs that MCP tools build up incrementally. + */ +export class GeometryGraphManager { + /** All managed geometry graphs, keyed by geometry name. */ + private _geometries = new Map(); + /** Auto-increment block id counter per geometry */ + private _nextId = new Map(); + /** Layout tracking for aesthetic NGE positioning */ + private _nextX = new Map(); + + // ── Lifecycle ────────────────────────────────────────────────────── + + /** + * Create a new empty geometry graph. + * @param name - Unique name for the geometry. + * @param comment - Optional comment/description. + * @returns The newly created serialized geometry. + */ + createGeometry(name: string, comment?: string): ISerializedGeometry { + const geo: ISerializedGeometry = { + customType: "BABYLON.NodeGeometry", + outputNodeId: -1, + blocks: [], + comment, + }; + this._geometries.set(name, geo); + this._nextId.set(name, 1); + this._nextX.set(name, 0); + return geo; + } + + /** + * Retrieve a geometry graph by name. + * @param name - The geometry name. + * @returns The serialized geometry, or undefined if not found. + */ + getGeometry(name: string): ISerializedGeometry | undefined { + return this._geometries.get(name); + } + + /** + * List the names of all managed geometries. + * @returns An array of geometry names. + */ + listGeometries(): string[] { + return Array.from(this._geometries.keys()); + } + + /** + * Delete a geometry graph by name. + * @param name - The geometry name to delete. + * @returns True if the geometry was found and deleted. + */ + deleteGeometry(name: string): boolean { + this._nextId.delete(name); + this._nextX.delete(name); + return this._geometries.delete(name); + } + + /** + * Remove all geometry graphs from memory, resetting the manager to its initial state. + */ + clearAll(): void { + this._geometries.clear(); + this._nextId.clear(); + this._nextX.clear(); + } + + // ── Block CRUD ───────────────────────────────────────────────────── + + /** + * Add a block to the geometry graph. + * + * @param geometryName Name of the geometry graph to add to. + * @param blockType Registry key (e.g. "BoxBlock", "GeometryInputBlock"). + * @param blockName Human-friendly name for this instance. + * @param properties Extra key-value properties to set on the block JSON. + * @returns The serialised block, or an error string. + */ + addBlock(geometryName: string, blockType: string, blockName?: string, properties?: Record): { block: ISerializedBlock; warnings?: string[] } | string { + const geo = this._geometries.get(geometryName); + if (!geo) { + return `Geometry "${geometryName}" not found. Create it first.`; + } + + const info: IBlockTypeInfo | undefined = BlockRegistry[blockType]; + if (!info) { + return `Unknown block type "${blockType}". Use list_block_types to see available blocks.`; + } + + const warnings: string[] = []; + + const id = this._nextId.get(geometryName)!; + this._nextId.set(geometryName, id + 1); + + const name = blockName ?? `${blockType}_${id}`; + + const block: ISerializedBlock = { + customType: `BABYLON.${info.className}`, + id, + name, + inputs: info.inputs.map((inp) => ({ + name: inp.name, + displayName: inp.name, + })), + outputs: info.outputs.map((out) => ({ + name: out.name, + displayName: out.name, + })), + }; + + // Set default GeometryInputBlock fields so the NGE parser never sees missing values + if (blockType === "GeometryInputBlock") { + block["type"] = ConnectionPointTypes.Float; // Default; overridden below + block["contextualValue"] = ContextualSources.None; + block["min"] = 0; + block["max"] = 0; + block["groupInInspector"] = ""; + block["displayInInspector"] = true; + } + + // Apply registry-defined default properties + if (info.defaultSerializedProperties) { + for (const [key, value] of Object.entries(info.defaultSerializedProperties)) { + block[key] = value; + } + } + + // Apply user-supplied properties + if (properties) { + for (const [key, value] of Object.entries(properties)) { + if (blockType === "GeometryInputBlock" && key === "type" && typeof value === "string") { + block["type"] = ConnectionPointTypes[value] ?? value; + } else if (blockType === "GeometryInputBlock" && key === "contextualValue" && typeof value === "string") { + const cv = ContextualSources[value] ?? value; + block["contextualValue"] = cv; + // Auto-derive type from contextual source + if (typeof cv === "number" && cv !== ContextualSources.None && ContextualSourceToType[cv] !== undefined) { + block["type"] = ContextualSourceToType[cv]; + } + } else if (typeof value === "string" && BlockEnumProperties[blockType]?.[key]) { + block[key] = BlockEnumProperties[blockType][key][value] ?? value; + } else { + block[key] = value; + } + } + } + + // For GeometryInputBlock: normalise the value + if (blockType === "GeometryInputBlock") { + this._normaliseInputBlockValue(block); + this._ensureDefaultValue(block); + } + + // Auto-mark as output node if this is the output block + if (blockType === "GeometryOutputBlock") { + geo.outputNodeId = id; + } + + // Track editor location for nice layout + const x = this._nextX.get(geometryName)!; + this._nextX.set(geometryName, x + 280); + if (!geo.editorData) { + geo.editorData = { locations: [] }; + } + geo.editorData.locations.push({ blockId: id, x, y: 0 }); + + // ── GeometryInputBlock-specific warnings ──────────────────────── + if (blockType === "GeometryInputBlock") { + const cv = block["contextualValue"] as number; + const hasValue = block["value"] !== undefined; + if (cv === ContextualSources.None && !hasValue) { + warnings.push( + `⚠ GeometryInputBlock "${block.name}" has contextualValue=None and no value. ` + + `Set either a contextualValue (e.g. 'Positions', 'Normals', 'UV', 'VertexID') ` + + `or provide a constant value.` + ); + } + } + + geo.blocks.push(block); + return { block, warnings: warnings.length > 0 ? warnings : undefined }; + } + + /** + * Normalise a GeometryInputBlock's `value` to the format the NGE parser expects, + * and set the corresponding `valueType` string. + * + * Babylon's GeometryInputBlock._deserialize reads: + * valueType === "number" → value is a scalar + * otherwise → GetClass(valueType).FromArray(value) + * @param block - The GeometryInputBlock to normalise. + */ + private _normaliseInputBlockValue(block: ISerializedBlock): void { + const val = block["value"]; + if (val === undefined || val === null) { + return; + } + + const type = block["type"] as number | undefined; + + // Scalar values + if (typeof val === "number") { + if (type === ConnectionPointTypes.Matrix) { + block["value"] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + block["valueType"] = "BABYLON.Matrix"; + return; + } + block["valueType"] = "number"; + return; + } + + // Already a flat array — just ensure valueType is set + if (Array.isArray(val)) { + if (!block["valueType"]) { + block["valueType"] = this._inferValueType(type, val.length); + } + return; + } + + // Object with named components → convert to flat array + if (typeof val === "object") { + const obj = val as Record; + if ("x" in obj) { + if ("w" in obj) { + block["value"] = [obj.x, obj.y, obj.z, obj.w]; + block["valueType"] = "BABYLON.Vector4"; + } else if ("z" in obj) { + block["value"] = [obj.x, obj.y, obj.z]; + block["valueType"] = "BABYLON.Vector3"; + } else { + block["value"] = [obj.x, obj.y]; + block["valueType"] = "BABYLON.Vector2"; + } + } + } + } + + /** + * Infer `valueType` from ConnectionPointTypes enum value and array length. + * @param type - The ConnectionPointTypes enum value. + * @param length - Length of the value array. + * @returns The inferred Babylon.js value type string. + */ + private _inferValueType(type: number | undefined, length: number): string { + const typeMap: Record = { + [ConnectionPointTypes.Vector2]: "BABYLON.Vector2", + [ConnectionPointTypes.Vector3]: "BABYLON.Vector3", + [ConnectionPointTypes.Vector4]: "BABYLON.Vector4", + [ConnectionPointTypes.Matrix]: "BABYLON.Matrix", + }; + if (type !== undefined && typeMap[type]) { + return typeMap[type]; + } + + const lengthMap: Record = { + 2: "BABYLON.Vector2", + 3: "BABYLON.Vector3", + 4: "BABYLON.Vector4", + 16: "BABYLON.Matrix", + }; + return lengthMap[length] ?? "BABYLON.Vector3"; + } + + /** + * Ensure that GeometryInputBlocks of vector/matrix types always have a default value + * so the editor doesn't crash reading .x on undefined. + * @param block - The GeometryInputBlock to ensure has a default value. + */ + private _ensureDefaultValue(block: ISerializedBlock): void { + if (block["value"] !== undefined && block["value"] !== null) { + return; + } + + // Contextual values don't need a stored value + const cv = block["contextualValue"] as number | undefined; + if (cv !== undefined && cv !== ContextualSources.None) { + return; + } + + const type = block["type"] as number | undefined; + if (type === undefined) { + return; + } + + const defaults: Record = { + [ConnectionPointTypes.Float]: { value: 0, valueType: "number" }, + [ConnectionPointTypes.Int]: { value: 0, valueType: "number" }, + [ConnectionPointTypes.Vector2]: { value: [0, 0], valueType: "BABYLON.Vector2" }, + [ConnectionPointTypes.Vector3]: { value: [0, 0, 0], valueType: "BABYLON.Vector3" }, + [ConnectionPointTypes.Vector4]: { value: [0, 0, 0, 0], valueType: "BABYLON.Vector4" }, + [ConnectionPointTypes.Matrix]: { + value: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + valueType: "BABYLON.Matrix", + }, + }; + + const def = defaults[type]; + if (def) { + block["value"] = def.value; + block["valueType"] = def.valueType; + } + } + + /** + * Remove a block from a geometry by its id. + * @param geometryName - Name of the target geometry. + * @param blockId - The block id to remove. + * @returns "OK" or an error string. + */ + removeBlock(geometryName: string, blockId: number): string { + const geo = this._geometries.get(geometryName); + if (!geo) { + return `Geometry "${geometryName}" not found.`; + } + + const idx = geo.blocks.findIndex((b) => b.id === blockId); + if (idx === -1) { + return `Block ${blockId} not found.`; + } + + // Remove any connections pointing to this block + for (const block of geo.blocks) { + for (const inp of block.inputs) { + if (inp.targetBlockId === blockId) { + delete inp.targetBlockId; + delete inp.targetConnectionName; + } + } + } + + geo.blocks.splice(idx, 1); + + // Clear output reference if this was the output block + if (geo.outputNodeId === blockId) { + geo.outputNodeId = -1; + } + + if (geo.editorData) { + geo.editorData.locations = geo.editorData.locations.filter((l) => l.blockId !== blockId); + } + + return "OK"; + } + + // ── Connections ──────────────────────────────────────────────────── + + /** + * Connect an output of one block to an input of another block. + * + * @param geometryName + * @param sourceBlockId The block whose output we connect FROM. + * @param outputName Name of the output connection point on the source. + * @param targetBlockId The block whose input we connect TO. + * @param inputName Name of the input connection point on the target. + * @returns "OK" or an error string. + */ + connectBlocks(geometryName: string, sourceBlockId: number, outputName: string, targetBlockId: number, inputName: string): string { + const geo = this._geometries.get(geometryName); + if (!geo) { + return `Geometry "${geometryName}" not found.`; + } + + const sourceBlock = geo.blocks.find((b) => b.id === sourceBlockId); + if (!sourceBlock) { + return `Source block ${sourceBlockId} not found.`; + } + + const targetBlock = geo.blocks.find((b) => b.id === targetBlockId); + if (!targetBlock) { + return `Target block ${targetBlockId} not found.`; + } + + const output = sourceBlock.outputs.find((o) => o.name === outputName); + if (!output) { + const available = sourceBlock.outputs.map((o) => o.name).join(", "); + return `Output "${outputName}" not found on block ${sourceBlockId} ("${sourceBlock.name}"). Available: ${available}`; + } + + const input = targetBlock.inputs.find((i) => i.name === inputName); + if (!input) { + const available = targetBlock.inputs.map((i) => i.name).join(", "); + return `Input "${inputName}" not found on block ${targetBlockId} ("${targetBlock.name}"). Available: ${available}`; + } + + // An input can only have one connection — overwrite any existing one + input.inputName = input.name; + input.targetBlockId = sourceBlockId; + input.targetConnectionName = outputName; + + return "OK"; + } + + /** + * Disconnect an input on a block. + * @param geometryName - Name of the target geometry. + * @param blockId - The block whose input to disconnect. + * @param inputName - Name of the input connection point. + * @returns "OK" or an error string. + */ + disconnectInput(geometryName: string, blockId: number, inputName: string): string { + const geo = this._geometries.get(geometryName); + if (!geo) { + return `Geometry "${geometryName}" not found.`; + } + + const block = geo.blocks.find((b) => b.id === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + const input = block.inputs.find((i) => i.name === inputName); + if (!input) { + return `Input "${inputName}" not found.`; + } + delete input.inputName; + delete input.targetBlockId; + delete input.targetConnectionName; + return "OK"; + } + + // ── Queries ──────────────────────────────────────────────────────── + + /** + * Get the current state of a geometry as a formatted description. + * @param geometryName - Name of the geometry to describe. + * @returns A human-readable string describing the geometry graph. + */ + describeGeometry(geometryName: string): string { + const geo = this._geometries.get(geometryName); + if (!geo) { + return `Geometry "${geometryName}" not found.`; + } + + const lines: string[] = []; + lines.push(`Geometry: ${geometryName}`); + lines.push(`Blocks (${geo.blocks.length}):`); + + for (const block of geo.blocks) { + const typeName = block.customType.replace("BABYLON.", ""); + lines.push(` [${block.id}] ${block.name} (${typeName})`); + + if (block.inputs.length > 0) { + for (const inp of block.inputs) { + if (inp.targetBlockId !== undefined) { + const srcBlock = geo.blocks.find((b) => b.id === inp.targetBlockId); + lines.push(` ← ${inp.name} ← [${inp.targetBlockId}] ${srcBlock?.name ?? "?"}.${inp.targetConnectionName}`); + } + } + } + } + + lines.push(`Output node: ${geo.outputNodeId >= 0 ? `[${geo.outputNodeId}]` : "(not set)"}`); + if (geo.comment) { + lines.push(`Comment: ${geo.comment}`); + } + return lines.join("\n"); + } + + /** + * Describe a single block in detail. + * @param geometryName - Name of the geometry containing the block. + * @param blockId - The block id to describe. + * @returns A human-readable string describing the block. + */ + describeBlock(geometryName: string, blockId: number): string { + const geo = this._geometries.get(geometryName); + if (!geo) { + return `Geometry "${geometryName}" not found.`; + } + + const block = geo.blocks.find((b) => b.id === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + const typeName = block.customType.replace("BABYLON.", ""); + const lines: string[] = []; + lines.push(`Block [${block.id}]: "${block.name}" — type ${typeName}`); + + lines.push("\nInputs:"); + for (const inp of block.inputs) { + const conn = inp.targetBlockId !== undefined ? ` ← connected to [${inp.targetBlockId}].${inp.targetConnectionName}` : " (unconnected)"; + lines.push(` • ${inp.name}${conn}`); + } + + lines.push("\nOutputs:"); + for (const out of block.outputs) { + const consumers: string[] = []; + for (const b of geo.blocks) { + for (const i of b.inputs) { + if (i.targetBlockId === blockId && i.targetConnectionName === out.name) { + consumers.push(`[${b.id}] ${b.name}.${i.name}`); + } + } + } + const conn = consumers.length > 0 ? ` → ${consumers.join(", ")}` : " (unconnected)"; + lines.push(` • ${out.name}${conn}`); + } + + // Show any extra properties + const ignoredKeys = new Set(["customType", "id", "name", "inputs", "outputs"]); + const extraProps = Object.entries(block).filter(([k]) => !ignoredKeys.has(k)); + if (extraProps.length > 0) { + lines.push("\nProperties:"); + for (const [k, v] of extraProps) { + lines.push(` ${k}: ${JSON.stringify(v)}`); + } + } + + return lines.join("\n"); + } + + // ── Serialisation ───────────────────────────────────────────────── + + /** + * Export to the NGE JSON format that Babylon.js can load. + * @param geometryName - Name of the geometry to export. + * @returns The JSON string, or undefined if the geometry is not found. + */ + exportJSON(geometryName: string): string | undefined { + const geo = this._geometries.get(geometryName); + if (!geo) { + return undefined; + } + + // Final pass: ensure every block has required properties for safe deserialization + for (const block of geo.blocks) { + if (block.customType === "BABYLON.GeometryInputBlock") { + this._normaliseInputBlockValue(block); + this._ensureDefaultValue(block); + } + + // Apply mandatory defaults from the registry for any block type + const typeName = block.customType.replace("BABYLON.", ""); + const info = BlockRegistry[typeName]; + if (info?.defaultSerializedProperties) { + for (const [key, value] of Object.entries(info.defaultSerializedProperties)) { + if (block[key] === undefined) { + block[key] = value; + } + } + } + + // Convert any remaining string enum values to numbers + const enumProps = BlockEnumProperties[typeName]; + if (enumProps) { + for (const [key, enumMap] of Object.entries(enumProps)) { + if (typeof block[key] === "string") { + block[key] = enumMap[block[key] as string] ?? block[key]; + } + } + } + } + + // Compute a proper layered graph layout for the editor + this._layoutGraph(geo); + + return JSON.stringify(geo, null, 2); + } + + // ── Graph Layout ─────────────────────────────────────────────────── + + /** Horizontal spacing between columns in the editor (px). */ + private static readonly COL_WIDTH = 340; + /** Vertical spacing between blocks within a column (px). */ + private static readonly ROW_HEIGHT = 180; + + /** + * Compute a layered graph layout and write it into `editorData.locations`. + * + * Algorithm: + * 1. Build predecessor/successor maps from block connections. + * 2. Assign each block a depth via longest-path BFS from the output node. + * 3. Reverse depth so inputs are on the left, output on the right. + * 4. Sort within each column with barycenter heuristic. + * 5. Write `{ blockId, x, y }` locations. + * + * @param geo - The geometry to lay out. + */ + private _layoutGraph(geo: ISerializedGeometry): void { + const blocks = geo.blocks; + if (blocks.length === 0) { + return; + } + + const blockById = new Map(); + for (const b of blocks) { + blockById.set(b.id, b); + } + + // ── Step 1: Build adjacency ──────────────────────────────────── + const predecessors = new Map>(); + const successors = new Map>(); + for (const b of blocks) { + predecessors.set(b.id, new Set()); + successors.set(b.id, new Set()); + } + for (const b of blocks) { + for (const inp of b.inputs) { + if (inp.targetBlockId !== undefined) { + predecessors.get(b.id)!.add(inp.targetBlockId); + successors.get(inp.targetBlockId)?.add(b.id); + } + } + } + + // ── Step 2: Longest-path depth from output node ──────────────── + const depth = new Map(); + const queue: number[] = []; + + // Use the output node as the root + if (geo.outputNodeId >= 0 && blockById.has(geo.outputNodeId)) { + depth.set(geo.outputNodeId, 0); + queue.push(geo.outputNodeId); + } else { + // Fallback: use blocks that match GeometryOutputBlock + for (const b of blocks) { + if (b.customType === "BABYLON.GeometryOutputBlock") { + depth.set(b.id, 0); + queue.push(b.id); + } + } + } + + // If still nothing, use the last block + if (queue.length === 0 && blocks.length > 0) { + depth.set(blocks[blocks.length - 1].id, 0); + queue.push(blocks[blocks.length - 1].id); + } + + let head = 0; + while (head < queue.length) { + const id = queue[head++]; + const d = depth.get(id)!; + for (const predId of predecessors.get(id) ?? []) { + const existing = depth.get(predId); + if (existing === undefined || d + 1 > existing) { + depth.set(predId, d + 1); + queue.push(predId); + } + } + } + + // Disconnected blocks get max depth + 1 + const maxDepth = Math.max(0, ...depth.values()); + for (const b of blocks) { + if (!depth.has(b.id)) { + depth.set(b.id, maxDepth + 1); + } + } + + // ── Step 3: Reverse so inputs are on the left ────────────────── + const totalMaxDepth = Math.max(0, ...depth.values()); + const column = new Map(); + for (const [id, d] of depth) { + column.set(id, totalMaxDepth - d); + } + + // ── Step 4: Group blocks by column and sort ──────────────────── + const columns = new Map(); + for (const b of blocks) { + const col = column.get(b.id)!; + if (!columns.has(col)) { + columns.set(col, []); + } + columns.get(col)!.push(b.id); + } + + const sortedCols = [...columns.keys()].sort((a, b) => b - a); + const yPosition = new Map(); + + for (const col of sortedCols) { + const colBlocks = columns.get(col)!; + + if (col === sortedCols[0]) { + colBlocks.forEach((id, i) => yPosition.set(id, i)); + } else { + const barycenters = new Map(); + for (const id of colBlocks) { + const succs = successors.get(id)!; + const succYs: number[] = []; + for (const sid of succs) { + const sy = yPosition.get(sid); + if (sy !== undefined) { + succYs.push(sy); + } + } + if (succYs.length > 0) { + barycenters.set(id, succYs.reduce((a, b) => a + b, 0) / succYs.length); + } else { + barycenters.set(id, 9999); + } + } + colBlocks.sort((a, b) => barycenters.get(a)! - barycenters.get(b)!); + colBlocks.forEach((id, i) => yPosition.set(id, i)); + } + } + + // ── Step 5: Write locations ──────────────────────────────────── + const locations: Array<{ blockId: number; x: number; y: number }> = []; + for (const b of blocks) { + const col = column.get(b.id)!; + const row = yPosition.get(b.id)!; + locations.push({ + blockId: b.id, + x: col * GeometryGraphManager.COL_WIDTH, + y: row * GeometryGraphManager.ROW_HEIGHT, + }); + } + + if (!geo.editorData) { + geo.editorData = { locations }; + } else { + geo.editorData.locations = locations; + } + } + + /** + * Import an NGE JSON string. + * @param geometryName - Name to assign to the imported geometry. + * @param json - The NGE JSON string to parse. + * @returns "OK" or an error string. + */ + importJSON(geometryName: string, json: string): string { + try { + const parsed = ValidateNodeGeometryAttachmentPayload(json) as unknown as ISerializedGeometry; + this._geometries.set(geometryName, parsed); + + const maxId = parsed.blocks.reduce((max, b) => Math.max(max, b.id), 0); + this._nextId.set(geometryName, maxId + 1); + this._nextX.set(geometryName, parsed.blocks.length * 280); + + return "OK"; + } catch (e) { + return (e as Error).message; + } + } + + // ── Block Property Mutation ──────────────────────────────────────── + + /** + * Set one or more properties on a block. + * @param geometryName - Name of the target geometry. + * @param blockId - The block id to update. + * @param properties - Key-value pairs to set on the block. + * @returns "OK" or an error string. + */ + setBlockProperties(geometryName: string, blockId: number, properties: Record): string { + const geo = this._geometries.get(geometryName); + if (!geo) { + return `Geometry "${geometryName}" not found.`; + } + + const block = geo.blocks.find((b) => b.id === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + const typeName = block.customType.replace("BABYLON.", ""); + + for (const [key, value] of Object.entries(properties)) { + if (typeName === "GeometryInputBlock" && key === "type" && typeof value === "string") { + block["type"] = ConnectionPointTypes[value] ?? value; + } else if (typeName === "GeometryInputBlock" && key === "contextualValue" && typeof value === "string") { + const cv = ContextualSources[value] ?? value; + block["contextualValue"] = cv; + if (typeof cv === "number" && cv !== ContextualSources.None && ContextualSourceToType[cv] !== undefined) { + block["type"] = ContextualSourceToType[cv]; + } + } else if (typeof value === "string" && BlockEnumProperties[typeName]?.[key]) { + block[key] = BlockEnumProperties[typeName][key][value] ?? value; + } else { + block[key] = value; + } + } + + // Re-normalise GeometryInputBlock value after property changes + if (typeName === "GeometryInputBlock") { + this._normaliseInputBlockValue(block); + this._ensureDefaultValue(block); + } + + return "OK"; + } + + // ── Validation ──────────────────────────────────────────────────── + + /** + * Run basic validation on the graph and return any warnings/errors. + * @param geometryName - Name of the geometry to validate. + * @returns An array of issue strings (or a success message). + */ + validateGeometry(geometryName: string): string[] { + const geo = this._geometries.get(geometryName); + if (!geo) { + return [`Geometry "${geometryName}" not found.`]; + } + + const issues: string[] = []; + + // Check for output node + const hasOutputBlock = geo.blocks.some((b) => b.customType === "BABYLON.GeometryOutputBlock"); + if (!hasOutputBlock) { + issues.push("ERROR: Missing GeometryOutputBlock — every geometry graph needs exactly one."); + } + + if (geo.outputNodeId < 0) { + issues.push("ERROR: outputNodeId is not set. There should be a GeometryOutputBlock."); + } else if (!geo.blocks.find((b) => b.id === geo.outputNodeId)) { + issues.push(`ERROR: outputNodeId references block ${geo.outputNodeId} which does not exist.`); + } + + // Check for unconnected required inputs + for (const block of geo.blocks) { + const typeName = block.customType.replace("BABYLON.", ""); + const info = Object.values(BlockRegistry).find((r) => r.className === typeName); + if (!info) { + continue; + } + + for (const inp of block.inputs) { + const inputInfo = info.inputs.find((i) => i.name === inp.name); + if (inp.targetBlockId === undefined && inputInfo && !inputInfo.isOptional) { + issues.push(`WARNING: Block [${block.id}] "${block.name}" has required input "${inp.name}" that is not connected.`); + } + } + } + + // Check for dangling connection references + for (const block of geo.blocks) { + for (const inp of block.inputs) { + if (inp.targetBlockId !== undefined) { + const src = geo.blocks.find((b) => b.id === inp.targetBlockId); + if (!src) { + issues.push(`ERROR: Block [${block.id}] "${block.name}" input "${inp.name}" references non-existent block ${inp.targetBlockId}.`); + } else if (!src.outputs.find((o) => o.name === inp.targetConnectionName)) { + issues.push( + `WARNING: Block [${block.id}] "${block.name}" input "${inp.name}" references output "${inp.targetConnectionName}" which doesn't exist on block [${src.id}].` + ); + } + } + } + } + + // Check GeometryInputBlock-specific issues + for (const block of geo.blocks) { + if (block.customType !== "BABYLON.GeometryInputBlock") { + continue; + } + const cv = block["contextualValue"] as number | undefined; + const hasValue = block["value"] !== undefined; + if ((cv === undefined || cv === ContextualSources.None) && !hasValue) { + issues.push(`WARNING: GeometryInputBlock [${block.id}] "${block.name}" has no contextual value and no constant value — it provides no data.`); + } + } + + // Check for orphan blocks + for (const block of geo.blocks) { + if (block.customType === "BABYLON.GeometryOutputBlock" || block.customType === "BABYLON.GeometryInputBlock") { + continue; + } + const hasIncomingConnection = block.inputs.some((inp) => inp.targetBlockId !== undefined); + const hasOutgoingConnection = geo.blocks.some((other) => other.inputs.some((inp) => inp.targetBlockId === block.id)); + if (!hasIncomingConnection && !hasOutgoingConnection) { + issues.push(`WARNING: Block [${block.id}] "${block.name}" (${block.customType.replace("BABYLON.", "")}) has no connections — it is an orphan and does nothing.`); + } + } + + if (issues.length === 0) { + issues.push("No issues found — graph looks valid."); + } + + return issues; + } +} diff --git a/packages/tools/nge-mcp-server/src/index.ts b/packages/tools/nge-mcp-server/src/index.ts new file mode 100644 index 00000000000..01c0d8e6b02 --- /dev/null +++ b/packages/tools/nge-mcp-server/src/index.ts @@ -0,0 +1,1051 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-console */ +/** + * Node Geometry MCP Server (babylonjs-node-geometry) + * ────────────── + * A Model Context Protocol server that exposes tools for building Babylon.js + * Node Geometries programmatically. An AI agent (or any MCP client) can: + * + * • Create / manage geometry graphs + * • Add blocks from the full NGE block catalog + * • Connect blocks together + * • Set block properties (constant values, contextual sources, etc.) + * • Validate the graph + * • Export the final geometry JSON (loadable by NGE / NodeGeometry.parseSerializedObject) + * • Import existing NGE 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 { + CreateErrorResponse, + CreateJsonExportResponse, + CreateInlineJsonSchema, + CreateJsonImportResponse, + CreateJsonFileSchema, + CreateOutputFileSchema, + CreateSnippetIdSchema, + CreateTextResponse, + CreateTypedSnippetImportResponse, + McpEditorSessionController, + ParseJsonText, + RunSnippetResponse, +} from "@tools/mcp-server-core"; + +import { BlockRegistry, GetBlockCatalogSummary, GetBlockTypeDetails } from "./blockRegistry.js"; +import { GeometryGraphManager } from "./geometryGraph.js"; +import { LoadSnippet, SaveSnippet, type IDataSnippetResult } from "@tools/snippet-loader"; + +// ─── Singleton graph manager ────────────────────────────────────────────── +const manager = new GeometryGraphManager(); +const sessionController = new McpEditorSessionController( + { + serverName: "NGE MCP Session Server", + documentKind: "node-geometry", + managerUnavailableMessage: "Geometry graph manager is not available", + getDocument: (manager, session) => manager.exportJSON(session.name), + setDocument: (manager, session, document) => { + const result = manager.importJSON(session.name, document); + return result && result !== "OK" ? result : undefined; + }, + }, + { + defaultPort: 3001, + statusTitle: "NGE MCP Session Server", + } +); + +/** + * Notify SSE subscribers if a session exists for the given geometry. + * @param geometryName - The geometry name to check for active sessions. + */ +function _notifyIfSession(geometryName: string): void { + const sessionId = sessionController.getSessionIdForName(geometryName); + if (sessionId) { + sessionController.notifySessionUpdate(sessionId); + } +} + +/** + * Import geometry JSON and notify a matching live session on success. + * @param geometryName - The geometry name to import into. + * @param jsonText - Serialized NGE JSON. + * @returns "OK" on success, or an error string. + */ +function _importGeometryJson(geometryName: string, jsonText: string): string { + const result = manager.importJSON(geometryName, jsonText); + if (result === "OK") { + _notifyIfSession(geometryName); + } + return result; +} + +// ─── MCP Server ─────────────────────────────────────────────────────────── +const server = new McpServer( + { + name: "babylonjs-node-geometry", + version: "1.0.0", + }, + { + instructions: [ + "You build Babylon.js procedural geometry via Node Geometry graphs. Workflow: create_geometry → add blocks (source geometry blocks like BoxBlock, then transform/math blocks, then GeometryOutputBlock) → connect ports → validate_geometry → export_geometry_json.", + "Every geometry needs a GeometryOutputBlock. Use get_block_type_info to discover ports before connecting.", + "Output JSON can be consumed by the Scene MCP via add_node_geometry_mesh.", + ].join(" "), + } +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Resources (read-only reference data) +// ═══════════════════════════════════════════════════════════════════════════ + +server.registerResource("block-catalog", "nge://block-catalog", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: `# NGE Block Catalog\n${GetBlockCatalogSummary()}`, + }, + ], +})); + +server.registerResource("enums", "nge://enums", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# NGE Enumerations Reference", + "", + "## NodeGeometryBlockConnectionPointTypes", + "Int (0x0001), Float (0x0002), Vector2 (0x0004), Vector3 (0x0008), Vector4 (0x0010), " + + "Matrix (0x0020), Geometry (0x0040), Texture (0x0080), AutoDetect (0x0400), BasedOnInput (0x0800), Undefined (0x1000)", + "", + "## NodeGeometryContextualSources (for GeometryInputBlock)", + "None (0), Positions (1), Normals (2), Tangents (3), UV (4), UV2 (5), UV3 (6), UV4 (7), " + + "UV5 (8), UV6 (9), Colors (10), VertexID (11), FaceID (12), GeometryID (13), " + + "CollectionID (14), LoopID (15), InstanceID (16), LatticeID (17), LatticeControl (18)", + "", + "## MathBlockOperations (for MathBlock)", + "Add (0), Subtract (1), Multiply (2), Divide (3), Max (4), Min (5)", + "", + "## GeometryTrigonometryBlockOperations (for GeometryTrigonometryBlock)", + "Cos (0), Sin (1), Abs (2), Exp (3), Round (4), Floor (5), Ceiling (6), " + + "Sqrt (7), Log (8), Tan (9), ArcTan (10), ArcCos (11), ArcSin (12), Sign (13), " + + "Negate (14), OneMinus (15), Reciprocal (16), ToDegrees (17), ToRadians (18), Fract (19), Exp2 (20)", + "", + "## ConditionBlockTests (for ConditionBlock)", + "Equal (0), NotEqual (1), LessThan (2), GreaterThan (3), LessOrEqual (4), GreaterOrEqual (5), Xor (6), Or (7), And (8)", + "", + "## BooleanGeometryOperations (for BooleanGeometryBlock)", + "Intersect (0), Subtract (1), Union (2)", + "", + "## RandomBlockLocks (for RandomBlock)", + "None (0), LoopID (1), InstanceID (2), Once (3)", + "", + "## Aggregations (for AggregatorBlock) — property name: aggregation", + "Max (0), Min (1), Sum (2). Default: Sum", + "", + "## GeometryEaseBlockTypes (for GeometryEaseBlock) — property name: type", + "EaseInSine (0), EaseOutSine (1), EaseInOutSine (2), EaseInQuad (3), EaseOutQuad (4), ...", + "", + "## MappingTypes (for MappingBlock) — property name: mapping", + "Spherical (0), Cylindrical (1), Cubic (2)", + ].join("\n"), + }, + ], +})); + +server.registerResource("concepts", "nge://concepts", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# Node Geometry Concepts", + "", + "## What is a Node Geometry?", + "A Node Geometry is a visual, graph-based procedural geometry builder in Babylon.js.", + "Instead of creating meshes from code, you connect typed blocks that represent geometry", + "operations. The graph evaluates at runtime to produce mesh vertex data.", + "", + "## Graph Structure — One Required Output", + "Every Node Geometry graph MUST have exactly one output block:", + " • **GeometryOutputBlock** — receives the final Geometry output", + "Without this block, the geometry cannot be built.", + "", + "## GeometryInputBlock — Contextual Sources & Constants", + "GeometryInputBlock is the source of all external data entering the graph. It has two modes:", + "", + "### Mode 1: Contextual Source", + "Reads per-vertex/per-face data from the geometry being processed.", + " • Set `contextualValue` to one of: Positions, Normals, Tangents, UV, UV2, UV3, UV4, UV5, UV6,", + " Colors, VertexID, FaceID, GeometryID, CollectionID, LoopID, InstanceID, LatticeID, LatticeControl", + " • The `type` is automatically derived from the contextual source:", + " - Positions/Normals/Tangents/LatticeControl → Vector3", + " - UV/UV2/UV3/UV4/UV5/UV6 → Vector2", + " - Colors → Vector4", + " - VertexID/FaceID/GeometryID/CollectionID/LoopID/InstanceID/LatticeID → Int", + "", + "### Mode 2: Constant Value", + "Provides a fixed value of a specific type.", + " • Set `type` to: Int, Float, Vector2, Vector3, Vector4, Matrix", + " • Set `value` to the constant (number, or {x,y}, {x,y,z}, {x,y,z,w}, or flat array)", + "", + "## Source Blocks — Built-in Primitives", + "These blocks generate mesh geometry directly without needing inputs:", + " • BoxBlock, SphereBlock, CylinderBlock, PlaneBlock, TorusBlock, DiscBlock", + " • CapsuleBlock, IcoSphereBlock, GridBlock, NullBlock, MeshBlock, PointListBlock", + "All dimension inputs (size, width, height, diameter, segments, subdivisions, etc.) are OPTIONAL INPUT", + "PORTS with sensible defaults. To override them, add a GeometryInputBlock (type Float or Int) with", + "the desired constant value and connect it to that port:", + " Example: connect GeometryInputBlock(Float, value=10) → PlaneBlock.size", + " Example: connect GeometryInputBlock(Float, value=0.5) → SphereBlock.diameter", + "You do NOT need to add inputs for dimensions you want to keep at their defaults.", + "", + "## The Simplest Geometry Graph", + "```", + "BoxBlock.geometry → GeometryOutputBlock.geometry", + "```", + "That's it — one source block connected to the output. This generates a default box.", + "", + "## Transforming Geometry", + "Use GeometryTransformBlock to translate, rotate, or scale geometry.", + "It accepts geometry on 'value' and has built-in translation/rotation/scaling inputs:", + "```", + "BoxBlock.geometry → GeometryTransformBlock.value", + "GeometryInputBlock(Vector3, value={x:0,y:2,z:0}) → GeometryTransformBlock.translation", + "GeometryTransformBlock.output → GeometryOutputBlock.geometry", + "```", + "Alternatively, wire a TranslationBlock (or RotationXBlock/ScalingBlock) to the 'matrix' input", + "for more complex multi-step transforms:", + "```", + "GeometryInputBlock(Vector3, {x:0,y:2,z:0}) → TranslationBlock.translation", + "TranslationBlock.matrix → GeometryTransformBlock.matrix", + "```", + "", + "## Merging Geometries", + "Use MergeGeometryBlock to combine multiple geometries:", + "```", + "BoxBlock.geometry → MergeGeometryBlock.geometry0", + "SphereBlock.geometry → MergeGeometryBlock.geometry1", + "MergeGeometryBlock.output → GeometryOutputBlock.geometry", + "```", + "", + "## Boolean Operations", + "Use BooleanGeometryBlock for CSG operations (Intersect, Subtract, Union):", + "```", + "BoxBlock.geometry → BooleanGeometryBlock.geometry0", + "SphereBlock.geometry → BooleanGeometryBlock.geometry1", + "BooleanGeometryBlock.output → GeometryOutputBlock.geometry", + "```", + "", + "## Instancing / Scattering", + "Create copies of geometry arranged in patterns:", + " • InstantiateLinearBlock — line of copies along a direction", + " • InstantiateRadialBlock — copies arranged in a circle", + " • InstantiateOnFacesBlock — scatter copies on faces of source geometry", + " • InstantiateOnVerticesBlock — place copies at vertices of source geometry", + " • InstantiateOnVolumeBlock — scatter copies inside a volume", + "", + "## Per-Vertex Modification", + "Use Set blocks (evaluateContext=true, the default) to modify vertices individually:", + "```", + "BoxBlock.geometry → SetPositionsBlock.geometry", + "GeometryInputBlock(contextualValue:'Positions') → MathBlock.left (positions, Vector3)", + "NoiseBlock.output → VectorConverterBlock['x '] → VectorConverterBlock.xyz → MathBlock.right", + "MathBlock.output → SetPositionsBlock.positions", + "SetPositionsBlock.output → GeometryOutputBlock.geometry", + "```", + "Notes:", + " • SetPositionsBlock.positions is a REQUIRED input (must be connected).", + " • NoiseBlock outputs a Float; use VectorConverterBlock to lift it to Vector3.", + " • The MathBlock operation should be set to Add (0) via properties: { operation: 0 }.", + "", + "## VectorConverterBlock — Important: Trailing-Space Input Names", + "VectorConverterBlock input port names have a TRAILING SPACE to disambiguate from outputs:", + " Inputs: 'xyzw ' (Vector4), 'xyz ' (Vector3), 'xy ' (Vector2), 'zw ' (Vector2),", + " 'x ' (Float), 'y ' (Float), 'z ' (Float), 'w ' (Float)", + " Outputs: 'xyzw' (Vector4), 'xyz' (Vector3), 'xy' (Vector2), 'zw' (Vector2),", + " 'x' (Float), 'y' (Float), 'z' (Float), 'w' (Float)", + "When calling connect_blocks to a VectorConverterBlock INPUT, you MUST include the trailing space:", + " connect_blocks(sourceId, 'output', vectorConverterId, 'x ') ← note the space", + " connect_blocks(vectorConverterId, 'xyz', targetId, ...) ← outputs have no space", + "", + "## IntFloatConverterBlock — Same Trailing-Space Pattern", + "Inputs: 'float ' (Float), 'int ' (Int)", + "Outputs: 'float' (Float), 'int' (Int)", + "", + "## Common Mistakes", + "1. Forgetting GeometryOutputBlock → geometry cannot be built", + "2. Creating GeometryInputBlock without contextualValue or value → no data provided", + "3. Setting evaluateContext=false on Set blocks when per-vertex behaviour is needed", + "4. Not connecting the geometry flow — every path must link back to GeometryOutputBlock", + "5. Omitting trailing space on VectorConverterBlock / IntFloatConverterBlock inputs", + "6. Leaving SetPositionsBlock.positions unconnected (required, not optional)", + ].join("\n"), + }, + ], +})); + +// ═══════════════════════════════════════════════════════════════════════════ +// Prompts (reusable prompt templates) +// ═══════════════════════════════════════════════════════════════════════════ + +server.registerPrompt("create-box-geometry", { description: "Step-by-step instructions for building a simple box geometry" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a simple box geometry. Steps:", + "1. create_geometry with name 'MyBox'", + "2. Add BoxBlock named 'box'", + "3. Add GeometryOutputBlock named 'output'", + "4. Connect box.geometry → output.geometry", + "5. validate_geometry, then export_geometry_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-scattered-instances", { description: "Step-by-step instructions for scattering instances of a geometry on the faces of another" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a geometry that scatters small spheres on the faces of a plane.", + "NOTE: PlaneBlock and SphereBlock inputs (size, diameter) are INPUT PORTS, not scalar properties.", + "To set them, add a GeometryInputBlock (type Float) with the desired value and connect it.", + "", + "Steps:", + "1. create_geometry with name 'ScatteredSpheres'", + "2. Add PlaneBlock named 'basePlane'", + "3. Add GeometryInputBlock named 'planeSize' with type 'Float', value 10", + "4. Connect planeSize.output → basePlane.size", + "5. Add SphereBlock named 'smallSphere'", + "6. Add GeometryInputBlock named 'sphereDiam' with type 'Float', value 0.2", + "7. Connect sphereDiam.output → smallSphere.diameter", + "8. Add GeometryInputBlock named 'count' with type 'Int', value 100", + "9. Add InstantiateOnFacesBlock named 'scatter'", + "10. Connect basePlane.geometry → scatter.geometry", + "11. Connect smallSphere.geometry → scatter.instance", + "12. Connect count.output → scatter.count", + "13. Add GeometryOutputBlock named 'output'", + "14. Connect scatter.output → output.geometry", + "15. validate_geometry, then export_geometry_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-deformed-terrain", { description: "Create a subdivided plane deformed by noise to produce terrain-like geometry" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a terrain by deforming a subdivided grid with noise.", + "NOTE: Input values (subdivisions, scale) are INPUT PORTS fed by GeometryInputBlocks.", + "", + "Steps:", + "1. create_geometry 'Terrain'", + "2. Add GridBlock named 'grid'", + "3. Add GeometryInputBlock 'gridSize' type Float, value 20 → connect to grid.size", + "4. Add GeometryInputBlock 'subdivs' type Int, value 64 → connect to grid.subdivisions", + "5. Add SetPositionsBlock named 'setPos'", + "6. Connect grid.geometry → setPos.geometry", + "7. Add Positions contextual input: GeometryInputBlock 'positions' contextualValue 'Positions' (Vector3)", + "8. Add NoiseSampleBlock named 'noise'", + "9. Connect positions.output → noise.position", + "10. Add GeometryInputBlock 'noiseScale' type Float, value 0.15 → noise.amplitude", + "11. Add VectorConverterBlock (or compose) to turn noise.output (Float) into a Y-offset Vector3:", + " a. Add GeometryInputBlock 'zero' type Float, value 0", + " b. Add CreateVector3Block 'offset' — connect zero→x, noise.output→y, zero→z", + "12. Add AddBlock 'displaced' — connect positions→left, offset→right", + "13. Connect displaced.output → setPos.positions", + "14. Add ComputeNormalsBlock 'normals' — connect setPos.output → normals.geometry", + "15. Add GeometryOutputBlock 'output' — connect normals.output → output.geometry", + "16. validate_geometry, then export_geometry_json", + ].join("\n"), + }, + }, + ], +})); + +// ═══════════════════════════════════════════════════════════════════════════ +// Tools +// ═══════════════════════════════════════════════════════════════════════════ + +// ── Geometry lifecycle ───────────────────────────────────────────────── + +server.registerTool( + "create_geometry", + { + description: "Create a new empty Node Geometry graph in memory. This is always the first step.", + inputSchema: { + name: z.string().describe("Unique name for the geometry (e.g. 'MyTerrain', 'TreeGenerator')"), + comment: z.string().optional().describe("An optional description of what this geometry does"), + }, + }, + async ({ name, comment }) => { + manager.createGeometry(name, comment); + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(name); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return { + content: [ + { + type: "text", + text: `Created geometry "${name}". Now add blocks with add_block, connect them with connect_blocks, then export with export_geometry_json.\n\nMCP Session URL: ${sessionUrl}`, + }, + ], + }; + } +); + +server.registerTool( + "get_session_url", + { + description: "Get or create a live-session URL for a geometry. The URL can be pasted into the Node Geometry Editor MCP session panel.", + inputSchema: { + geometryName: z.string().describe("Name of the geometry"), + }, + }, + async ({ geometryName }) => { + const geometries = manager.listGeometries(); + if (!geometries.includes(geometryName)) { + return CreateErrorResponse(`Geometry "${geometryName}" not found.`); + } + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(geometryName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return CreateTextResponse(`MCP Session URL: ${sessionUrl}`); + } +); + +server.registerTool( + "start_session", + { + description: "Start a live session for an existing geometry. If a session already exists for this geometry, returns the existing URL.", + inputSchema: { + geometryName: z.string().describe("Name of the geometry"), + }, + }, + async ({ geometryName }) => { + const geometries = manager.listGeometries(); + if (!geometries.includes(geometryName)) { + return CreateErrorResponse(`Geometry "${geometryName}" not found.`); + } + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(geometryName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return CreateTextResponse(`MCP Session URL: ${sessionUrl}`); + } +); + +server.registerTool( + "close_session", + { + description: "Close a live session for a geometry. Disconnects all SSE subscribers in the editor and removes the session. The geometry itself is NOT deleted.", + inputSchema: { + geometryName: z.string().describe("Name of the geometry whose session to close"), + }, + }, + async ({ geometryName }) => { + const closed = sessionController.closeSessionForName(geometryName); + if (!closed) { + return CreateTextResponse(`No active session for "${geometryName}".`); + } + return CreateTextResponse(`Session for "${geometryName}" closed. The editor will disconnect.`); + } +); + +server.registerTool( + "stop_session_server", + { + description: "Stop the live MCP editor session server started by this MCP process. This closes all active sessions, disconnects editors, and releases the port.", + }, + async () => { + await sessionController.stopAsync(); + return CreateTextResponse("MCP session server stopped. Any connected editors have been disconnected."); + } +); + +server.registerTool( + "delete_geometry", + { + description: "Delete a geometry graph from memory.", + inputSchema: { + name: z.string().describe("Name of the geometry to delete"), + }, + }, + async ({ name }) => { + sessionController.closeSessionForName(name); + const ok = manager.deleteGeometry(name); + return { + content: [{ type: "text", text: ok ? `Deleted "${name}".` : `Geometry "${name}" not found.` }], + }; + } +); + +server.registerTool("clear_all", { description: "Remove all geometry graphs from memory, resetting the server to a clean state." }, async () => { + const names = manager.listGeometries(); + for (const name of names) { + sessionController.closeSessionForName(name); + } + manager.clearAll(); + return { + content: [{ type: "text", text: names.length > 0 ? `Cleared ${names.length} geometry(s): ${names.join(", ")}` : "Nothing to clear — memory was already empty." }], + }; +}); + +server.registerTool("list_geometries", { description: "List all geometry graphs currently in memory." }, async () => { + const names = manager.listGeometries(); + return { + content: [ + { + type: "text", + text: names.length > 0 ? `Geometries in memory:\n${names.map((n) => ` • ${n}`).join("\n")}` : "No geometries in memory.", + }, + ], + }; +}); + +// ── Block operations ──────────────────────────────────────────────────── + +server.registerTool( + "add_block", + { + description: "Add a new block to a geometry graph. Returns the block's id for use in connect_blocks.", + inputSchema: { + geometryName: z.string().describe("Name of the geometry to add the block to"), + blockType: z + .string() + .describe( + "The block type from the registry (e.g. 'BoxBlock', 'GeometryInputBlock', 'MergeGeometryBlock', " + + "'SetPositionsBlock', 'InstantiateOnFacesBlock', etc.). Use list_block_types to see all." + ), + name: z.string().optional().describe("Human-friendly name for this block instance (e.g. 'myBox', 'positions')"), + properties: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Key-value properties to set on the block. For GeometryInputBlock: type (Int/Float/Vector2/Vector3/Vector4/Matrix), " + + "contextualValue (None/Positions/Normals/UV/VertexID/FaceID/etc.), value (the constant value), min/max (number). " + + "For MathBlock: operation (MathBlockOperations: 0=Add/1=Subtract/2=Multiply/3=Divide/4=Max/5=Min). " + + "For BooleanGeometryBlock: operation (BooleanGeometryOperations: 0=Intersect/1=Subtract/2=Union). " + + "For GeometryTrigonometryBlock: operation (GeometryTrigonometryBlockOperations: 0=Cos/1=Sin/2=Abs/3=Exp/4=Round.../19=Fract/20=Exp2). " + + "For ConditionBlock: test (ConditionBlockTests: 0=Equal/1=NotEqual/2=LessThan...). " + + "Many blocks support evaluateContext (boolean) for per-vertex evaluation." + ), + }, + }, + async ({ geometryName, blockType, name, properties }) => { + const result = manager.addBlock(geometryName, blockType, name, properties as Record); + if (typeof result === "string") { + return { content: [{ type: "text", text: `Error: ${result}` }], isError: true }; + } + _notifyIfSession(geometryName); + const lines = [`Added block [${result.block.id}] "${result.block.name}" (${blockType}). Use this id (${result.block.id}) to connect it.`]; + if (result.warnings) { + lines.push("", "Warnings:", ...result.warnings); + } + return { + content: [ + { + type: "text", + text: lines.join("\n"), + }, + ], + }; + } +); + +server.registerTool( + "add_blocks_batch", + { + description: "Add multiple blocks at once. More efficient than calling add_block repeatedly. Returns all created block ids.", + inputSchema: { + geometryName: z.string().describe("Name of the geometry"), + blocks: z + .array( + z.object({ + blockType: z.string().describe("Block type name"), + blockName: z.string().optional().describe("Instance name for the block"), + name: z.string().optional().describe("Instance name (alias for blockName)"), + properties: z.record(z.string(), z.unknown()).optional().describe("Block properties"), + }) + ) + .describe("Array of blocks to add"), + }, + }, + async ({ geometryName, blocks }) => { + const results: string[] = []; + let hasSuccess = false; + for (const blockDef of blocks) { + const bName = blockDef.blockName ?? blockDef.name; + const result = manager.addBlock(geometryName, blockDef.blockType, bName, blockDef.properties as Record); + if (typeof result === "string") { + results.push(`Error adding ${blockDef.blockType}: ${result}`); + } else { + hasSuccess = true; + let line = `[${result.block.id}] ${result.block.name} (${blockDef.blockType})`; + if (result.warnings) { + line += `\n ⚠ ${result.warnings.join("\n ⚠ ")}`; + } + results.push(line); + } + } + if (hasSuccess) { + _notifyIfSession(geometryName); + } + return { content: [{ type: "text", text: `Added blocks:\n${results.join("\n")}` }] }; + } +); + +server.registerTool( + "remove_block", + { + description: "Remove a block from a geometry graph. Also removes any connections to/from it.", + inputSchema: { + geometryName: z.string().describe("Name of the geometry"), + blockId: z.number().describe("The block id to remove"), + }, + }, + async ({ geometryName, blockId }) => { + const result = manager.removeBlock(geometryName, blockId); + if (result === "OK") { + _notifyIfSession(geometryName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Removed block ${blockId}.` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "set_block_properties", + { + description: "Set or update properties on an existing block (e.g. change a GeometryInputBlock value, set a MathBlock operation, toggle evaluateContext).", + inputSchema: { + geometryName: z.string().describe("Name of the geometry"), + blockId: z.number().describe("The block id to modify"), + properties: z.record(z.string(), z.unknown()).describe("Key-value properties to set. Same keys as add_block's properties parameter."), + }, + }, + async ({ geometryName, blockId, properties }) => { + const result = manager.setBlockProperties(geometryName, blockId, properties as Record); + if (result === "OK") { + _notifyIfSession(geometryName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Updated block ${blockId}.` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +// ── Connections ────────────────────────────────────────────────────────── + +server.registerTool( + "connect_blocks", + { + description: "Connect an output of one block to an input of another block. Data flows from source output → target input.", + inputSchema: { + geometryName: z.string().describe("Name of the geometry"), + sourceBlockId: z.number().describe("Block id to connect FROM (the one with the output)"), + outputName: z.string().describe("Name of the output on the source block (e.g. 'output', 'geometry', 'matrix')"), + targetBlockId: z.number().describe("Block id to connect TO (the one with the input)"), + inputName: z.string().describe("Name of the input on the target block (e.g. 'geometry', 'value', 'positions')"), + }, + }, + async ({ geometryName, sourceBlockId, outputName, targetBlockId, inputName }) => { + const result = manager.connectBlocks(geometryName, sourceBlockId, outputName, targetBlockId, inputName); + if (result === "OK") { + _notifyIfSession(geometryName); + } + return { + content: [ + { + type: "text", + text: result === "OK" ? `Connected [${sourceBlockId}].${outputName} → [${targetBlockId}].${inputName}` : `Error: ${result}`, + }, + ], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "connect_blocks_batch", + { + description: "Connect multiple block pairs at once. More efficient than calling connect_blocks repeatedly.", + inputSchema: { + geometryName: z.string().describe("Name of the geometry"), + connections: z + .array( + z.object({ + sourceBlockId: z.number(), + outputName: z.string(), + targetBlockId: z.number(), + inputName: z.string(), + }) + ) + .describe("Array of connections to make"), + }, + }, + async ({ geometryName, connections }) => { + const results: string[] = []; + let hasSuccess = false; + for (const conn of connections) { + const result = manager.connectBlocks(geometryName, conn.sourceBlockId, conn.outputName, conn.targetBlockId, conn.inputName); + if (result === "OK") { + hasSuccess = true; + results.push(`[${conn.sourceBlockId}].${conn.outputName} → [${conn.targetBlockId}].${conn.inputName}`); + } else { + results.push(`Error: ${result}`); + } + } + if (hasSuccess) { + _notifyIfSession(geometryName); + } + return { content: [{ type: "text", text: `Connections:\n${results.join("\n")}` }] }; + } +); + +server.registerTool( + "disconnect_input", + { + description: "Disconnect an input on a block (remove an existing connection).", + inputSchema: { + geometryName: z.string().describe("Name of the geometry"), + blockId: z.number().describe("The block id whose input to disconnect"), + inputName: z.string().describe("Name of the input to disconnect"), + }, + }, + async ({ geometryName, blockId, inputName }) => { + const result = manager.disconnectInput(geometryName, blockId, inputName); + if (result === "OK") { + _notifyIfSession(geometryName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Disconnected [${blockId}].${inputName}` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +// ── Query tools ───────────────────────────────────────────────────────── + +server.registerTool( + "describe_geometry", + { + description: "Get a human-readable description of the current state of a geometry graph, " + "including all blocks and their connections.", + inputSchema: { + geometryName: z.string().describe("Name of the geometry to describe"), + }, + }, + async ({ geometryName }) => { + const desc = manager.describeGeometry(geometryName); + return { content: [{ type: "text", text: desc }] }; + } +); + +server.registerTool( + "describe_block", + { + description: "Get detailed information about a specific block instance in a geometry, including its connections and properties.", + inputSchema: { + geometryName: z.string().describe("Name of the geometry"), + blockId: z.number().describe("The block id to describe"), + }, + }, + async ({ geometryName, blockId }) => { + const desc = manager.describeBlock(geometryName, blockId); + return { content: [{ type: "text", text: desc }] }; + } +); + +server.registerTool( + "list_block_types", + { + description: "List all available NGE block types, grouped by category. Use this to discover which blocks you can add.", + inputSchema: { + category: z + .string() + .optional() + .describe( + "Optionally filter by category (Source, Geometry, Set, Instance, Math, Vector, Color, Matrix, Converter, Texture, Utility, Teleport, Input, Output, Mapping)" + ), + }, + }, + async ({ category }) => { + if (category) { + const matching = Object.entries(BlockRegistry) + .filter(([, info]) => info.category.toLowerCase() === category.toLowerCase()) + .map(([key, info]) => ` ${key}: ${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 inputs, outputs, properties, and description.", + inputSchema: { + blockType: z.string().describe("The block type name (e.g. 'BoxBlock', 'GeometryInputBlock', 'InstantiateOnFacesBlock')"), + }, + }, + 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}`); + lines.push(`Category: ${info.category}`); + lines.push(`Description: ${info.description}`); + + lines.push("\n### Inputs:"); + if (info.inputs.length === 0) { + lines.push(" (none)"); + } + for (const inp of info.inputs) { + const opt = inp.isOptional ? " (optional)" : " (required)"; + lines.push(` • ${inp.name}: ${inp.type}${opt}`); + } + + lines.push("\n### Outputs:"); + if (info.outputs.length === 0) { + lines.push(" (none)"); + } + for (const out of info.outputs) { + lines.push(` • ${out.name}: ${out.type}`); + } + + if (info.properties) { + lines.push("\n### Configurable Properties:"); + for (const [k, v] of Object.entries(info.properties)) { + lines.push(` • ${k}: ${v}`); + } + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } +); + +// ── Validation ────────────────────────────────────────────────────────── + +server.registerTool( + "validate_geometry", + { + description: "Run validation checks on a geometry graph. Reports missing output block, unconnected required inputs, and broken references.", + inputSchema: { + geometryName: z.string().describe("Name of the geometry to validate"), + }, + }, + async ({ geometryName }) => { + const issues = manager.validateGeometry(geometryName); + return { + content: [{ type: "text", text: issues.join("\n") }], + isError: issues.some((i) => i.startsWith("ERROR")), + }; + } +); + +// ── Export / Import ───────────────────────────────────────────────────── + +server.registerTool( + "export_geometry_json", + { + description: + "Export the geometry graph as NGE-compatible JSON. This JSON can be loaded in the Babylon.js Node Geometry Editor " + + "or via NodeGeometry.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: { + geometryName: z.string().describe("Name of the geometry to export"), + outputFile: CreateOutputFileSchema(z), + }, + }, + async ({ geometryName, outputFile }) => { + return CreateJsonExportResponse({ + jsonText: manager.exportJSON(geometryName), + outputFile, + missingMessage: `Geometry "${geometryName}" not found.`, + fileLabel: "NGE JSON", + }); + } +); + +server.registerTool( + "import_geometry_json", + { + description: + "Import an existing NGE JSON into memory for editing. You can then modify blocks, connections, etc. " + + "Provide either the inline json string OR a jsonFile path (not both).", + inputSchema: { + geometryName: z.string().describe("Name to give the imported geometry"), + json: CreateInlineJsonSchema(z, "The NGE JSON string to import"), + jsonFile: CreateJsonFileSchema(z, "Absolute path to a file containing the NGE JSON to import (alternative to inline json)"), + }, + }, + async ({ geometryName, json, jsonFile }) => { + return CreateJsonImportResponse({ + json, + jsonFile, + fileDescription: "NGE JSON file", + importJson: (jsonText) => _importGeometryJson(geometryName, jsonText), + describeImported: () => manager.describeGeometry(geometryName), + }); + } +); + +server.registerTool( + "import_from_snippet", + { + description: + "Import a Node Geometry from the Babylon.js Snippet Server by its snippet ID. " + + "The snippet is fetched, validated as a nodeGeometry type, and loaded into memory for editing. " + + 'Snippet IDs look like "ABC123" or "ABC123#2" (with revision).', + inputSchema: { + geometryName: z.string().describe("Name to give the imported geometry in memory"), + snippetId: CreateSnippetIdSchema(z), + }, + }, + async ({ geometryName, snippetId }) => { + return await RunSnippetResponse({ + snippetId, + loadSnippet: async (requestedSnippetId: string) => (await LoadSnippet(requestedSnippetId)) as IDataSnippetResult, + createResponse: (snippetResult: IDataSnippetResult) => + CreateTypedSnippetImportResponse({ + snippetId, + snippetResult, + expectedType: "nodeGeometry", + importJson: (jsonText) => _importGeometryJson(geometryName, jsonText), + describeImported: () => manager.describeGeometry(geometryName), + successMessage: `Imported snippet "${snippetId}" as "${geometryName}" successfully.`, + }), + }); + } +); + +// ── Snippet / URL helpers ─────────────────────────────────────────────── + +server.registerTool( + "get_snippet_url", + { + description: "Generate a URL that opens the geometry in the online Babylon.js Node Geometry Editor. " + "The JSON is encoded in the URL fragment.", + inputSchema: { + geometryName: z.string().describe("Name of the geometry"), + }, + }, + async ({ geometryName }) => { + const json = manager.exportJSON(geometryName); + if (!json) { + return { content: [{ type: "text", text: `Geometry "${geometryName}" not found.` }], isError: true }; + } + const encoded = Buffer.from(json).toString("base64"); + const url = `https://nge.babylonjs.com/#${encoded}`; + return { + content: [ + { + type: "text", + text: `Open this geometry in the NGE editor:\n${url}\n\nNote: For very large geometries, use the snippet server instead.`, + }, + ], + }; + } +); + +// ── Snippet server ────────────────────────────────────────────────────── + +server.registerTool( + "save_snippet", + { + description: + "Save the geometry to the Babylon.js Snippet Server and return the snippet ID and version. " + + "The snippet can later be loaded in the Node Geometry Editor via its snippet ID, or fetched with import_from_snippet. " + + "To create a new revision of an existing snippet, pass the previous snippetId.", + inputSchema: { + geometryName: z.string().describe("Name of the geometry to save"), + snippetId: z.string().optional().describe('Optional existing snippet ID to create a new revision of (e.g. "ABC123" or "ABC123#1")'), + name: z.string().optional().describe("Optional human-readable title for the snippet"), + description: z.string().optional().describe("Optional description"), + tags: z.string().optional().describe("Optional comma-separated tags"), + }, + }, + async ({ geometryName, snippetId, name, description, tags }) => { + const json = manager.exportJSON(geometryName); + if (!json) { + return { content: [{ type: "text", text: `Geometry "${geometryName}" not found.` }], isError: true }; + } + try { + const result = await SaveSnippet( + { type: "nodeGeometry", data: ParseJsonText({ jsonText: json, jsonLabel: "NGE JSON" }) }, + { snippetId, metadata: { name, description, tags } } + ); + return { + content: [ + { + type: "text", + text: `Saved geometry "${geometryName}" to snippet server.\n\nSnippet ID: ${result.id}\nVersion: ${result.version}\nFull ID: ${result.snippetId}\n\nLoad in NGE editor: https://nge.babylonjs.com/#${result.snippetId}`, + }, + ], + }; + } catch (e) { + return { content: [{ type: "text", text: `Error saving snippet: ${(e as Error).message}` }], isError: true }; + } + } +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Start the server +// ═══════════════════════════════════════════════════════════════════════════ + +async function Main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Babylon.js Node Geometry Editor MCP Server running on stdio"); +} + +try { + await Main(); +} catch (err) { + console.error("Fatal error:", err); + process.exit(1); +} + +const _shutdown = () => { + void sessionController.stopAsync(); + process.exit(0); +}; +process.on("SIGINT", _shutdown); +process.on("SIGTERM", _shutdown); diff --git a/packages/tools/nge-mcp-server/test/unit/generateExamples.test.ts b/packages/tools/nge-mcp-server/test/unit/generateExamples.test.ts new file mode 100644 index 00000000000..df7470f1632 --- /dev/null +++ b/packages/tools/nge-mcp-server/test/unit/generateExamples.test.ts @@ -0,0 +1,285 @@ +/** + * Node Geometry MCP Server – Example Geometry Generator + * + * Builds several reference Node Geometry graphs via the GeometryGraphManager API, + * validates them, and writes them to the examples/ directory. + * + * Run: npx ts-node --esm test/unit/generateExamples.ts + * Or simply include as a test file – Jest will run it and the examples are + * written to disk as a side effect. + */ + +import * as fs from "fs"; +import * as path from "path"; +import { GeometryGraphManager } from "../../src/geometryGraph"; + +const EXAMPLES_DIR = path.resolve(__dirname, "../../examples"); + +function writeExample(name: string, json: string): void { + fs.mkdirSync(EXAMPLES_DIR, { recursive: true }); + const filePath = path.join(EXAMPLES_DIR, `${name}.json`); + fs.writeFileSync(filePath, json, "utf-8"); +} + +function id(result: ReturnType): number { + if (typeof result === "string") { + throw new Error(result); + } + return result.block.id; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 1 – Simple Box +// A minimal geometry: BoxBlock → GeometryOutputBlock +// ═══════════════════════════════════════════════════════════════════════════ + +function buildSimpleBox(): string { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("SimpleBox", "Minimal box geometry – one BoxBlock piped to the output."); + + const boxId = id(mgr.addBlock("SimpleBox", "BoxBlock", "box")); + const outId = id(mgr.addBlock("SimpleBox", "GeometryOutputBlock", "output")); + + mgr.connectBlocks("SimpleBox", boxId, "geometry", outId, "geometry"); + + const issues = mgr.validateGeometry("SimpleBox"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + return mgr.exportJSON("SimpleBox")!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 2 – Scattered Instances +// Scatter small spheres on the faces of a box using InstantiateOnFacesBlock. +// ═══════════════════════════════════════════════════════════════════════════ + +function buildScatteredInstances(): string { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("ScatteredInstances", "Scatter small spheres across a box's faces."); + + const boxId = id(mgr.addBlock("ScatteredInstances", "BoxBlock", "baseMesh")); + const sphereId = id(mgr.addBlock("ScatteredInstances", "SphereBlock", "instanceMesh")); + + // Scale factor for the small spheres + const scaleValId = id( + mgr.addBlock("ScatteredInstances", "GeometryInputBlock", "scaleFactor", { + type: "Float", + value: 0.05, + }) + ); + + const scaleId = id(mgr.addBlock("ScatteredInstances", "ScalingBlock", "scale")); + mgr.connectBlocks("ScatteredInstances", scaleValId, "output", scaleId, "x"); + mgr.connectBlocks("ScatteredInstances", scaleValId, "output", scaleId, "y"); + mgr.connectBlocks("ScatteredInstances", scaleValId, "output", scaleId, "z"); + + // Instance count + const countId = id( + mgr.addBlock("ScatteredInstances", "GeometryInputBlock", "count", { + type: "Int", + value: 200, + }) + ); + + const scatterId = id(mgr.addBlock("ScatteredInstances", "InstantiateOnFacesBlock", "scatter")); + mgr.connectBlocks("ScatteredInstances", boxId, "geometry", scatterId, "geometry"); + mgr.connectBlocks("ScatteredInstances", sphereId, "geometry", scatterId, "instance"); + mgr.connectBlocks("ScatteredInstances", scaleId, "matrix", scatterId, "matrix"); + mgr.connectBlocks("ScatteredInstances", countId, "output", scatterId, "count"); + + const outId = id(mgr.addBlock("ScatteredInstances", "GeometryOutputBlock", "output")); + mgr.connectBlocks("ScatteredInstances", scatterId, "output", outId, "geometry"); + + const issues = mgr.validateGeometry("ScatteredInstances"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + return mgr.exportJSON("ScatteredInstances")!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 3 – Noise Terrain +// Deform a plane with a noise function to create a terrain-like surface. +// ═══════════════════════════════════════════════════════════════════════════ + +function buildNoiseTerrain(): string { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("NoiseTerrain", "Deform a plane with Perlin noise to create terrain."); + + // GridBlock acts as a subdivided plane + const gridId = id(mgr.addBlock("NoiseTerrain", "GridBlock", "plane")); + + // Read positions + const posId = id( + mgr.addBlock("NoiseTerrain", "GeometryInputBlock", "positions", { + contextualValue: "Positions", + }) + ); + + // Noise block + const noiseId = id(mgr.addBlock("NoiseTerrain", "NoiseBlock", "noise")); + mgr.connectBlocks("NoiseTerrain", posId, "output", noiseId, "input"); + + // Multiply noise by height scale + const heightId = id( + mgr.addBlock("NoiseTerrain", "GeometryInputBlock", "heightScale", { + type: "Float", + value: 2.0, + }) + ); + const mulId = id(mgr.addBlock("NoiseTerrain", "MathBlock", "heightMul", { operation: "Multiply" })); + mgr.connectBlocks("NoiseTerrain", noiseId, "output", mulId, "left"); + mgr.connectBlocks("NoiseTerrain", heightId, "output", mulId, "right"); + + // SetPositions block + const setPosId = id(mgr.addBlock("NoiseTerrain", "SetPositionsBlock", "setPositions")); + mgr.connectBlocks("NoiseTerrain", gridId, "geometry", setPosId, "geometry"); + mgr.connectBlocks("NoiseTerrain", mulId, "output", setPosId, "positions"); + + // Recompute normals + const normalsId = id(mgr.addBlock("NoiseTerrain", "ComputeNormalsBlock", "normals")); + mgr.connectBlocks("NoiseTerrain", setPosId, "output", normalsId, "geometry"); + + const outId = id(mgr.addBlock("NoiseTerrain", "GeometryOutputBlock", "output")); + mgr.connectBlocks("NoiseTerrain", normalsId, "output", outId, "geometry"); + + const issues = mgr.validateGeometry("NoiseTerrain"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + return mgr.exportJSON("NoiseTerrain")!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 4 – Boolean CSG +// Subtract a sphere from a box to create a hollowed cube. +// ═══════════════════════════════════════════════════════════════════════════ + +function buildBooleanCSG(): string { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("BooleanCSG", "Subtract a sphere from a box (CSG operation)."); + + const boxId = id(mgr.addBlock("BooleanCSG", "BoxBlock", "box")); + const sphereId = id(mgr.addBlock("BooleanCSG", "SphereBlock", "sphere")); + + const boolId = id(mgr.addBlock("BooleanCSG", "BooleanGeometryBlock", "csg", { operation: "Subtract" })); + mgr.connectBlocks("BooleanCSG", boxId, "geometry", boolId, "geometry0"); + mgr.connectBlocks("BooleanCSG", sphereId, "geometry", boolId, "geometry1"); + + const outId = id(mgr.addBlock("BooleanCSG", "GeometryOutputBlock", "output")); + mgr.connectBlocks("BooleanCSG", boolId, "output", outId, "geometry"); + + const issues = mgr.validateGeometry("BooleanCSG"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + return mgr.exportJSON("BooleanCSG")!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 5 – Math Pipeline +// Demonstrate a computational graph: sin wave deformation along Y axis. +// ═══════════════════════════════════════════════════════════════════════════ + +function buildMathPipeline(): string { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("MathPipeline", "Sin-wave deformation along the Y axis of a grid."); + + const gridId = id(mgr.addBlock("MathPipeline", "GridBlock", "grid")); + + // Read positions + const posId = id( + mgr.addBlock("MathPipeline", "GeometryInputBlock", "positions", { + contextualValue: "Positions", + }) + ); + + // Extract X component using VectorConverterBlock + const decomposeId = id(mgr.addBlock("MathPipeline", "VectorConverterBlock", "decompose")); + mgr.connectBlocks("MathPipeline", posId, "output", decomposeId, "xyzIn"); + + // Frequency + const freqId = id( + mgr.addBlock("MathPipeline", "GeometryInputBlock", "frequency", { + type: "Float", + value: 5.0, + }) + ); + + // Multiply x by frequency + const mulFreqId = id(mgr.addBlock("MathPipeline", "MathBlock", "xTimesFreq", { operation: "Multiply" })); + mgr.connectBlocks("MathPipeline", decomposeId, "x", mulFreqId, "left"); + mgr.connectBlocks("MathPipeline", freqId, "output", mulFreqId, "right"); + + // Sin of (x * frequency) + const sinId = id(mgr.addBlock("MathPipeline", "GeometryTrigonometryBlock", "sin", { operation: "Sin" })); + mgr.connectBlocks("MathPipeline", mulFreqId, "output", sinId, "input"); + + // Amplitude + const ampId = id( + mgr.addBlock("MathPipeline", "GeometryInputBlock", "amplitude", { + type: "Float", + value: 0.3, + }) + ); + + // Multiply sin by amplitude + const mulAmpId = id(mgr.addBlock("MathPipeline", "MathBlock", "sinTimesAmp", { operation: "Multiply" })); + mgr.connectBlocks("MathPipeline", sinId, "output", mulAmpId, "left"); + mgr.connectBlocks("MathPipeline", ampId, "output", mulAmpId, "right"); + + // SetPositions to apply the Y offset + const setPosId = id(mgr.addBlock("MathPipeline", "SetPositionsBlock", "deform")); + mgr.connectBlocks("MathPipeline", gridId, "geometry", setPosId, "geometry"); + mgr.connectBlocks("MathPipeline", mulAmpId, "output", setPosId, "positions"); + + // Recompute normals + const normalsId = id(mgr.addBlock("MathPipeline", "ComputeNormalsBlock", "normals")); + mgr.connectBlocks("MathPipeline", setPosId, "output", normalsId, "geometry"); + + const outId = id(mgr.addBlock("MathPipeline", "GeometryOutputBlock", "output")); + mgr.connectBlocks("MathPipeline", normalsId, "output", outId, "geometry"); + + const issues = mgr.validateGeometry("MathPipeline"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + return mgr.exportJSON("MathPipeline")!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Jest Test Wrapper +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Node Geometry MCP Server – Example Generation", () => { + it("generates SimpleBox example", () => { + const json = buildSimpleBox(); + const parsed = JSON.parse(json); + expect(parsed.blocks.length).toBe(2); + writeExample("SimpleBox", json); + }); + + it("generates ScatteredInstances example", () => { + const json = buildScatteredInstances(); + const parsed = JSON.parse(json); + expect(parsed.blocks.length).toBeGreaterThanOrEqual(6); + writeExample("ScatteredInstances", json); + }); + + it("generates NoiseTerrain example", () => { + const json = buildNoiseTerrain(); + const parsed = JSON.parse(json); + expect(parsed.blocks.length).toBeGreaterThanOrEqual(6); + writeExample("NoiseTerrain", json); + }); + + it("generates BooleanCSG example", () => { + const json = buildBooleanCSG(); + const parsed = JSON.parse(json); + expect(parsed.blocks.length).toBe(4); + writeExample("BooleanCSG", json); + }); + + it("generates MathPipeline example", () => { + const json = buildMathPipeline(); + const parsed = JSON.parse(json); + expect(parsed.blocks.length).toBeGreaterThanOrEqual(9); + writeExample("MathPipeline", json); + }); +}); diff --git a/packages/tools/nge-mcp-server/test/unit/graphManager.test.ts b/packages/tools/nge-mcp-server/test/unit/graphManager.test.ts new file mode 100644 index 00000000000..7c0e3c763a1 --- /dev/null +++ b/packages/tools/nge-mcp-server/test/unit/graphManager.test.ts @@ -0,0 +1,479 @@ +/** + * Node Geometry MCP Server – Graph Manager Validation Tests + * + * Creates geometry graphs via GeometryGraphManager, exports them to JSON, + * validates the JSON structure, and exercises core operations. + */ + +import { GeometryGraphManager } from "../../src/geometryGraph"; +import { BlockRegistry } from "../../src/blockRegistry"; + +// ─── Test Helpers ───────────────────────────────────────────────────────── + +function validateGeometryJSON(json: string, label: string): any { + let parsed: any; + try { + parsed = JSON.parse(json); + } catch { + throw new Error(`${label}: invalid JSON`); + } + + expect(parsed.customType).toBe("BABYLON.NodeGeometry"); + expect(typeof parsed.outputNodeId).toBe("number"); + expect(Array.isArray(parsed.blocks)).toBe(true); + + const allIds = new Set(parsed.blocks.map((b: any) => b.id)); + for (const block of parsed.blocks) { + expect(typeof block.customType).toBe("string"); + expect(block.customType.startsWith("BABYLON.")).toBe(true); + expect(typeof block.id).toBe("number"); + expect(typeof block.name).toBe("string"); + expect(Array.isArray(block.inputs)).toBe(true); + expect(Array.isArray(block.outputs)).toBe(true); + + // Validate connections reference existing block IDs + for (const inp of block.inputs) { + if (inp.targetBlockId !== undefined) { + expect(allIds.has(inp.targetBlockId)).toBe(true); + expect(typeof inp.targetConnectionName).toBe("string"); + } + } + } + + // Output node references a valid block + if (parsed.outputNodeId >= 0) { + expect(allIds.has(parsed.outputNodeId)).toBe(true); + } + + return parsed; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Node Geometry MCP Server – Graph Manager Validation", () => { + // ── Test 1: Simple box geometry ───────────────────────────────────── + + it("creates and exports a simple box geometry with valid JSON", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("simpleBox"); + + const box = mgr.addBlock("simpleBox", "BoxBlock", "box"); + expect(typeof box).not.toBe("string"); + const boxBlock = (box as any).block; + + const output = mgr.addBlock("simpleBox", "GeometryOutputBlock", "output"); + expect(typeof output).not.toBe("string"); + const outputBlock = (output as any).block; + + const connResult = mgr.connectBlocks("simpleBox", boxBlock.id, "geometry", outputBlock.id, "geometry"); + expect(connResult).toBe("OK"); + + const json = mgr.exportJSON("simpleBox"); + expect(json).toBeDefined(); + const parsed = validateGeometryJSON(json!, "simpleBox"); + expect(parsed.blocks.length).toBe(2); + expect(parsed.outputNodeId).toBe(outputBlock.id); + }); + + // ── Test 2: Lifecycle operations ──────────────────────────────────── + + it("supports create, list, delete lifecycle", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("a"); + mgr.createGeometry("b"); + + const list = mgr.listGeometries(); + expect(list).toContain("a"); + expect(list).toContain("b"); + + expect(mgr.deleteGeometry("a")).toBe(true); + expect(mgr.listGeometries()).not.toContain("a"); + expect(mgr.deleteGeometry("nonexistent")).toBe(false); + }); + + // ── Test 3: GeometryInputBlock with contextual source ─────────────── + + it("correctly sets contextual source and auto-derives type", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("ctx"); + + const result = mgr.addBlock("ctx", "GeometryInputBlock", "positions", { + contextualValue: "Positions", + }); + expect(typeof result).not.toBe("string"); + const block = (result as any).block; + + // contextualValue should be numeric 1 (Positions) + expect(block.contextualValue).toBe(1); + // type should be auto-derived to Vector3 (0x0008) + expect(block.type).toBe(0x0008); + }); + + // ── Test 4: GeometryInputBlock with constant value ────────────────── + + it("correctly normalises constant input values", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("const"); + + // Float constant + const floatRes = mgr.addBlock("const", "GeometryInputBlock", "myFloat", { + type: "Float", + value: 3.14, + }); + expect(typeof floatRes).not.toBe("string"); + const floatBlock = (floatRes as any).block; + expect(floatBlock.type).toBe(0x0002); // Float + expect(floatBlock.value).toBe(3.14); + expect(floatBlock.valueType).toBe("number"); + + // Vector3 constant via object + const vec3Res = mgr.addBlock("const", "GeometryInputBlock", "myVec3", { + type: "Vector3", + value: { x: 1, y: 2, z: 3 }, + }); + expect(typeof vec3Res).not.toBe("string"); + const vec3Block = (vec3Res as any).block; + expect(vec3Block.type).toBe(0x0008); // Vector3 + expect(vec3Block.value).toEqual([1, 2, 3]); + expect(vec3Block.valueType).toBe("BABYLON.Vector3"); + }); + + // ── Test 5: Enum conversion for block properties ──────────────────── + + it("converts string enum values to numbers for all block types", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("enums"); + + // MathBlock with string operation + const math = mgr.addBlock("enums", "MathBlock", "add", { operation: "Multiply" }); + expect(typeof math).not.toBe("string"); + expect((math as any).block.operation).toBe(2); // Multiply = 2 + + // GeometryTrigonometryBlock + const trig = mgr.addBlock("enums", "GeometryTrigonometryBlock", "sin", { operation: "Sin" }); + expect(typeof trig).not.toBe("string"); + expect((trig as any).block.operation).toBe(1); // Sin = 1 + + // ConditionBlock + const cond = mgr.addBlock("enums", "ConditionBlock", "cmp", { test: "GreaterThan" }); + expect(typeof cond).not.toBe("string"); + expect((cond as any).block.test).toBe(3); // GreaterThan = 3 + + // BooleanGeometryBlock + const bool = mgr.addBlock("enums", "BooleanGeometryBlock", "csg", { operation: "Union" }); + expect(typeof bool).not.toBe("string"); + expect((bool as any).block.operation).toBe(2); // Union = 2 + + // MappingBlock + const map = mgr.addBlock("enums", "MappingBlock", "uvMap", { mapping: "Cylindrical" }); + expect(typeof map).not.toBe("string"); + expect((map as any).block.mapping).toBe(1); // Cylindrical = 1 + + // GeometryEaseBlock + const ease = mgr.addBlock("enums", "GeometryEaseBlock", "ease", { type: "EaseInBack" }); + expect(typeof ease).not.toBe("string"); + expect((ease as any).block.type).toBe(21); // EaseInBack = 21 + + // GeometryCurveBlock + const curve = mgr.addBlock("enums", "GeometryCurveBlock", "curve", { curveType: "EaseOutElastic" }); + expect(typeof curve).not.toBe("string"); + expect((curve as any).block.curveType).toBe(25); // EaseOutElastic = 25 + }); + + // ── Test 6: setBlockProperties also converts enums ────────────────── + + it("converts string enums via setBlockProperties", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("setProp"); + + const math = mgr.addBlock("setProp", "MathBlock", "m"); + expect(typeof math).not.toBe("string"); + const block = (math as any).block; + expect(block.operation).toBe(0); // Default: Add + + const result = mgr.setBlockProperties("setProp", block.id, { operation: "Divide" }); + expect(result).toBe("OK"); + expect(block.operation).toBe(3); // Divide = 3 + }); + + // ── Test 7: exportJSON safety net converts remaining string enums ─── + + it("exportJSON converts any remaining string enum values", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("export"); + + const math = mgr.addBlock("export", "MathBlock", "m"); + const block = (math as any).block; + // Force a string value past the normal conversion (simulating edge case) + block.operation = "Max"; + + mgr.addBlock("export", "GeometryOutputBlock", "out"); + const json = mgr.exportJSON("export"); + expect(json).toBeDefined(); + const parsed = JSON.parse(json!); + const mathBlock = parsed.blocks.find((b: any) => b.name === "m"); + expect(mathBlock.operation).toBe(4); // Max = 4 + }); + + // ── Test 8: Connection validation ─────────────────────────────────── + + it("rejects invalid connections", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("conn"); + + const box = mgr.addBlock("conn", "BoxBlock", "box"); + const boxId = (box as any).block.id; + + // Wrong output name + expect(mgr.connectBlocks("conn", boxId, "nonexistent", boxId, "geometry")).toContain("not found"); + + // Non-existent block + expect(mgr.connectBlocks("conn", 999, "geometry", boxId, "geometry")).toContain("not found"); + + // Non-existent geometry + expect(mgr.connectBlocks("nope", boxId, "geometry", boxId, "geometry")).toContain("not found"); + }); + + // ── Test 9: Disconnect input ──────────────────────────────────────── + + it("disconnects inputs correctly", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("disc"); + + const box = mgr.addBlock("disc", "BoxBlock", "box"); + const out = mgr.addBlock("disc", "GeometryOutputBlock", "out"); + const boxId = (box as any).block.id; + const outId = (out as any).block.id; + + mgr.connectBlocks("disc", boxId, "geometry", outId, "geometry"); + expect(mgr.disconnectInput("disc", outId, "geometry")).toBe("OK"); + + const desc = mgr.describeGeometry("disc"); + expect(desc).not.toContain("connected to"); + }); + + // ── Test 10: Remove block cleans up connections ───────────────────── + + it("removeBlock cleans up dangling connections", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("rm"); + + const input = mgr.addBlock("rm", "GeometryInputBlock", "val", { + type: "Float", + value: 5, + }); + const box = mgr.addBlock("rm", "BoxBlock", "box"); + const out = mgr.addBlock("rm", "GeometryOutputBlock", "out"); + + const inputId = (input as any).block.id; + const boxId = (box as any).block.id; + const outId = (out as any).block.id; + + mgr.connectBlocks("rm", inputId, "output", boxId, "size"); + mgr.connectBlocks("rm", boxId, "geometry", outId, "geometry"); + + // Remove input block + expect(mgr.removeBlock("rm", inputId)).toBe("OK"); + + // Box's size input should be disconnected + const issues = mgr.validateGeometry("rm"); + expect(issues.every((i) => !i.includes(`block ${inputId}`))).toBe(true); + }); + + // ── Test 11: Validation catches issues ────────────────────────────── + + it("validation detects missing output block and orphans", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("val"); + + mgr.addBlock("val", "BoxBlock", "orphanBox"); + // No output block + + const issues = mgr.validateGeometry("val"); + expect(issues.some((i) => i.includes("Missing GeometryOutputBlock"))).toBe(true); + expect(issues.some((i) => i.includes("orphan"))).toBe(true); + }); + + // ── Test 12: Registry completeness ────────────────────────────────── + + it("block registry has all expected block types", () => { + const expectedBlocks = [ + "GeometryInputBlock", + "GeometryOutputBlock", + "BoxBlock", + "SphereBlock", + "CylinderBlock", + "PlaneBlock", + "TorusBlock", + "DiscBlock", + "CapsuleBlock", + "IcoSphereBlock", + "GridBlock", + "NullBlock", + "MeshBlock", + "PointListBlock", + "GeometryTransformBlock", + "MergeGeometryBlock", + "BooleanGeometryBlock", + "ComputeNormalsBlock", + "CleanGeometryBlock", + "SubdivideBlock", + "GeometryOptimizeBlock", + "BoundingBlock", + "GeometryInfoBlock", + "GeometryCollectionBlock", + "SetPositionsBlock", + "SetNormalsBlock", + "SetColorsBlock", + "SetUVsBlock", + "SetTangentsBlock", + "SetMaterialIDBlock", + "AggregatorBlock", + "LatticeBlock", + "InstantiateBlock", + "InstantiateLinearBlock", + "InstantiateRadialBlock", + "InstantiateOnFacesBlock", + "InstantiateOnVerticesBlock", + "InstantiateOnVolumeBlock", + "TranslationBlock", + "ScalingBlock", + "RotationXBlock", + "RotationYBlock", + "RotationZBlock", + "AlignBlock", + "MatrixComposeBlock", + "MathBlock", + "GeometryTrigonometryBlock", + "ConditionBlock", + "RandomBlock", + "NoiseBlock", + "GeometryClampBlock", + "GeometryLerpBlock", + "GeometryNLerpBlock", + "GeometrySmoothStepBlock", + "GeometryStepBlock", + "GeometryDotBlock", + "GeometryCrossBlock", + "GeometryLengthBlock", + "GeometryDistanceBlock", + "NormalizeVectorBlock", + "GeometryModBlock", + "GeometryPowBlock", + "GeometryArcTan2Block", + "GeometryReplaceColorBlock", + "GeometryPosterizeBlock", + "GeometryDesaturateBlock", + "MappingBlock", + "MapRangeBlock", + "GeometryRotate2dBlock", + "GeometryEaseBlock", + "GeometryCurveBlock", + "VectorConverterBlock", + "IntFloatConverterBlock", + "DebugBlock", + "GeometryElbowBlock", + "GeometryInterceptorBlock", + "TeleportInBlock", + "TeleportOutBlock", + "GeometryTextureBlock", + "GeometryTextureFetchBlock", + ]; + + for (const blockType of expectedBlocks) { + expect(BlockRegistry[blockType]).toBeDefined(); + expect(BlockRegistry[blockType].className).toBe(blockType); + } + }); + + // ── Test 13: Import/export round-trip ─────────────────────────────── + + it("round-trips through import and export", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("original"); + + mgr.addBlock("original", "BoxBlock", "box"); + mgr.addBlock("original", "GeometryOutputBlock", "out"); + mgr.connectBlocks("original", 1, "geometry", 2, "geometry"); + + const json1 = mgr.exportJSON("original")!; + const parsed1 = JSON.parse(json1); + + // Import into a new name + expect(mgr.importJSON("copy", json1)).toBe("OK"); + const json2 = mgr.exportJSON("copy")!; + const parsed2 = JSON.parse(json2); + + // Same block count and connections + expect(parsed2.blocks.length).toBe(parsed1.blocks.length); + expect(parsed2.outputNodeId).toBe(parsed1.outputNodeId); + }); + + it("rejects invalid geometry JSON on import", () => { + const mgr = new GeometryGraphManager(); + + expect(mgr.importJSON("bad", '{"customType":"WRONG","blocks":[]}')).toContain("Invalid NGE JSON"); + expect(mgr.importJSON("bad", "not json")).toContain("Invalid NGE JSON: parse error."); + }); + + // ── Test 14: Default serialized properties ────────────────────────── + + it("applies defaultSerializedProperties from registry", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("defaults"); + + const box = mgr.addBlock("defaults", "BoxBlock", "box"); + expect((box as any).block.evaluateContext).toBe(false); + + const setPos = mgr.addBlock("defaults", "SetPositionsBlock", "sp"); + expect((setPos as any).block.evaluateContext).toBe(true); + + const cond = mgr.addBlock("defaults", "ConditionBlock", "c"); + expect((cond as any).block.test).toBe(0); + expect((cond as any).block.epsilon).toBe(0); + }); + + // ── Test 15: Editor layout is generated ───────────────────────────── + + it("generates editor layout data on export", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("layout"); + + mgr.addBlock("layout", "BoxBlock", "box"); + mgr.addBlock("layout", "GeometryOutputBlock", "out"); + mgr.connectBlocks("layout", 1, "geometry", 2, "geometry"); + + const json = mgr.exportJSON("layout")!; + const parsed = JSON.parse(json); + + expect(parsed.editorData).toBeDefined(); + expect(Array.isArray(parsed.editorData.locations)).toBe(true); + expect(parsed.editorData.locations.length).toBe(2); + }); + + // ── clearAll ──────────────────────────────────────────────────────── + + it("clearAll removes all geometries and resets state", () => { + const mgr = new GeometryGraphManager(); + mgr.createGeometry("a"); + mgr.createGeometry("b"); + expect(mgr.listGeometries().length).toBe(2); + + mgr.clearAll(); + expect(mgr.listGeometries()).toEqual([]); + expect(mgr.getGeometry("a")).toBeUndefined(); + expect(mgr.getGeometry("b")).toBeUndefined(); + + // Can create new geometries after clear + mgr.createGeometry("c"); + expect(mgr.listGeometries()).toEqual(["c"]); + }); + + it("clearAll on empty manager is a no-op", () => { + const mgr = new GeometryGraphManager(); + mgr.clearAll(); + expect(mgr.listGeometries()).toEqual([]); + }); +}); diff --git a/packages/tools/nge-mcp-server/test/unit/ngeParse.test.ts b/packages/tools/nge-mcp-server/test/unit/ngeParse.test.ts new file mode 100644 index 00000000000..8e56758bb4f --- /dev/null +++ b/packages/tools/nge-mcp-server/test/unit/ngeParse.test.ts @@ -0,0 +1,55 @@ +/** + * Node Geometry MCP Server – Babylon.js Parse Validation + * + * Tests that JSON produced by the Node Geometry MCP server can be parsed by Babylon.js's + * NodeGeometry.parseSerializedObject() without errors. This proves the JSON + * structure is valid and all block types are recognized by Babylon.js. + * + * Note: We skip build() because it actually _evaluates_ the computational graph, + * which requires proper Vector3/Matrix runtime objects. The MCP server's + * responsibility is producing structurally valid JSON — not runtime evaluation. + */ +import { NodeGeometry } from "core/Meshes/Node/nodeGeometry"; + +// Side-effect imports: register ALL NGE block types via RegisterClass +import "core/Meshes/Node/index"; + +import * as fs from "fs"; +import * as path from "path"; + +const NGE_SERVER_DIR = path.resolve(__dirname, "../.."); +const EXAMPLES_DIR = path.resolve(NGE_SERVER_DIR, "examples"); + +function readExampleJson(filename: string): string { + return fs.readFileSync(path.join(EXAMPLES_DIR, filename), "utf-8"); +} + +describe("Node Geometry MCP Server – Babylon.js Parse", () => { + const exampleFiles = ["SimpleBox.json", "ScatteredInstances.json", "NoiseTerrain.json", "BooleanCSG.json", "MathPipeline.json"]; + + for (const file of exampleFiles) { + it(`should parse example geometry: ${file}`, () => { + let jsonStr: string; + try { + jsonStr = readExampleJson(file); + } catch { + console.warn(`Skipping ${file}: not found (run generateExamples.test.ts first)`); + return; + } + + const source = JSON.parse(jsonStr); + + // Use parseSerializedObject (not Parse) to validate structure + // without executing the computational graph + const nodeGeometry = new NodeGeometry(file.replace(".json", "")); + nodeGeometry.parseSerializedObject(source); + + expect(nodeGeometry.attachedBlocks.length).toBeGreaterThan(0); + + // Verify block count matches + expect(nodeGeometry.attachedBlocks.length).toBe(source.blocks.length); + + nodeGeometry.dispose(); + }); + } +}); diff --git a/packages/tools/nge-mcp-server/tsconfig.json b/packages/tools/nge-mcp-server/tsconfig.json new file mode 100644 index 00000000000..2c6df59671a --- /dev/null +++ b/packages/tools/nge-mcp-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "types": ["node"], + "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/nme-mcp-server/NME_MCP_SESSION_BRIDGE.md b/packages/tools/nme-mcp-server/NME_MCP_SESSION_BRIDGE.md new file mode 100644 index 00000000000..346de4640a3 --- /dev/null +++ b/packages/tools/nme-mcp-server/NME_MCP_SESSION_BRIDGE.md @@ -0,0 +1,142 @@ +# NME ↔ MCP Live Session Bridge — Agent Prompt + +## Context + +You are working in the Babylon.js monorepo at `packages/tools/`. There are two targets: + +1. **`packages/tools/nme-mcp-server/`** — the Node Material Editor MCP server (runs locally via stdio) +2. **`packages/tools/nodeEditor/`** — the Node Material Editor UI (React/TypeScript, eventually hosted at `nme.babylonjs.com`) + +The goal is to implement a **bidirectional live session** between the two: the MCP server broadcasts changes to the editor, and the editor can push user changes back to the MCP server. + +Read the following files **before writing any code** to understand existing patterns: + +- `packages/tools/nme-mcp-server/src/index.ts` +- `packages/tools/nme-mcp-server/src/materialGraph.ts` +- `packages/tools/scene-mcp-server/src/previewServer.ts` — the SSE/HTTP server pattern to replicate +- `packages/tools/nodeEditor/src/globalState.ts` +- `packages/tools/nodeEditor/src/graphEditor.tsx` +- `packages/tools/nodeEditor/src/components/propertyTab/propertyTabComponent.tsx` + +--- + +## Part 1 — MCP Server: Session Server (`nme-mcp-server`) + +Create a new file `packages/tools/nme-mcp-server/src/sessionServer.ts`. Model it closely on `packages/tools/scene-mcp-server/src/previewServer.ts` (zero new npm dependencies — use only Node built-ins: `http`, `crypto`). + +**The session server is a local HTTP server** with the following routes. It maintains a `Map` so one server instance can serve multiple simultaneous sessions (one per material). + +Routes: + +- `GET /session/:id/events` — **SSE stream**. The editor subscribes here. Whenever the MCP updates the material for this session, it sends a `data:` event with the full material JSON. Send a keepalive comment (`: ping\n\n`) every 15 seconds. +- `GET /session/:id/material` — Returns the current material JSON (for initial load on connect). Responds with `Content-Type: application/json`. +- `POST /session/:id/material` — The editor posts updated JSON here after the user makes changes. The server parses the JSON, calls `manager.importJSON(materialName, json)` to replace the in-memory graph, then broadcasts the update to all other SSE subscribers on the same session. Responds `200 OK` or an error. +- `GET /` — A plain-text status page listing active sessions. + +Add CORS headers on every response (`Access-Control-Allow-Origin: *`). Handle `OPTIONS` preflight. + +**Singleton lifecycle** (same pattern as `previewServer.ts`): + +- `startSessionServer(manager: MaterialGraphManager, port?: number): Promise` — starts the server if not running, returns the port +- `stopSessionServer(): Promise` +- `isSessionServerRunning(): boolean` +- `createSession(materialName: string): string` — generates a random 8-char alphanumeric session ID, stores the mapping, returns the ID +- `notifyMaterialUpdate(sessionId: string)` — pushes the latest JSON to all SSE subscribers for that session (called internally whenever an MCP tool modifies a material that has an active session) +- `getSessionUrl(sessionId: string, port: number): string` — returns `http://localhost:{port}/session/{id}` + +The `MaterialGraphManager` reference must be passed in and stored (as a module-level variable, same as `previewServer.ts` stores `_manager`). + +--- + +## Part 2 — MCP Server: Hook into existing tools (`index.ts`) + +In `packages/tools/nme-mcp-server/src/index.ts`: + +1. Import `startSessionServer`, `createSession`, `notifyMaterialUpdate`, `getSessionUrl`, `isSessionServerRunning` from `./sessionServer.js`. + +2. **Modify `create_material` tool**: after successfully creating a material, automatically: + + - Call `startSessionServer(manager)` if not already running (use port 3001 by default) + - Call `createSession(materialName)` to get a session ID + - Append to the returned text: `\n\nMCP Session URL: http://localhost:3001/session/{sessionId}\nPaste this URL in the Node Material Editor's "Connect to MCP Session" panel to see live updates.` + +3. **Add a `get_session_url` tool** that, given a `materialName`, finds its active session and returns the session URL. If no session exists for that material, creates one (starting the server if needed). + +4. **After every tool that modifies a material** (i.e. `add_block`, `remove_block`, `connect_blocks`, `disconnect_input`, `set_block_properties`, `add_blocks_batch`, `connect_blocks_batch`), call `notifyMaterialUpdate(sessionId)` for any active session associated with that material. To do this efficiently, keep a reverse `Map` in `sessionServer.ts` and expose a `getSessionForMaterial(materialName): string | undefined` helper. + +--- + +## Part 3 — Editor UI: "Connect to MCP Session" panel (`nodeEditor`) + +In `packages/tools/nodeEditor/src/globalState.ts`, add: + +```ts +mcpSessionUrl: string | null = null; +mcpSessionConnected: boolean = false; +onMcpSessionStateChangedObservable: Observable; // fires on connect/disconnect +``` + +Create a new file `packages/tools/nodeEditor/src/components/mcpSession/mcpSessionComponent.tsx`: + +This is a small React component (functional, with hooks) that renders: + +- A text input for the session URL (placeholder: `http://localhost:3001/session/ABC123`) +- A **Connect** button (when disconnected) / **Disconnect** button (when connected) +- A status badge: green "Connected" / grey "Disconnected" +- A **Push to MCP** button (when connected): immediately POSTs the current serialized material JSON to `{sessionUrl}/material` using `fetch` +- A small note in italic: _"Do not edit the material in the editor while the AI agent is working — changes may conflict."_ + +**Connect behavior** (triggered by clicking Connect): + +1. Store the URL in `globalState.mcpSessionUrl` +2. Make a `GET {sessionUrl}/material` request; if successful, call `NodeMaterial.Parse(json, scene)` to load the material and `globalState.onNewNodeCreatedObservable` etc. to refresh the editor (look at how `import_from_snippet` works in the existing property tab for the right sequence of calls to re-initialize the editor with a new `NodeMaterial` instance) +3. Open an `EventSource` SSE connection to `{sessionUrl}/events` +4. On each SSE `message` event: parse the JSON, reload the material the same way as step 2 +5. On SSE `error`: update status to disconnected +6. Set `globalState.mcpSessionConnected = true` and notify `onMcpSessionStateChangedObservable` + +**Disconnect behavior**: close the `EventSource`, set `mcpSessionConnected = false`. + +**Push to MCP behavior**: serialize current material via `SerializationTools.Serialize(globalState.nodeMaterial, globalState)`, POST JSON string to `{sessionUrl}/material`. + +Store the `EventSource` instance in a `useRef` so it can be closed on disconnect/unmount. + +--- + +## Part 4 — Wire the component into the editor UI + +In `packages/tools/nodeEditor/src/graphEditor.tsx`, add the `` to the left or bottom of the property panel (look at how other sidebar components are mounted). It should always be visible regardless of what is selected in the graph. + +--- + +## Part 5 — Warning Documentation + +In `packages/tools/nme-mcp-server/src/sessionServer.ts`, add a JSDoc comment at the top of the file: + +``` + * **No lock mechanism**: The MCP server and the user can both modify the material, + * but NOT simultaneously. If the user edits the material in the editor while the AI + * agent is calling MCP tools, their changes will be overwritten by the next agent + * push. Users should finish their own edits before asking the agent to continue, + * and vice versa. +``` + +--- + +## Implementation Notes + +- The session server uses **port 3001** by default. If the port is in use, try 3002, 3003, up to 3010 (same auto-increment pattern as `previewServer.ts` if it does that, otherwise just fail with a clear error message). +- The `EventSource` in the browser connects to the MCP server running **locally** — this only works when the user is running the MCP server on their own machine, which is always the case for MCP. +- SSE requires no extra npm packages (browser-native `EventSource` on client, standard HTTP chunked response on server). +- When reloading the material in the editor from incoming JSON, reuse whatever code path the existing "Load" / `import_from_snippet` feature uses to swap in a new `NodeMaterial` — do not invent a new code path. +- Run `npm run lint` after implementation and fix any lint errors. +- The `MaterialGraphManager` type is the default export / main class in `materialGraph.ts` — check the exact type name before referencing it. + +--- + +## What NOT to do + +- Do not add any npm dependencies beyond what's already in `nme-mcp-server/package.json`. +- Do not modify `materialGraph.ts` for this feature — all integration is in `index.ts` and the new `sessionServer.ts`. +- Do not add a lock mechanism — that is explicitly deferred. +- Do not implement this for any other MCP server (NGE, NPE, GUI, etc.) — NME only. diff --git a/packages/tools/nme-mcp-server/README.md b/packages/tools/nme-mcp-server/README.md new file mode 100644 index 00000000000..2651aeda39f --- /dev/null +++ b/packages/tools/nme-mcp-server/README.md @@ -0,0 +1,42 @@ +# @tools/nme-mcp-server + +MCP server for AI-driven Babylon.js Node Material authoring. + +## Provides + +- create, inspect, and delete in-memory Node Material graphs +- add blocks, connect ports, and update block properties +- import and export NME JSON +- import from and save to Babylon.js snippets +- optional live session bridge support for the Node Material Editor + +## Typical Workflow + +```text +create_material -> add_block -> connect_blocks -> set_block_properties -> validate_material -> export_material_json +``` + +For scene integration, export the material JSON and hand it to Scene MCP through `add_material`. + +## Binary + +```bash +babylonjs-node-material +``` + +## Build And Run + +```bash +npm run build -w @tools/nme-mcp-server +npm run start -w @tools/nme-mcp-server +``` + +## Integration + +The exported NME JSON can be handed to the Scene MCP server through `add_material`, either inline or via `nmeJsonFile`. + +## Related Files + +- `src/index.ts`: MCP tool registration +- `src/materialGraph.ts`: in-memory graph manager +- `src/sessionServer.ts`: optional live session bridge diff --git a/packages/tools/nme-mcp-server/examples/Bricks.json b/packages/tools/nme-mcp-server/examples/Bricks.json new file mode 100644 index 00000000000..9dc7d91a7c3 --- /dev/null +++ b/packages/tools/nme-mcp-server/examples/Bricks.json @@ -0,0 +1,2009 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "normal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "world", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 1, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "worldViewProjection", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 6, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "cameraPosition", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 7, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "view", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 2, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 8, + "name": "brickCountX", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 6, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 9, + "name": "brickCountY", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 12, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 10, + "name": "mortarWidth", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.05, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 11, + "name": "halfConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.5, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 12, + "name": "twoConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 2, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 13, + "name": "oneConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 14, + "name": "roughness", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.92, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 15, + "name": "metallic", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 16, + "name": "noiseAmount", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.12, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 17, + "name": "zeroConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 18, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 21, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 19, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 51, + "targetConnectionName": "lighting" + }, + { + "name": "a", + "displayName": "a", + "inputName": "a", + "targetBlockId": 13, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.TransformBlock", + "id": 20, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 21, + "name": "clipPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 20, + "targetConnectionName": "xyz" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 22, + "name": "splitUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 23, + "name": "scaledU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 22, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 24, + "name": "scaledV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 22, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 25, + "name": "rowIndex", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 24, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 6 + }, + { + "customType": "BABYLON.ModBlock", + "id": 26, + "name": "rowMod2", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 25, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 12, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 27, + "name": "staggerOffset", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 26, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 28, + "name": "staggeredU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 23, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 27, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 29, + "name": "fractU", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 28, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 30, + "name": "fractV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 24, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.OneMinusBlock", + "id": 31, + "name": "oneMinusFractU", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 29, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MinBlock", + "id": 32, + "name": "distFromEdgeU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 29, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 31, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.OneMinusBlock", + "id": 33, + "name": "oneMinusFractV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 30, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MinBlock", + "id": 34, + "name": "distFromEdgeV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 30, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 33, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SmoothStepBlock", + "id": 35, + "name": "brickMaskU", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 32, + "targetConnectionName": "output" + }, + { + "name": "edge0", + "displayName": "edge0", + "inputName": "edge0", + "targetBlockId": 17, + "targetConnectionName": "output" + }, + { + "name": "edge1", + "displayName": "edge1", + "inputName": "edge1", + "targetBlockId": 10, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SmoothStepBlock", + "id": 36, + "name": "brickMaskV", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 34, + "targetConnectionName": "output" + }, + { + "name": "edge0", + "displayName": "edge0", + "inputName": "edge0", + "targetBlockId": 17, + "targetConnectionName": "output" + }, + { + "name": "edge1", + "displayName": "edge1", + "inputName": "edge1", + "targetBlockId": 10, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 37, + "name": "brickMask", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 35, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 36, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 38, + "name": "floorU", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 28, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 6 + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 39, + "name": "floorV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 24, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 6 + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 40, + "name": "cellSeed", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 38, + "targetConnectionName": "output" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 39, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 11, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 41, + "name": "brickVariation", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 40, + "targetConnectionName": "xyz" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 42, + "name": "colorShift", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 41, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 43, + "name": "noiseHalf", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 41, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 44, + "name": "noiseRemapped", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 43, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ClampBlock", + "id": 45, + "name": "noiseClamped", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 44, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "minimum": 0, + "maximum": 1 + }, + { + "customType": "BABYLON.GradientBlock", + "id": 46, + "name": "brickGradient", + "target": 4, + "inputs": [ + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 45, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "colorSteps": [ + { + "color": { + "b": 0.08, + "g": 0.12, + "r": 0.35 + }, + "step": 0 + }, + { + "color": { + "b": 0.1, + "g": 0.18, + "r": 0.55 + }, + "step": 0.3 + }, + { + "color": { + "b": 0.12, + "g": 0.25, + "r": 0.65 + }, + "step": 0.6 + }, + { + "color": { + "b": 0.15, + "g": 0.3, + "r": 0.7 + }, + "step": 0.85 + }, + { + "color": { + "b": 0.18, + "g": 0.35, + "r": 0.75 + }, + "step": 1 + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 47, + "name": "mortarColor", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 0.7, + 0.68, + 0.65 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.LerpBlock", + "id": 48, + "name": "finalColor", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 47, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 46, + "targetConnectionName": "output" + }, + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 37, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 49, + "name": "mortarRoughness", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.7, + "valueType": "number" + }, + { + "customType": "BABYLON.LerpBlock", + "id": 50, + "name": "finalRoughness", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 49, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 14, + "targetConnectionName": "output" + }, + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 37, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.PBRMetallicRoughnessBlock", + "id": 51, + "name": "pbr", + "target": 3, + "inputs": [ + { + "name": "worldPosition", + "displayName": "worldPosition", + "inputName": "worldPosition", + "targetBlockId": 20, + "targetConnectionName": "output" + }, + { + "name": "worldNormal", + "displayName": "worldNormal", + "inputName": "worldNormal", + "targetBlockId": 52, + "targetConnectionName": "output" + }, + { + "name": "view", + "displayName": "view", + "inputName": "view", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "cameraPosition", + "displayName": "cameraPosition", + "inputName": "cameraPosition", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "perturbedNormal", + "displayName": "perturbedNormal" + }, + { + "name": "baseColor", + "displayName": "baseColor", + "inputName": "baseColor", + "targetBlockId": 48, + "targetConnectionName": "output" + }, + { + "name": "metallic", + "displayName": "metallic", + "inputName": "metallic", + "targetBlockId": 15, + "targetConnectionName": "output" + }, + { + "name": "roughness", + "displayName": "roughness", + "inputName": "roughness", + "targetBlockId": 50, + "targetConnectionName": "output" + }, + { + "name": "ambientOcc", + "displayName": "ambientOcc" + }, + { + "name": "opacity", + "displayName": "opacity" + }, + { + "name": "indexOfRefraction", + "displayName": "indexOfRefraction" + }, + { + "name": "ambientColor", + "displayName": "ambientColor" + }, + { + "name": "reflection", + "displayName": "reflection" + }, + { + "name": "clearcoat", + "displayName": "clearcoat" + }, + { + "name": "sheen", + "displayName": "sheen" + }, + { + "name": "subsurface", + "displayName": "subsurface" + }, + { + "name": "anisotropy", + "displayName": "anisotropy" + }, + { + "name": "iridescence", + "displayName": "iridescence" + } + ], + "outputs": [ + { + "name": "ambientClr", + "displayName": "ambientClr" + }, + { + "name": "diffuseDir", + "displayName": "diffuseDir" + }, + { + "name": "specularDir", + "displayName": "specularDir" + }, + { + "name": "diffuseInd", + "displayName": "diffuseInd" + }, + { + "name": "specularInd", + "displayName": "specularInd" + }, + { + "name": "specularEnvironmentR0", + "displayName": "specularEnvironmentR0" + }, + { + "name": "specularEnvironmentR90", + "displayName": "specularEnvironmentR90" + }, + { + "name": "refraction", + "displayName": "refraction" + }, + { + "name": "clearcoat", + "displayName": "clearcoat" + }, + { + "name": "sheen", + "displayName": "sheen" + }, + { + "name": "lighting", + "displayName": "lighting" + }, + { + "name": "shadow", + "displayName": "shadow" + }, + { + "name": "alpha", + "displayName": "alpha" + } + ], + "lightFalloff": 0, + "useAlphaTest": false, + "alphaTestCutoff": 0.5, + "useAlphaBlending": false, + "useRadianceOverAlpha": true, + "useSpecularOverAlpha": true, + "enableSpecularAntiAliasing": false, + "realTimeFiltering": false, + "realTimeFilteringQuality": 8, + "useEnergyConservation": true, + "useRadianceOcclusion": true, + "useHorizonOcclusion": true, + "unlit": false, + "forceNormalForward": false, + "debugMode": 0, + "debugLimit": -1, + "debugFactor": 1, + "generateOnlyFragmentCode": false, + "directIntensity": 1, + "environmentIntensity": 1, + "specularIntensity": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 52, + "name": "worldNormal", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + } + ], + "outputNodes": [ + 18, + 19 + ], + "comment": "Procedural brick wall material with mortar lines and color variation", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 4760, + "y": 0 + }, + { + "blockId": 2, + "x": 4760, + "y": 1260 + }, + { + "blockId": 3, + "x": 340, + "y": 0 + }, + { + "blockId": 4, + "x": 4760, + "y": 180 + }, + { + "blockId": 5, + "x": 5100, + "y": 0 + }, + { + "blockId": 6, + "x": 5100, + "y": 360 + }, + { + "blockId": 7, + "x": 5100, + "y": 540 + }, + { + "blockId": 8, + "x": 1700, + "y": 0 + }, + { + "blockId": 9, + "x": 680, + "y": 0 + }, + { + "blockId": 10, + "x": 4080, + "y": 360 + }, + { + "blockId": 11, + "x": 1700, + "y": 180 + }, + { + "blockId": 12, + "x": 1360, + "y": 0 + }, + { + "blockId": 13, + "x": 5440, + "y": 180 + }, + { + "blockId": 14, + "x": 4760, + "y": 900 + }, + { + "blockId": 15, + "x": 5100, + "y": 720 + }, + { + "blockId": 16, + "x": 0, + "y": 0 + }, + { + "blockId": 17, + "x": 4080, + "y": 540 + }, + { + "blockId": 18, + "x": 5780, + "y": 0 + }, + { + "blockId": 19, + "x": 5780, + "y": 180 + }, + { + "blockId": 20, + "x": 5100, + "y": 180 + }, + { + "blockId": 21, + "x": 5440, + "y": 0 + }, + { + "blockId": 22, + "x": 680, + "y": 180 + }, + { + "blockId": 23, + "x": 2040, + "y": 0 + }, + { + "blockId": 24, + "x": 1020, + "y": 0 + }, + { + "blockId": 25, + "x": 1360, + "y": 180 + }, + { + "blockId": 26, + "x": 1700, + "y": 360 + }, + { + "blockId": 27, + "x": 2040, + "y": 180 + }, + { + "blockId": 28, + "x": 2380, + "y": 0 + }, + { + "blockId": 29, + "x": 3400, + "y": 180 + }, + { + "blockId": 30, + "x": 3400, + "y": 360 + }, + { + "blockId": 31, + "x": 3740, + "y": 180 + }, + { + "blockId": 32, + "x": 4080, + "y": 180 + }, + { + "blockId": 33, + "x": 3740, + "y": 360 + }, + { + "blockId": 34, + "x": 4080, + "y": 720 + }, + { + "blockId": 35, + "x": 4420, + "y": 180 + }, + { + "blockId": 36, + "x": 4420, + "y": 360 + }, + { + "blockId": 37, + "x": 4760, + "y": 720 + }, + { + "blockId": 38, + "x": 2720, + "y": 0 + }, + { + "blockId": 39, + "x": 2720, + "y": 180 + }, + { + "blockId": 40, + "x": 3060, + "y": 0 + }, + { + "blockId": 41, + "x": 3400, + "y": 0 + }, + { + "blockId": 42, + "x": 0, + "y": 180 + }, + { + "blockId": 43, + "x": 3740, + "y": 0 + }, + { + "blockId": 44, + "x": 4080, + "y": 0 + }, + { + "blockId": 45, + "x": 4420, + "y": 0 + }, + { + "blockId": 46, + "x": 4760, + "y": 360 + }, + { + "blockId": 47, + "x": 4760, + "y": 540 + }, + { + "blockId": 48, + "x": 5100, + "y": 900 + }, + { + "blockId": 49, + "x": 4760, + "y": 1080 + }, + { + "blockId": 50, + "x": 5100, + "y": 1080 + }, + { + "blockId": 51, + "x": 5440, + "y": 360 + }, + { + "blockId": 52, + "x": 5100, + "y": 1260 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/examples/BricksAndMortar.json b/packages/tools/nme-mcp-server/examples/BricksAndMortar.json new file mode 100644 index 00000000000..e813d010a26 --- /dev/null +++ b/packages/tools/nme-mcp-server/examples/BricksAndMortar.json @@ -0,0 +1,2425 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "attributeName": "uv", + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "normal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "attributeName": "normal", + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "attributeName": "position", + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "worldViewProjection", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 128, + "systemValue": 6, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "world", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 128, + "systemValue": 1, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "view", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 128, + "systemValue": 2, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "cameraPosition", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "systemValue": 7, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.TransformBlock", + "id": 8, + "name": "wvpTransform", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 9, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 10, + "name": "worldNormal", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.InputBlock", + "id": 11, + "name": "brickScale", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 8, + 4 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 12, + "name": "scaledUV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 13, + "name": "splitUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 12, + "targetConnectionName": "output" + }, + { + "name": "xyz ", + "displayName": "xyz " + }, + { + "name": "xy ", + "displayName": "xy " + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 14, + "name": "floorU", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 13, + "targetConnectionName": "x" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 6 + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 15, + "name": "floorV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 13, + "targetConnectionName": "y" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 6 + }, + { + "customType": "BABYLON.InputBlock", + "id": 16, + "name": "half", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.5, + "valueType": "number" + }, + { + "customType": "BABYLON.ModBlock", + "id": 17, + "name": "rowMod", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 15, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 64, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.StepBlock", + "id": 18, + "name": "rowStep", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 17, + "targetConnectionName": "output" + }, + { + "name": "edge", + "displayName": "edge", + "inputName": "edge", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 19, + "name": "rowOffset", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 18, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 20, + "name": "offsetU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 13, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 19, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 21, + "name": "fractU", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 20, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 22, + "name": "fractV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 13, + "targetConnectionName": "y" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.InputBlock", + "id": 23, + "name": "mortarWidth", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.05, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 24, + "name": "one", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1, + "valueType": "number" + }, + { + "customType": "BABYLON.SubtractBlock", + "id": 25, + "name": "mortarInv", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 24, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 23, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.StepBlock", + "id": 26, + "name": "stepU", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 21, + "targetConnectionName": "output" + }, + { + "name": "edge", + "displayName": "edge", + "inputName": "edge", + "targetBlockId": 23, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.StepBlock", + "id": 27, + "name": "stepV", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 22, + "targetConnectionName": "output" + }, + { + "name": "edge", + "displayName": "edge", + "inputName": "edge", + "targetBlockId": 23, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.OneMinusBlock", + "id": 28, + "name": "invStepU", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 26, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.OneMinusBlock", + "id": 29, + "name": "invStepV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 27, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.StepBlock", + "id": 30, + "name": "stepUHigh", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 21, + "targetConnectionName": "output" + }, + { + "name": "edge", + "displayName": "edge", + "inputName": "edge", + "targetBlockId": 25, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.StepBlock", + "id": 31, + "name": "stepVHigh", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 22, + "targetConnectionName": "output" + }, + { + "name": "edge", + "displayName": "edge", + "inputName": "edge", + "targetBlockId": 25, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 32, + "name": "bandU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 26, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 65, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 33, + "name": "bandV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 27, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 66, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 34, + "name": "brickMask", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 32, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 33, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 35, + "name": "brickColor", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 0.72, + 0.31, + 0.22 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 36, + "name": "mortarColor", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 0.78, + 0.76, + 0.7 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.LerpBlock", + "id": 37, + "name": "colorMix", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 36, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 35, + "targetConnectionName": "output" + }, + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 34, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 38, + "name": "mossColor", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 0.15, + 0.45, + 0.12 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 39, + "name": "hashSeed1", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 12.9898, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 40, + "name": "hashSeed2", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 78.233, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 41, + "name": "hashMulU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 14, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 39, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 42, + "name": "hashMulV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 15, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 40, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 43, + "name": "hashSum", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 41, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 42, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 44, + "name": "sinHash", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 43, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 1 + }, + { + "customType": "BABYLON.InputBlock", + "id": 45, + "name": "hashBig", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 43758.5453, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 46, + "name": "hashScaled", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 44, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 45, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 47, + "name": "fractHash", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 46, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.InputBlock", + "id": 48, + "name": "mossThreshold", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.65, + "valueType": "number" + }, + { + "customType": "BABYLON.StepBlock", + "id": 49, + "name": "mossStep", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 47, + "targetConnectionName": "output" + }, + { + "name": "edge", + "displayName": "edge", + "inputName": "edge", + "targetBlockId": 48, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.LerpBlock", + "id": 50, + "name": "mossMix", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 37, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 38, + "targetConnectionName": "output" + }, + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 49, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 51, + "name": "brickRoughness", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.85, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 52, + "name": "mortarRoughness", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.95, + "valueType": "number" + }, + { + "customType": "BABYLON.LerpBlock", + "id": 53, + "name": "roughnessMix", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 52, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 51, + "targetConnectionName": "output" + }, + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 34, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 54, + "name": "metallicVal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.PBRMetallicRoughnessBlock", + "id": 55, + "name": "PBR", + "target": 3, + "inputs": [ + { + "name": "worldPosition", + "displayName": "worldPosition", + "inputName": "worldPosition", + "targetBlockId": 9, + "targetConnectionName": "output" + }, + { + "name": "worldNormal", + "displayName": "worldNormal", + "inputName": "worldNormal", + "targetBlockId": 10, + "targetConnectionName": "output" + }, + { + "name": "view", + "displayName": "view", + "inputName": "view", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "cameraPosition", + "displayName": "cameraPosition", + "inputName": "cameraPosition", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "perturbedNormal", + "displayName": "perturbedNormal", + "inputName": "perturbedNormal", + "targetBlockId": 61, + "targetConnectionName": "output" + }, + { + "name": "baseColor", + "displayName": "baseColor", + "inputName": "baseColor", + "targetBlockId": 50, + "targetConnectionName": "output" + }, + { + "name": "metallic", + "displayName": "metallic", + "inputName": "metallic", + "targetBlockId": 54, + "targetConnectionName": "output" + }, + { + "name": "roughness", + "displayName": "roughness", + "inputName": "roughness", + "targetBlockId": 57, + "targetConnectionName": "output" + }, + { + "name": "ambientOcc", + "displayName": "ambientOcc" + }, + { + "name": "opacity", + "displayName": "opacity" + }, + { + "name": "indexOfRefraction", + "displayName": "indexOfRefraction" + }, + { + "name": "ambientColor", + "displayName": "ambientColor" + }, + { + "name": "reflection", + "displayName": "reflection" + }, + { + "name": "clearcoat", + "displayName": "clearcoat" + }, + { + "name": "sheen", + "displayName": "sheen" + }, + { + "name": "subsurface", + "displayName": "subsurface" + }, + { + "name": "anisotropy", + "displayName": "anisotropy" + }, + { + "name": "iridescence", + "displayName": "iridescence" + } + ], + "outputs": [ + { + "name": "ambientClr", + "displayName": "ambientClr" + }, + { + "name": "diffuseDir", + "displayName": "diffuseDir" + }, + { + "name": "specularDir", + "displayName": "specularDir" + }, + { + "name": "clearcoatDir", + "displayName": "clearcoatDir" + }, + { + "name": "sheenDir", + "displayName": "sheenDir" + }, + { + "name": "diffuseInd", + "displayName": "diffuseInd" + }, + { + "name": "specularInd", + "displayName": "specularInd" + }, + { + "name": "clearcoatInd", + "displayName": "clearcoatInd" + }, + { + "name": "sheenInd", + "displayName": "sheenInd" + }, + { + "name": "refraction", + "displayName": "refraction" + }, + { + "name": "lighting", + "displayName": "lighting" + }, + { + "name": "shadow", + "displayName": "shadow" + }, + { + "name": "alpha", + "displayName": "alpha" + } + ], + "lightFalloff": 0, + "useAlphaTest": false, + "alphaTestCutoff": 0.5, + "useAlphaBlending": false, + "useRadianceOverAlpha": true, + "useSpecularOverAlpha": true, + "enableSpecularAntiAliasing": false, + "realTimeFiltering": false, + "realTimeFilteringQuality": 8, + "useEnergyConservation": true, + "useRadianceOcclusion": true, + "useHorizonOcclusion": true, + "unlit": false, + "forceNormalForward": false, + "debugMode": 0, + "debugLimit": -1, + "debugFactor": 1, + "generateOnlyFragmentCode": false, + "directIntensity": 1, + "environmentIntensity": 1, + "specularIntensity": 1 + }, + { + "customType": "BABYLON.InputBlock", + "id": 56, + "name": "mossRoughness", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.95, + "valueType": "number" + }, + { + "customType": "BABYLON.LerpBlock", + "id": 57, + "name": "finalRoughness", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 53, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 56, + "targetConnectionName": "output" + }, + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 49, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 58, + "name": "bumpStrength", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.15, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 59, + "name": "bumpOffset", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 34, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 58, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 60, + "name": "bumpVec", + "target": 4, + "inputs": [ + { + "name": "xyzw ", + "displayName": "xyzw " + }, + { + "name": "xyz ", + "displayName": "xyz " + }, + { + "name": "xy ", + "displayName": "xy " + }, + { + "name": "zw ", + "displayName": "zw " + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 59, + "targetConnectionName": "output" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 24, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 59, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.NormalizeBlock", + "id": 61, + "name": "bumpNorm", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 60, + "targetConnectionName": "xyz" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 62, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 63, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 55, + "targetConnectionName": "lighting" + }, + { + "name": "a", + "displayName": "a" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.InputBlock", + "id": 64, + "name": "two", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 2, + "valueType": "number" + }, + { + "customType": "BABYLON.OneMinusBlock", + "id": 65, + "name": "invStepUHigh", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 30, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.OneMinusBlock", + "id": 66, + "name": "invStepVHigh", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 31, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + } + ], + "outputNodes": [ + 62, + 63 + ], + "comment": "Procedural brick wall material with moss effect and PBR lighting", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 340, + "y": 0 + }, + { + "blockId": 2, + "x": 5100, + "y": 360 + }, + { + "blockId": 3, + "x": 5100, + "y": 0 + }, + { + "blockId": 4, + "x": 5440, + "y": 0 + }, + { + "blockId": 5, + "x": 5100, + "y": 180 + }, + { + "blockId": 6, + "x": 5440, + "y": 180 + }, + { + "blockId": 7, + "x": 5440, + "y": 360 + }, + { + "blockId": 8, + "x": 5780, + "y": 0 + }, + { + "blockId": 9, + "x": 5440, + "y": 540 + }, + { + "blockId": 10, + "x": 5440, + "y": 720 + }, + { + "blockId": 11, + "x": 340, + "y": 180 + }, + { + "blockId": 12, + "x": 680, + "y": 0 + }, + { + "blockId": 13, + "x": 1020, + "y": 0 + }, + { + "blockId": 14, + "x": 3060, + "y": 0 + }, + { + "blockId": 15, + "x": 1360, + "y": 0 + }, + { + "blockId": 16, + "x": 1700, + "y": 0 + }, + { + "blockId": 17, + "x": 1700, + "y": 180 + }, + { + "blockId": 18, + "x": 2040, + "y": 0 + }, + { + "blockId": 19, + "x": 2380, + "y": 0 + }, + { + "blockId": 20, + "x": 2720, + "y": 180 + }, + { + "blockId": 21, + "x": 3060, + "y": 540 + }, + { + "blockId": 22, + "x": 3060, + "y": 900 + }, + { + "blockId": 23, + "x": 2720, + "y": 0 + }, + { + "blockId": 24, + "x": 2720, + "y": 360 + }, + { + "blockId": 25, + "x": 3060, + "y": 720 + }, + { + "blockId": 26, + "x": 3740, + "y": 180 + }, + { + "blockId": 27, + "x": 3740, + "y": 540 + }, + { + "blockId": 28, + "x": 0, + "y": 0 + }, + { + "blockId": 29, + "x": 0, + "y": 180 + }, + { + "blockId": 30, + "x": 3400, + "y": 360 + }, + { + "blockId": 31, + "x": 3400, + "y": 540 + }, + { + "blockId": 32, + "x": 4080, + "y": 360 + }, + { + "blockId": 33, + "x": 4080, + "y": 540 + }, + { + "blockId": 34, + "x": 4420, + "y": 180 + }, + { + "blockId": 35, + "x": 4760, + "y": 0 + }, + { + "blockId": 36, + "x": 4760, + "y": 180 + }, + { + "blockId": 37, + "x": 5100, + "y": 540 + }, + { + "blockId": 38, + "x": 5100, + "y": 720 + }, + { + "blockId": 39, + "x": 3060, + "y": 180 + }, + { + "blockId": 40, + "x": 3060, + "y": 360 + }, + { + "blockId": 41, + "x": 3400, + "y": 0 + }, + { + "blockId": 42, + "x": 3400, + "y": 180 + }, + { + "blockId": 43, + "x": 3740, + "y": 0 + }, + { + "blockId": 44, + "x": 4080, + "y": 0 + }, + { + "blockId": 45, + "x": 4080, + "y": 180 + }, + { + "blockId": 46, + "x": 4420, + "y": 0 + }, + { + "blockId": 47, + "x": 4760, + "y": 360 + }, + { + "blockId": 48, + "x": 4760, + "y": 540 + }, + { + "blockId": 49, + "x": 5100, + "y": 900 + }, + { + "blockId": 50, + "x": 5440, + "y": 900 + }, + { + "blockId": 51, + "x": 4760, + "y": 720 + }, + { + "blockId": 52, + "x": 4760, + "y": 900 + }, + { + "blockId": 53, + "x": 5100, + "y": 1080 + }, + { + "blockId": 54, + "x": 5440, + "y": 1080 + }, + { + "blockId": 55, + "x": 5780, + "y": 180 + }, + { + "blockId": 56, + "x": 5100, + "y": 1260 + }, + { + "blockId": 57, + "x": 5440, + "y": 1260 + }, + { + "blockId": 58, + "x": 4420, + "y": 360 + }, + { + "blockId": 59, + "x": 4760, + "y": 1080 + }, + { + "blockId": 60, + "x": 5100, + "y": 1440 + }, + { + "blockId": 61, + "x": 5440, + "y": 1440 + }, + { + "blockId": 62, + "x": 6120, + "y": 0 + }, + { + "blockId": 63, + "x": 6120, + "y": 180 + }, + { + "blockId": 64, + "x": 1360, + "y": 180 + }, + { + "blockId": 65, + "x": 3740, + "y": 360 + }, + { + "blockId": 66, + "x": 3740, + "y": 720 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/examples/CheckeredBoard.json b/packages/tools/nme-mcp-server/examples/CheckeredBoard.json new file mode 100644 index 00000000000..d1a5ba8677d --- /dev/null +++ b/packages/tools/nme-mcp-server/examples/CheckeredBoard.json @@ -0,0 +1,1115 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "attributeName": "uv", + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "normal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "attributeName": "normal", + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "attributeName": "position", + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "worldViewProjection", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 6, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "world", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 1, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "view", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 2, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "cameraPosition", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 7, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.TransformBlock", + "id": 8, + "name": "wvpTransform", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 9, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 10, + "name": "worldNormal", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 11, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 12, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 13, + "targetConnectionName": "lighting" + }, + { + "name": "a", + "displayName": "a" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.PBRMetallicRoughnessBlock", + "id": 13, + "name": "PBR", + "target": 3, + "inputs": [ + { + "name": "worldPosition", + "displayName": "worldPosition", + "inputName": "worldPosition", + "targetBlockId": 9, + "targetConnectionName": "output" + }, + { + "name": "worldNormal", + "displayName": "worldNormal", + "inputName": "worldNormal", + "targetBlockId": 10, + "targetConnectionName": "output" + }, + { + "name": "view", + "displayName": "view", + "inputName": "view", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "cameraPosition", + "displayName": "cameraPosition", + "inputName": "cameraPosition", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "perturbedNormal", + "displayName": "perturbedNormal" + }, + { + "name": "baseColor", + "displayName": "baseColor", + "inputName": "baseColor", + "targetBlockId": 24, + "targetConnectionName": "output" + }, + { + "name": "metallic", + "displayName": "metallic", + "inputName": "metallic", + "targetBlockId": 26, + "targetConnectionName": "output" + }, + { + "name": "roughness", + "displayName": "roughness", + "inputName": "roughness", + "targetBlockId": 25, + "targetConnectionName": "output" + }, + { + "name": "ambientOcc", + "displayName": "ambientOcc" + }, + { + "name": "opacity", + "displayName": "opacity" + }, + { + "name": "indexOfRefraction", + "displayName": "indexOfRefraction" + }, + { + "name": "ambientColor", + "displayName": "ambientColor" + }, + { + "name": "reflection", + "displayName": "reflection" + }, + { + "name": "clearcoat", + "displayName": "clearcoat" + }, + { + "name": "sheen", + "displayName": "sheen" + }, + { + "name": "subsurface", + "displayName": "subsurface" + }, + { + "name": "anisotropy", + "displayName": "anisotropy" + }, + { + "name": "iridescence", + "displayName": "iridescence" + } + ], + "outputs": [ + { + "name": "ambientClr", + "displayName": "ambientClr" + }, + { + "name": "diffuseDir", + "displayName": "diffuseDir" + }, + { + "name": "specularDir", + "displayName": "specularDir" + }, + { + "name": "clearcoatDir", + "displayName": "clearcoatDir" + }, + { + "name": "sheenDir", + "displayName": "sheenDir" + }, + { + "name": "diffuseInd", + "displayName": "diffuseInd" + }, + { + "name": "specularInd", + "displayName": "specularInd" + }, + { + "name": "clearcoatInd", + "displayName": "clearcoatInd" + }, + { + "name": "sheenInd", + "displayName": "sheenInd" + }, + { + "name": "refraction", + "displayName": "refraction" + }, + { + "name": "lighting", + "displayName": "lighting" + }, + { + "name": "shadow", + "displayName": "shadow" + }, + { + "name": "alpha", + "displayName": "alpha" + } + ], + "lightFalloff": 0, + "useAlphaTest": false, + "alphaTestCutoff": 0.5, + "useAlphaBlending": false, + "useRadianceOverAlpha": true, + "useSpecularOverAlpha": true, + "enableSpecularAntiAliasing": false, + "realTimeFiltering": false, + "realTimeFilteringQuality": 8, + "useEnergyConservation": true, + "useRadianceOcclusion": true, + "useHorizonOcclusion": true, + "unlit": false, + "forceNormalForward": false, + "debugMode": 0, + "debugLimit": -1, + "debugFactor": 1, + "generateOnlyFragmentCode": false, + "directIntensity": 1, + "environmentIntensity": 1, + "specularIntensity": 1 + }, + { + "customType": "BABYLON.InputBlock", + "id": 14, + "name": "piScale", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 25.1327, + 25.1327 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 15, + "name": "scaledUV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 14, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 16, + "name": "splitUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz ", + "displayName": "xyz " + }, + { + "name": "xy ", + "displayName": "xy ", + "inputName": "xy ", + "targetBlockId": 15, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 17, + "name": "sinU", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 16, + "targetConnectionName": "x" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 1 + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 18, + "name": "sinV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 16, + "targetConnectionName": "y" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 1 + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 19, + "name": "sinProduct", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 17, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 18, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 20, + "name": "zero", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.StepBlock", + "id": 21, + "name": "checker", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 19, + "targetConnectionName": "output" + }, + { + "name": "edge", + "displayName": "edge", + "inputName": "edge", + "targetBlockId": 20, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 22, + "name": "blackColor", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 0.02, + 0.02, + 0.02 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 23, + "name": "whiteColor", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 0.95, + 0.95, + 0.95 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.LerpBlock", + "id": 24, + "name": "colorMix", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 22, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 23, + "targetConnectionName": "output" + }, + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 21, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 25, + "name": "roughnessVal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.5, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 26, + "name": "metallicVal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + } + ], + "outputNodes": [ + 11, + 12 + ], + "comment": "Procedural black and white checkerboard pattern with PBR lighting", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 0, + "y": 0 + }, + { + "blockId": 2, + "x": 1700, + "y": 360 + }, + { + "blockId": 3, + "x": 1700, + "y": 0 + }, + { + "blockId": 4, + "x": 2040, + "y": 0 + }, + { + "blockId": 5, + "x": 1700, + "y": 180 + }, + { + "blockId": 6, + "x": 2040, + "y": 180 + }, + { + "blockId": 7, + "x": 2040, + "y": 360 + }, + { + "blockId": 8, + "x": 2380, + "y": 0 + }, + { + "blockId": 9, + "x": 2040, + "y": 540 + }, + { + "blockId": 10, + "x": 2040, + "y": 720 + }, + { + "blockId": 11, + "x": 2720, + "y": 0 + }, + { + "blockId": 12, + "x": 2720, + "y": 180 + }, + { + "blockId": 13, + "x": 2380, + "y": 180 + }, + { + "blockId": 14, + "x": 0, + "y": 180 + }, + { + "blockId": 15, + "x": 340, + "y": 0 + }, + { + "blockId": 16, + "x": 680, + "y": 0 + }, + { + "blockId": 17, + "x": 1020, + "y": 0 + }, + { + "blockId": 18, + "x": 1020, + "y": 180 + }, + { + "blockId": 19, + "x": 1360, + "y": 0 + }, + { + "blockId": 20, + "x": 1360, + "y": 180 + }, + { + "blockId": 21, + "x": 1700, + "y": 540 + }, + { + "blockId": 22, + "x": 1700, + "y": 720 + }, + { + "blockId": 23, + "x": 1700, + "y": 900 + }, + { + "blockId": 24, + "x": 2040, + "y": 900 + }, + { + "blockId": 25, + "x": 2040, + "y": 1080 + }, + { + "blockId": 26, + "x": 2040, + "y": 1260 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/examples/Grass.json b/packages/tools/nme-mcp-server/examples/Grass.json new file mode 100644 index 00000000000..894a99598f0 --- /dev/null +++ b/packages/tools/nme-mcp-server/examples/Grass.json @@ -0,0 +1,1587 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "normal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "world", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 1, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "worldViewProjection", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 6, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "cameraPosition", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 7, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "view", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 2, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 8, + "name": "time", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 1, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 9, + "name": "windSpeed", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.8, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 10, + "name": "windStrength", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.12, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 11, + "name": "noiseScale", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 4, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 12, + "name": "detailScale", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 8, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 13, + "name": "roughness", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.85, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 14, + "name": "metallic", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 15, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 18, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 16, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 37, + "targetConnectionName": "lighting" + }, + { + "name": "a", + "displayName": "a", + "inputName": "a", + "targetBlockId": 39, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.TransformBlock", + "id": 17, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 18, + "name": "clipPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 17, + "targetConnectionName": "xyz" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 19, + "name": "splitUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 20, + "name": "timeScale", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 8, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 21, + "name": "windOffsetX", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 19, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 20, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 22, + "name": "noiseSeed3D", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 21, + "targetConnectionName": "output" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 19, + "targetConnectionName": "y" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 20, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 23, + "name": "scaledSeed", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 22, + "targetConnectionName": "xyz" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 24, + "name": "grassNoise", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 23, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 25, + "name": "detailSeed", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 22, + "targetConnectionName": "xyz" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 12, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 26, + "name": "detailNoise", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 25, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 27, + "name": "largeWeight", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.7, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 28, + "name": "detailWeight", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.3, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 29, + "name": "largeWeighted", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 24, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 27, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 30, + "name": "detailWeighted", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 26, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 28, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 31, + "name": "combinedNoise", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 29, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 30, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 32, + "name": "half", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.5, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 33, + "name": "noiseHalf", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 31, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 32, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 34, + "name": "noiseRemapped", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 33, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 32, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ClampBlock", + "id": 35, + "name": "noiseClamped", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 34, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "minimum": 0, + "maximum": 1 + }, + { + "customType": "BABYLON.GradientBlock", + "id": 36, + "name": "grassGradient", + "target": 4, + "inputs": [ + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 35, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "colorSteps": [ + { + "step": 0, + "color": { + "r": 0.05, + "g": 0.08, + "b": 0.02 + } + }, + { + "step": 0.25, + "color": { + "r": 0.1, + "g": 0.25, + "b": 0.05 + } + }, + { + "step": 0.5, + "color": { + "r": 0.15, + "g": 0.45, + "b": 0.08 + } + }, + { + "step": 0.75, + "color": { + "r": 0.3, + "g": 0.6, + "b": 0.12 + } + }, + { + "step": 1, + "color": { + "r": 0.45, + "g": 0.75, + "b": 0.2 + } + } + ] + }, + { + "customType": "BABYLON.PBRMetallicRoughnessBlock", + "id": 37, + "name": "pbr", + "target": 3, + "inputs": [ + { + "name": "worldPosition", + "displayName": "worldPosition", + "inputName": "worldPosition", + "targetBlockId": 17, + "targetConnectionName": "output" + }, + { + "name": "worldNormal", + "displayName": "worldNormal", + "inputName": "worldNormal", + "targetBlockId": 38, + "targetConnectionName": "output" + }, + { + "name": "view", + "displayName": "view", + "inputName": "view", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "cameraPosition", + "displayName": "cameraPosition", + "inputName": "cameraPosition", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "perturbedNormal", + "displayName": "perturbedNormal" + }, + { + "name": "baseColor", + "displayName": "baseColor", + "inputName": "baseColor", + "targetBlockId": 36, + "targetConnectionName": "output" + }, + { + "name": "metallic", + "displayName": "metallic", + "inputName": "metallic", + "targetBlockId": 14, + "targetConnectionName": "output" + }, + { + "name": "roughness", + "displayName": "roughness", + "inputName": "roughness", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "ambientOcc", + "displayName": "ambientOcc" + }, + { + "name": "opacity", + "displayName": "opacity" + }, + { + "name": "indexOfRefraction", + "displayName": "indexOfRefraction" + }, + { + "name": "ambientColor", + "displayName": "ambientColor" + }, + { + "name": "reflection", + "displayName": "reflection" + }, + { + "name": "clearcoat", + "displayName": "clearcoat" + }, + { + "name": "sheen", + "displayName": "sheen" + }, + { + "name": "subsurface", + "displayName": "subsurface" + }, + { + "name": "anisotropy", + "displayName": "anisotropy" + }, + { + "name": "iridescence", + "displayName": "iridescence" + } + ], + "outputs": [ + { + "name": "ambientClr", + "displayName": "ambientClr" + }, + { + "name": "diffuseDir", + "displayName": "diffuseDir" + }, + { + "name": "specularDir", + "displayName": "specularDir" + }, + { + "name": "diffuseInd", + "displayName": "diffuseInd" + }, + { + "name": "specularInd", + "displayName": "specularInd" + }, + { + "name": "specularEnvironmentR0", + "displayName": "specularEnvironmentR0" + }, + { + "name": "specularEnvironmentR90", + "displayName": "specularEnvironmentR90" + }, + { + "name": "refraction", + "displayName": "refraction" + }, + { + "name": "clearcoat", + "displayName": "clearcoat" + }, + { + "name": "sheen", + "displayName": "sheen" + }, + { + "name": "lighting", + "displayName": "lighting" + }, + { + "name": "shadow", + "displayName": "shadow" + }, + { + "name": "alpha", + "displayName": "alpha" + } + ], + "lightFalloff": 0, + "useAlphaTest": false, + "alphaTestCutoff": 0.5, + "useAlphaBlending": false, + "useRadianceOverAlpha": true, + "useSpecularOverAlpha": true, + "enableSpecularAntiAliasing": false, + "realTimeFiltering": false, + "realTimeFilteringQuality": 8, + "useEnergyConservation": true, + "useRadianceOcclusion": true, + "useHorizonOcclusion": true, + "unlit": false, + "forceNormalForward": false, + "debugMode": 0, + "debugLimit": -1, + "debugFactor": 1, + "generateOnlyFragmentCode": false, + "directIntensity": 1, + "environmentIntensity": 1, + "specularIntensity": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 38, + "name": "worldNormal", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.InputBlock", + "id": 39, + "name": "alphaOne", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1, + "valueType": "number" + } + ], + "outputNodes": [ + 15, + 16 + ], + "comment": "Dynamic procedural grass material with wind animation and PBR lighting", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 3740, + "y": 0 + }, + { + "blockId": 2, + "x": 3740, + "y": 540 + }, + { + "blockId": 3, + "x": 340, + "y": 0 + }, + { + "blockId": 4, + "x": 3740, + "y": 180 + }, + { + "blockId": 5, + "x": 4080, + "y": 0 + }, + { + "blockId": 6, + "x": 4080, + "y": 360 + }, + { + "blockId": 7, + "x": 4080, + "y": 540 + }, + { + "blockId": 8, + "x": 340, + "y": 180 + }, + { + "blockId": 9, + "x": 340, + "y": 360 + }, + { + "blockId": 10, + "x": 0, + "y": 0 + }, + { + "blockId": 11, + "x": 1360, + "y": 0 + }, + { + "blockId": 12, + "x": 1360, + "y": 360 + }, + { + "blockId": 13, + "x": 4080, + "y": 720 + }, + { + "blockId": 14, + "x": 4080, + "y": 900 + }, + { + "blockId": 15, + "x": 4760, + "y": 0 + }, + { + "blockId": 16, + "x": 4760, + "y": 180 + }, + { + "blockId": 17, + "x": 4080, + "y": 180 + }, + { + "blockId": 18, + "x": 4420, + "y": 0 + }, + { + "blockId": 19, + "x": 680, + "y": 0 + }, + { + "blockId": 20, + "x": 680, + "y": 180 + }, + { + "blockId": 21, + "x": 1020, + "y": 0 + }, + { + "blockId": 22, + "x": 1360, + "y": 180 + }, + { + "blockId": 23, + "x": 1700, + "y": 0 + }, + { + "blockId": 24, + "x": 2040, + "y": 0 + }, + { + "blockId": 25, + "x": 1700, + "y": 180 + }, + { + "blockId": 26, + "x": 2040, + "y": 360 + }, + { + "blockId": 27, + "x": 2040, + "y": 180 + }, + { + "blockId": 28, + "x": 2040, + "y": 540 + }, + { + "blockId": 29, + "x": 2380, + "y": 0 + }, + { + "blockId": 30, + "x": 2380, + "y": 180 + }, + { + "blockId": 31, + "x": 2720, + "y": 0 + }, + { + "blockId": 32, + "x": 2720, + "y": 180 + }, + { + "blockId": 33, + "x": 3060, + "y": 0 + }, + { + "blockId": 34, + "x": 3400, + "y": 0 + }, + { + "blockId": 35, + "x": 3740, + "y": 360 + }, + { + "blockId": 36, + "x": 4080, + "y": 1080 + }, + { + "blockId": 37, + "x": 4420, + "y": 180 + }, + { + "blockId": 38, + "x": 4080, + "y": 1260 + }, + { + "blockId": 39, + "x": 4420, + "y": 360 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/examples/HumanSkin.json b/packages/tools/nme-mcp-server/examples/HumanSkin.json new file mode 100644 index 00000000000..2237ab6f3e2 --- /dev/null +++ b/packages/tools/nme-mcp-server/examples/HumanSkin.json @@ -0,0 +1,2092 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "attributeName": "uv", + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "normal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "attributeName": "normal", + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "attributeName": "position", + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "worldViewProjection", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 6, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "world", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 1, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "view", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 2, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "cameraPosition", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 7, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.TransformBlock", + "id": 8, + "name": "wvpTransform", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 9, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 10, + "name": "worldNormal", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 11, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 12, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 13, + "targetConnectionName": "lighting" + }, + { + "name": "a", + "displayName": "a" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.PBRMetallicRoughnessBlock", + "id": 13, + "name": "PBR", + "target": 3, + "inputs": [ + { + "name": "worldPosition", + "displayName": "worldPosition", + "inputName": "worldPosition", + "targetBlockId": 9, + "targetConnectionName": "output" + }, + { + "name": "worldNormal", + "displayName": "worldNormal", + "inputName": "worldNormal", + "targetBlockId": 10, + "targetConnectionName": "output" + }, + { + "name": "view", + "displayName": "view", + "inputName": "view", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "cameraPosition", + "displayName": "cameraPosition", + "inputName": "cameraPosition", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "perturbedNormal", + "displayName": "perturbedNormal", + "inputName": "perturbedNormal", + "targetBlockId": 54, + "targetConnectionName": "output" + }, + { + "name": "baseColor", + "displayName": "baseColor", + "inputName": "baseColor", + "targetBlockId": 38, + "targetConnectionName": "output" + }, + { + "name": "metallic", + "displayName": "metallic", + "inputName": "metallic", + "targetBlockId": 40, + "targetConnectionName": "output" + }, + { + "name": "roughness", + "displayName": "roughness", + "inputName": "roughness", + "targetBlockId": 39, + "targetConnectionName": "output" + }, + { + "name": "ambientOcc", + "displayName": "ambientOcc" + }, + { + "name": "opacity", + "displayName": "opacity" + }, + { + "name": "indexOfRefraction", + "displayName": "indexOfRefraction" + }, + { + "name": "ambientColor", + "displayName": "ambientColor" + }, + { + "name": "reflection", + "displayName": "reflection" + }, + { + "name": "clearcoat", + "displayName": "clearcoat" + }, + { + "name": "sheen", + "displayName": "sheen" + }, + { + "name": "subsurface", + "displayName": "subsurface" + }, + { + "name": "anisotropy", + "displayName": "anisotropy" + }, + { + "name": "iridescence", + "displayName": "iridescence" + } + ], + "outputs": [ + { + "name": "ambientClr", + "displayName": "ambientClr" + }, + { + "name": "diffuseDir", + "displayName": "diffuseDir" + }, + { + "name": "specularDir", + "displayName": "specularDir" + }, + { + "name": "clearcoatDir", + "displayName": "clearcoatDir" + }, + { + "name": "sheenDir", + "displayName": "sheenDir" + }, + { + "name": "diffuseInd", + "displayName": "diffuseInd" + }, + { + "name": "specularInd", + "displayName": "specularInd" + }, + { + "name": "clearcoatInd", + "displayName": "clearcoatInd" + }, + { + "name": "sheenInd", + "displayName": "sheenInd" + }, + { + "name": "refraction", + "displayName": "refraction" + }, + { + "name": "lighting", + "displayName": "lighting" + }, + { + "name": "shadow", + "displayName": "shadow" + }, + { + "name": "alpha", + "displayName": "alpha" + } + ], + "lightFalloff": 0, + "useAlphaTest": false, + "alphaTestCutoff": 0.5, + "useAlphaBlending": false, + "useRadianceOverAlpha": true, + "useSpecularOverAlpha": true, + "enableSpecularAntiAliasing": false, + "realTimeFiltering": false, + "realTimeFilteringQuality": 8, + "useEnergyConservation": true, + "useRadianceOcclusion": true, + "useHorizonOcclusion": true, + "unlit": false, + "forceNormalForward": false, + "debugMode": 0, + "debugLimit": -1, + "debugFactor": 1, + "generateOnlyFragmentCode": false, + "directIntensity": 1, + "environmentIntensity": 1, + "specularIntensity": 1 + }, + { + "customType": "BABYLON.InputBlock", + "id": 14, + "name": "skinBase", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 0.87, + 0.65, + 0.52 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 15, + "name": "skinWarm", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 0.95, + 0.72, + 0.58 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 16, + "name": "poreScale", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 40, + 40 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 17, + "name": "scaledPoreUV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 18, + "name": "splitPoreUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 17, + "targetConnectionName": "output" + }, + { + "name": "xyz ", + "displayName": "xyz " + }, + { + "name": "xy ", + "displayName": "xy " + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 19, + "name": "hashSeed1", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 12.9898, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 20, + "name": "hashSeed2", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 78.233, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 21, + "name": "hashMulU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 18, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 19, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 22, + "name": "hashMulV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 18, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 20, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 23, + "name": "hashSum", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 21, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 22, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 24, + "name": "sinHash", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 23, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 1 + }, + { + "customType": "BABYLON.InputBlock", + "id": 25, + "name": "hashBig", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 43758.5453, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 26, + "name": "hashScaled", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 24, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 25, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 27, + "name": "fractHash", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 26, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.InputBlock", + "id": 28, + "name": "poreDepth", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.15, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 29, + "name": "poreMask", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 27, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 28, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.LerpBlock", + "id": 30, + "name": "skinColor", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 14, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 15, + "targetConnectionName": "output" + }, + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 29, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.FresnelBlock", + "id": 31, + "name": "fresnel", + "target": 4, + "inputs": [ + { + "name": "worldNormal", + "displayName": "worldNormal", + "inputName": "worldNormal", + "targetBlockId": 10, + "targetConnectionName": "output" + }, + { + "name": "viewDirection", + "displayName": "viewDirection", + "inputName": "viewDirection", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "bias", + "displayName": "bias", + "inputName": "bias", + "targetBlockId": 32, + "targetConnectionName": "output" + }, + { + "name": "power", + "displayName": "power", + "inputName": "power", + "targetBlockId": 33, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "fresnel", + "displayName": "fresnel" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 32, + "name": "fresnelBias", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.05, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 33, + "name": "fresnelPower", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 3, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 34, + "name": "sssColor", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 0.9, + 0.3, + 0.2 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 35, + "name": "sssStrength", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.15, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 36, + "name": "sssContrib", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 31, + "targetConnectionName": "fresnel" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 35, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 37, + "name": "sssScaled", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 34, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 36, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 38, + "name": "finalColor", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 30, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 37, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 39, + "name": "roughnessVal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.45, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 40, + "name": "metallicVal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 41, + "name": "poreDetailScale", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 80, + 80 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 42, + "name": "scaledDetailUV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 41, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 43, + "name": "splitDetailUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 42, + "targetConnectionName": "output" + }, + { + "name": "xyz ", + "displayName": "xyz " + }, + { + "name": "xy ", + "displayName": "xy " + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 44, + "name": "detHashMulU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 43, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 19, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 45, + "name": "detHashMulV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 43, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 20, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 46, + "name": "detHashSum", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 44, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 45, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 47, + "name": "detSinHash", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 46, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 1 + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 48, + "name": "detHashScaled", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 47, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 25, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 49, + "name": "detFractHash", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 48, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.InputBlock", + "id": 50, + "name": "detailBumpStr", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.08, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 51, + "name": "detailBump", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 49, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 50, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 52, + "name": "oneVal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1, + "valueType": "number" + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 53, + "name": "bumpVec", + "target": 4, + "inputs": [ + { + "name": "xyzw ", + "displayName": "xyzw " + }, + { + "name": "xyz ", + "displayName": "xyz " + }, + { + "name": "xy ", + "displayName": "xy " + }, + { + "name": "zw ", + "displayName": "zw " + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 51, + "targetConnectionName": "output" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 52, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 51, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.NormalizeBlock", + "id": 54, + "name": "bumpNorm", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 53, + "targetConnectionName": "xyz" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + } + ], + "outputNodes": [ + 11, + 12 + ], + "comment": "Realistic human skin material with subsurface scattering, pore detail, and PBR", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 0, + "y": 180 + }, + { + "blockId": 2, + "x": 1700, + "y": 180 + }, + { + "blockId": 3, + "x": 3060, + "y": 0 + }, + { + "blockId": 4, + "x": 3400, + "y": 0 + }, + { + "blockId": 5, + "x": 1700, + "y": 360 + }, + { + "blockId": 6, + "x": 3400, + "y": 180 + }, + { + "blockId": 7, + "x": 2040, + "y": 180 + }, + { + "blockId": 8, + "x": 3740, + "y": 0 + }, + { + "blockId": 9, + "x": 3400, + "y": 360 + }, + { + "blockId": 10, + "x": 2040, + "y": 360 + }, + { + "blockId": 11, + "x": 4080, + "y": 0 + }, + { + "blockId": 12, + "x": 4080, + "y": 180 + }, + { + "blockId": 13, + "x": 3740, + "y": 180 + }, + { + "blockId": 14, + "x": 2720, + "y": 0 + }, + { + "blockId": 15, + "x": 2720, + "y": 180 + }, + { + "blockId": 16, + "x": 0, + "y": 0 + }, + { + "blockId": 17, + "x": 340, + "y": 0 + }, + { + "blockId": 18, + "x": 680, + "y": 0 + }, + { + "blockId": 19, + "x": 680, + "y": 180 + }, + { + "blockId": 20, + "x": 680, + "y": 360 + }, + { + "blockId": 21, + "x": 1020, + "y": 0 + }, + { + "blockId": 22, + "x": 1020, + "y": 180 + }, + { + "blockId": 23, + "x": 1360, + "y": 0 + }, + { + "blockId": 24, + "x": 1700, + "y": 0 + }, + { + "blockId": 25, + "x": 1700, + "y": 540 + }, + { + "blockId": 26, + "x": 2040, + "y": 0 + }, + { + "blockId": 27, + "x": 2380, + "y": 0 + }, + { + "blockId": 28, + "x": 2380, + "y": 180 + }, + { + "blockId": 29, + "x": 2720, + "y": 360 + }, + { + "blockId": 30, + "x": 3060, + "y": 180 + }, + { + "blockId": 31, + "x": 2380, + "y": 360 + }, + { + "blockId": 32, + "x": 2040, + "y": 540 + }, + { + "blockId": 33, + "x": 2040, + "y": 720 + }, + { + "blockId": 34, + "x": 2720, + "y": 540 + }, + { + "blockId": 35, + "x": 2380, + "y": 540 + }, + { + "blockId": 36, + "x": 2720, + "y": 720 + }, + { + "blockId": 37, + "x": 3060, + "y": 360 + }, + { + "blockId": 38, + "x": 3400, + "y": 540 + }, + { + "blockId": 39, + "x": 3400, + "y": 720 + }, + { + "blockId": 40, + "x": 3400, + "y": 900 + }, + { + "blockId": 41, + "x": 0, + "y": 360 + }, + { + "blockId": 42, + "x": 340, + "y": 180 + }, + { + "blockId": 43, + "x": 680, + "y": 540 + }, + { + "blockId": 44, + "x": 1020, + "y": 360 + }, + { + "blockId": 45, + "x": 1020, + "y": 540 + }, + { + "blockId": 46, + "x": 1360, + "y": 180 + }, + { + "blockId": 47, + "x": 1700, + "y": 720 + }, + { + "blockId": 48, + "x": 2040, + "y": 900 + }, + { + "blockId": 49, + "x": 2380, + "y": 720 + }, + { + "blockId": 50, + "x": 2380, + "y": 900 + }, + { + "blockId": 51, + "x": 2720, + "y": 900 + }, + { + "blockId": 52, + "x": 2720, + "y": 1080 + }, + { + "blockId": 53, + "x": 3060, + "y": 540 + }, + { + "blockId": 54, + "x": 3400, + "y": 1080 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/examples/Psychedelic.json b/packages/tools/nme-mcp-server/examples/Psychedelic.json new file mode 100644 index 00000000000..6f9a249ad5b --- /dev/null +++ b/packages/tools/nme-mcp-server/examples/Psychedelic.json @@ -0,0 +1,1822 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "attributeName": "uv", + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "normal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "attributeName": "normal", + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "attributeName": "position", + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "worldViewProjection", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 6, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "world", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 1, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "view", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 2, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "cameraPosition", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 7, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.TransformBlock", + "id": 8, + "name": "wvpTransform", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 9, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 10, + "name": "worldNormal", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 11, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 12, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 13, + "targetConnectionName": "lighting" + }, + { + "name": "a", + "displayName": "a" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.PBRMetallicRoughnessBlock", + "id": 13, + "name": "PBR", + "target": 3, + "inputs": [ + { + "name": "worldPosition", + "displayName": "worldPosition", + "inputName": "worldPosition", + "targetBlockId": 9, + "targetConnectionName": "output" + }, + { + "name": "worldNormal", + "displayName": "worldNormal", + "inputName": "worldNormal", + "targetBlockId": 10, + "targetConnectionName": "output" + }, + { + "name": "view", + "displayName": "view", + "inputName": "view", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "cameraPosition", + "displayName": "cameraPosition", + "inputName": "cameraPosition", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "perturbedNormal", + "displayName": "perturbedNormal" + }, + { + "name": "baseColor", + "displayName": "baseColor", + "inputName": "baseColor", + "targetBlockId": 43, + "targetConnectionName": "xyz" + }, + { + "name": "metallic", + "displayName": "metallic", + "inputName": "metallic", + "targetBlockId": 45, + "targetConnectionName": "output" + }, + { + "name": "roughness", + "displayName": "roughness", + "inputName": "roughness", + "targetBlockId": 44, + "targetConnectionName": "output" + }, + { + "name": "ambientOcc", + "displayName": "ambientOcc" + }, + { + "name": "opacity", + "displayName": "opacity" + }, + { + "name": "indexOfRefraction", + "displayName": "indexOfRefraction" + }, + { + "name": "ambientColor", + "displayName": "ambientColor" + }, + { + "name": "reflection", + "displayName": "reflection" + }, + { + "name": "clearcoat", + "displayName": "clearcoat" + }, + { + "name": "sheen", + "displayName": "sheen" + }, + { + "name": "subsurface", + "displayName": "subsurface" + }, + { + "name": "anisotropy", + "displayName": "anisotropy" + }, + { + "name": "iridescence", + "displayName": "iridescence" + } + ], + "outputs": [ + { + "name": "ambientClr", + "displayName": "ambientClr" + }, + { + "name": "diffuseDir", + "displayName": "diffuseDir" + }, + { + "name": "specularDir", + "displayName": "specularDir" + }, + { + "name": "clearcoatDir", + "displayName": "clearcoatDir" + }, + { + "name": "sheenDir", + "displayName": "sheenDir" + }, + { + "name": "diffuseInd", + "displayName": "diffuseInd" + }, + { + "name": "specularInd", + "displayName": "specularInd" + }, + { + "name": "clearcoatInd", + "displayName": "clearcoatInd" + }, + { + "name": "sheenInd", + "displayName": "sheenInd" + }, + { + "name": "refraction", + "displayName": "refraction" + }, + { + "name": "lighting", + "displayName": "lighting" + }, + { + "name": "shadow", + "displayName": "shadow" + }, + { + "name": "alpha", + "displayName": "alpha" + } + ], + "lightFalloff": 0, + "useAlphaTest": false, + "alphaTestCutoff": 0.5, + "useAlphaBlending": false, + "useRadianceOverAlpha": true, + "useSpecularOverAlpha": true, + "enableSpecularAntiAliasing": false, + "realTimeFiltering": false, + "realTimeFilteringQuality": 8, + "useEnergyConservation": true, + "useRadianceOcclusion": true, + "useHorizonOcclusion": true, + "unlit": false, + "forceNormalForward": false, + "debugMode": 0, + "debugLimit": -1, + "debugFactor": 1, + "generateOnlyFragmentCode": false, + "directIntensity": 1, + "environmentIntensity": 1, + "specularIntensity": 1 + }, + { + "customType": "BABYLON.InputBlock", + "id": 14, + "name": "time", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 1, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 15, + "name": "uvScale", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 6.2832, + 6.2832 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 16, + "name": "scaledUV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 15, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 17, + "name": "splitUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz ", + "displayName": "xyz " + }, + { + "name": "xy ", + "displayName": "xy ", + "inputName": "xy ", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 18, + "name": "timeScale1", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1.3, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 19, + "name": "timeScale2", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.7, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 20, + "name": "timeScale3", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1.1, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 21, + "name": "tPhase1", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 14, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 18, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 22, + "name": "tPhase2", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 14, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 19, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 23, + "name": "tPhase3", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 14, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 20, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 24, + "name": "rPhaseU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 17, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 21, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 25, + "name": "rPhaseV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 17, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 21, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 26, + "name": "rAdd", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 24, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 25, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 27, + "name": "sinR", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 26, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 1 + }, + { + "customType": "BABYLON.AddBlock", + "id": 28, + "name": "gPhaseU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 17, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 22, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 29, + "name": "gPhaseV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 17, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 46, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 30, + "name": "gAdd", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 28, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 29, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 31, + "name": "sinG", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 30, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 1 + }, + { + "customType": "BABYLON.AddBlock", + "id": 32, + "name": "bPhaseU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 17, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 23, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 33, + "name": "bPhaseV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 17, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 47, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 34, + "name": "bAdd", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 32, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 33, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 35, + "name": "sinB", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 34, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 1 + }, + { + "customType": "BABYLON.InputBlock", + "id": 36, + "name": "halfVal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.5, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 37, + "name": "rBias", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 27, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 36, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 38, + "name": "rFinal", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 37, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 36, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 39, + "name": "gBias", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 31, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 36, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 40, + "name": "gFinal", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 39, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 36, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 41, + "name": "bBias", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 35, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 36, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 42, + "name": "bFinal", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 41, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 36, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 43, + "name": "colorMerge", + "target": 4, + "inputs": [ + { + "name": "xyzw ", + "displayName": "xyzw " + }, + { + "name": "xyz ", + "displayName": "xyz " + }, + { + "name": "xy ", + "displayName": "xy " + }, + { + "name": "zw ", + "displayName": "zw " + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 38, + "targetConnectionName": "output" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 40, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 42, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.InputBlock", + "id": 44, + "name": "roughnessVal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.3, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 45, + "name": "metallicVal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.5, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 46, + "name": "channelOffset1", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 2.094, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 47, + "name": "channelOffset2", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 4.189, + "valueType": "number" + } + ], + "outputNodes": [ + 11, + 12 + ], + "comment": "Animated psychedelic material with swirling rainbow colors and time-based animation", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 0, + "y": 0 + }, + { + "blockId": 2, + "x": 2380, + "y": 360 + }, + { + "blockId": 3, + "x": 2380, + "y": 0 + }, + { + "blockId": 4, + "x": 2720, + "y": 0 + }, + { + "blockId": 5, + "x": 2380, + "y": 180 + }, + { + "blockId": 6, + "x": 2720, + "y": 180 + }, + { + "blockId": 7, + "x": 2720, + "y": 360 + }, + { + "blockId": 8, + "x": 3060, + "y": 0 + }, + { + "blockId": 9, + "x": 2720, + "y": 540 + }, + { + "blockId": 10, + "x": 2720, + "y": 720 + }, + { + "blockId": 11, + "x": 3400, + "y": 0 + }, + { + "blockId": 12, + "x": 3400, + "y": 180 + }, + { + "blockId": 13, + "x": 3060, + "y": 180 + }, + { + "blockId": 14, + "x": 340, + "y": 360 + }, + { + "blockId": 15, + "x": 0, + "y": 180 + }, + { + "blockId": 16, + "x": 340, + "y": 540 + }, + { + "blockId": 17, + "x": 680, + "y": 360 + }, + { + "blockId": 18, + "x": 340, + "y": 0 + }, + { + "blockId": 19, + "x": 340, + "y": 180 + }, + { + "blockId": 20, + "x": 340, + "y": 720 + }, + { + "blockId": 21, + "x": 680, + "y": 0 + }, + { + "blockId": 22, + "x": 680, + "y": 180 + }, + { + "blockId": 23, + "x": 680, + "y": 720 + }, + { + "blockId": 24, + "x": 1020, + "y": 0 + }, + { + "blockId": 25, + "x": 1020, + "y": 180 + }, + { + "blockId": 26, + "x": 1360, + "y": 0 + }, + { + "blockId": 27, + "x": 1700, + "y": 0 + }, + { + "blockId": 28, + "x": 1020, + "y": 360 + }, + { + "blockId": 29, + "x": 1020, + "y": 540 + }, + { + "blockId": 30, + "x": 1360, + "y": 180 + }, + { + "blockId": 31, + "x": 1700, + "y": 180 + }, + { + "blockId": 32, + "x": 1020, + "y": 720 + }, + { + "blockId": 33, + "x": 1020, + "y": 900 + }, + { + "blockId": 34, + "x": 1360, + "y": 360 + }, + { + "blockId": 35, + "x": 1700, + "y": 360 + }, + { + "blockId": 36, + "x": 1700, + "y": 540 + }, + { + "blockId": 37, + "x": 2040, + "y": 0 + }, + { + "blockId": 38, + "x": 2380, + "y": 540 + }, + { + "blockId": 39, + "x": 2040, + "y": 180 + }, + { + "blockId": 40, + "x": 2380, + "y": 720 + }, + { + "blockId": 41, + "x": 2040, + "y": 360 + }, + { + "blockId": 42, + "x": 2380, + "y": 900 + }, + { + "blockId": 43, + "x": 2720, + "y": 900 + }, + { + "blockId": 44, + "x": 2720, + "y": 1080 + }, + { + "blockId": 45, + "x": 2720, + "y": 1260 + }, + { + "blockId": 46, + "x": 680, + "y": 540 + }, + { + "blockId": 47, + "x": 680, + "y": 900 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/examples/PsychedelicAlt.json b/packages/tools/nme-mcp-server/examples/PsychedelicAlt.json new file mode 100644 index 00000000000..3cd0a93796f --- /dev/null +++ b/packages/tools/nme-mcp-server/examples/PsychedelicAlt.json @@ -0,0 +1,1914 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "normal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "world", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 1, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "worldViewProjection", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 6, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "cameraPosition", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 7, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "view", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 2, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 8, + "name": "time", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 1, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 9, + "name": "speed1", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.3, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 10, + "name": "speed2", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.7, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 11, + "name": "noiseScale1", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 3, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 12, + "name": "noiseScale2", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 7, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 13, + "name": "twirlStr", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 3.5, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 14, + "name": "posterSteps", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 6, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 15, + "name": "oneConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 16, + "name": "halfConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.5, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 17, + "name": "twirlCenter", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 0.5, + 0.5 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 18, + "name": "twirlOffset", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 19, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 22, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 20, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 52, + "targetConnectionName": "output" + }, + { + "name": "a", + "displayName": "a", + "inputName": "a", + "targetBlockId": 15, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.TransformBlock", + "id": 21, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 22, + "name": "clipPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 21, + "targetConnectionName": "xyz" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 23, + "name": "twirlAngle", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 8, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TwirlBlock", + "id": 24, + "name": "twirlUV", + "target": 2, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "strength", + "displayName": "strength", + "inputName": "strength", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "center", + "displayName": "center", + "inputName": "center", + "targetBlockId": 17, + "targetConnectionName": "output" + }, + { + "name": "offset", + "displayName": "offset", + "inputName": "offset", + "targetBlockId": 18, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 25, + "name": "splitTwirl", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 24, + "targetConnectionName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 26, + "name": "timeShift1", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 8, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 27, + "name": "timeShift2", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 8, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 10, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 28, + "name": "scrolledV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 25, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 27, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 29, + "name": "seed3D_1", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 25, + "targetConnectionName": "x" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 28, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 26, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 30, + "name": "scaleSeed1", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 29, + "targetConnectionName": "xyz" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 31, + "name": "noise1", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 30, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 32, + "name": "scaleSeed2", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 29, + "targetConnectionName": "xyz" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 12, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 33, + "name": "noise2", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 32, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 34, + "name": "combNoise", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 31, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 33, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 35, + "name": "noiseHalf", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 34, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 36, + "name": "noiseRemapped", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 35, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 37, + "name": "hueCycle", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 8, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 10, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 38, + "name": "shiftedNoise", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 36, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 37, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 39, + "name": "fractHue", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 38, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.GradientBlock", + "id": 40, + "name": "neonGradient", + "target": 4, + "inputs": [ + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 39, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "colorSteps": [ + { + "color": { + "b": 0.3, + "g": 0, + "r": 1 + }, + "step": 0 + }, + { + "color": { + "b": 0, + "g": 0.5, + "r": 1 + }, + "step": 0.17 + }, + { + "color": { + "b": 0, + "g": 1, + "r": 1 + }, + "step": 0.33 + }, + { + "color": { + "b": 0.3, + "g": 1, + "r": 0 + }, + "step": 0.5 + }, + { + "color": { + "b": 1, + "g": 0.5, + "r": 0 + }, + "step": 0.67 + }, + { + "color": { + "b": 1, + "g": 0, + "r": 0.6 + }, + "step": 0.83 + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 41, + "name": "noise2Half", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 33, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 42, + "name": "noise2Remap", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 41, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 43, + "name": "shiftedNoise2", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 42, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 26, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 44, + "name": "fractHue2", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 43, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.GradientBlock", + "id": 45, + "name": "neonGradient2", + "target": 4, + "inputs": [ + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 44, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "colorSteps": [ + { + "color": { + "b": 1, + "g": 1, + "r": 0 + }, + "step": 0 + }, + { + "color": { + "b": 1, + "g": 0, + "r": 1 + }, + "step": 0.2 + }, + { + "color": { + "b": 0, + "g": 1, + "r": 1 + }, + "step": 0.4 + }, + { + "color": { + "b": 0, + "g": 1, + "r": 0 + }, + "step": 0.6 + }, + { + "color": { + "b": 1, + "g": 0, + "r": 0 + }, + "step": 0.8 + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 46, + "name": "n1Half", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 31, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 47, + "name": "n1Remap", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 46, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ClampBlock", + "id": 48, + "name": "blendFactor", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 47, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "minimum": 0, + "maximum": 1 + }, + { + "customType": "BABYLON.LerpBlock", + "id": 49, + "name": "mixColors", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 40, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 45, + "targetConnectionName": "output" + }, + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 48, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.PosterizeBlock", + "id": 50, + "name": "posterize", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 49, + "targetConnectionName": "output" + }, + { + "name": "steps", + "displayName": "steps", + "inputName": "steps", + "targetBlockId": 14, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 51, + "name": "boostFactor", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1.4, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 52, + "name": "boosted", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 50, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 51, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + } + ], + "outputNodes": [ + 19, + 20 + ], + "comment": "Psychedelic animated material with swirling neon colors and time-cycling hues", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 4760, + "y": 0 + }, + { + "blockId": 2, + "x": 0, + "y": 0 + }, + { + "blockId": 3, + "x": 340, + "y": 0 + }, + { + "blockId": 4, + "x": 4760, + "y": 180 + }, + { + "blockId": 5, + "x": 5100, + "y": 0 + }, + { + "blockId": 6, + "x": 0, + "y": 180 + }, + { + "blockId": 7, + "x": 0, + "y": 360 + }, + { + "blockId": 8, + "x": 680, + "y": 0 + }, + { + "blockId": 9, + "x": 1020, + "y": 0 + }, + { + "blockId": 10, + "x": 680, + "y": 360 + }, + { + "blockId": 11, + "x": 1700, + "y": 360 + }, + { + "blockId": 12, + "x": 1700, + "y": 0 + }, + { + "blockId": 13, + "x": 340, + "y": 180 + }, + { + "blockId": 14, + "x": 4760, + "y": 360 + }, + { + "blockId": 15, + "x": 5440, + "y": 180 + }, + { + "blockId": 16, + "x": 2720, + "y": 180 + }, + { + "blockId": 17, + "x": 340, + "y": 360 + }, + { + "blockId": 18, + "x": 340, + "y": 540 + }, + { + "blockId": 19, + "x": 5780, + "y": 0 + }, + { + "blockId": 20, + "x": 5780, + "y": 180 + }, + { + "blockId": 21, + "x": 5100, + "y": 180 + }, + { + "blockId": 22, + "x": 5440, + "y": 0 + }, + { + "blockId": 23, + "x": 0, + "y": 540 + }, + { + "blockId": 24, + "x": 680, + "y": 180 + }, + { + "blockId": 25, + "x": 1020, + "y": 180 + }, + { + "blockId": 26, + "x": 1360, + "y": 0 + }, + { + "blockId": 27, + "x": 1020, + "y": 360 + }, + { + "blockId": 28, + "x": 1360, + "y": 180 + }, + { + "blockId": 29, + "x": 1700, + "y": 180 + }, + { + "blockId": 30, + "x": 2040, + "y": 180 + }, + { + "blockId": 31, + "x": 2380, + "y": 180 + }, + { + "blockId": 32, + "x": 2040, + "y": 0 + }, + { + "blockId": 33, + "x": 2380, + "y": 0 + }, + { + "blockId": 34, + "x": 2720, + "y": 0 + }, + { + "blockId": 35, + "x": 3060, + "y": 0 + }, + { + "blockId": 36, + "x": 3400, + "y": 0 + }, + { + "blockId": 37, + "x": 3400, + "y": 180 + }, + { + "blockId": 38, + "x": 3740, + "y": 0 + }, + { + "blockId": 39, + "x": 4080, + "y": 0 + }, + { + "blockId": 40, + "x": 4420, + "y": 0 + }, + { + "blockId": 41, + "x": 3060, + "y": 180 + }, + { + "blockId": 42, + "x": 3400, + "y": 360 + }, + { + "blockId": 43, + "x": 3740, + "y": 180 + }, + { + "blockId": 44, + "x": 4080, + "y": 180 + }, + { + "blockId": 45, + "x": 4420, + "y": 180 + }, + { + "blockId": 46, + "x": 3740, + "y": 360 + }, + { + "blockId": 47, + "x": 4080, + "y": 360 + }, + { + "blockId": 48, + "x": 4420, + "y": 360 + }, + { + "blockId": 49, + "x": 4760, + "y": 540 + }, + { + "blockId": 50, + "x": 5100, + "y": 360 + }, + { + "blockId": 51, + "x": 5100, + "y": 540 + }, + { + "blockId": 52, + "x": 5440, + "y": 360 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/examples/PurpleFire.json b/packages/tools/nme-mcp-server/examples/PurpleFire.json new file mode 100644 index 00000000000..9772d3f5552 --- /dev/null +++ b/packages/tools/nme-mcp-server/examples/PurpleFire.json @@ -0,0 +1,1388 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "normal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "world", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 1, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "worldViewProjection", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 6, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "cameraPosition", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 7, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "time", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 1, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 8, + "name": "scrollSpeed", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1.5, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 9, + "name": "noiseScale", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 3, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 10, + "name": "distortStrength", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.15, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 11, + "name": "alphaEdgeHigh", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.85, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 12, + "name": "oneConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 13, + "name": "zeroConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 14, + "name": "gradientPower", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 2, + "valueType": "number" + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 15, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 18, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 16, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 34, + "targetConnectionName": "output" + }, + { + "name": "a", + "displayName": "a", + "inputName": "a", + "targetBlockId": 36, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.TransformBlock", + "id": 17, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 18, + "name": "clipPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 17, + "targetConnectionName": "xyz" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 19, + "name": "splitUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 20, + "name": "timeScale", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SubtractBlock", + "id": 21, + "name": "scrollUV_Y", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 19, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 20, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 22, + "name": "scrolledUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 19, + "targetConnectionName": "x" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 21, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 20, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 23, + "name": "scaleUV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 22, + "targetConnectionName": "xyz" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 24, + "name": "noise", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 23, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 25, + "name": "distortAmount", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 24, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 10, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 26, + "name": "distortedU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 19, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 25, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 27, + "name": "distortedUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 26, + "targetConnectionName": "output" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 21, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 20, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 28, + "name": "fireNoise", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 27, + "targetConnectionName": "xyz" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 29, + "name": "splitUV2", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.OneMinusBlock", + "id": 30, + "name": "invertV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 29, + "targetConnectionName": "y" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SubtractBlock", + "id": 31, + "name": "flameShape", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 30, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 12, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 32, + "name": "rawFlame", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 28, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 31, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ClampBlock", + "id": 33, + "name": "clampedFlame", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 32, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "minimum": 0, + "maximum": 1 + }, + { + "customType": "BABYLON.GradientBlock", + "id": 34, + "name": "fireGradient", + "target": 4, + "inputs": [ + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 33, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "colorSteps": [ + { + "step": 0, + "color": { + "r": 0, + "g": 0.05, + "b": 0 + } + }, + { + "step": 0.3, + "color": { + "r": 0, + "g": 0.4, + "b": 0.05 + } + }, + { + "step": 0.6, + "color": { + "r": 0.2, + "g": 0.8, + "b": 0.1 + } + }, + { + "step": 0.85, + "color": { + "r": 0.7, + "g": 1, + "b": 0.3 + } + }, + { + "step": 1, + "color": { + "r": 0.95, + "g": 1, + "b": 0.9 + } + } + ] + }, + { + "customType": "BABYLON.PowBlock", + "id": 35, + "name": "alphaShape", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 33, + "targetConnectionName": "output" + }, + { + "name": "power", + "displayName": "power", + "inputName": "power", + "targetBlockId": 14, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SmoothStepBlock", + "id": 36, + "name": "alphaSmooth", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 35, + "targetConnectionName": "output" + }, + { + "name": "edge0", + "displayName": "edge0", + "inputName": "edge0", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "edge1", + "displayName": "edge1", + "inputName": "edge1", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + } + ], + "outputNodes": [ + 15, + 16 + ], + "comment": "Animated procedural purple fire material", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 4080, + "y": 0 + }, + { + "blockId": 2, + "x": 0, + "y": 0 + }, + { + "blockId": 3, + "x": 340, + "y": 0 + }, + { + "blockId": 4, + "x": 4080, + "y": 180 + }, + { + "blockId": 5, + "x": 4420, + "y": 0 + }, + { + "blockId": 6, + "x": 0, + "y": 180 + }, + { + "blockId": 7, + "x": 340, + "y": 180 + }, + { + "blockId": 8, + "x": 340, + "y": 360 + }, + { + "blockId": 9, + "x": 1360, + "y": 0 + }, + { + "blockId": 10, + "x": 2040, + "y": 0 + }, + { + "blockId": 11, + "x": 4420, + "y": 360 + }, + { + "blockId": 12, + "x": 3060, + "y": 180 + }, + { + "blockId": 13, + "x": 4420, + "y": 540 + }, + { + "blockId": 14, + "x": 4080, + "y": 540 + }, + { + "blockId": 15, + "x": 5100, + "y": 0 + }, + { + "blockId": 16, + "x": 5100, + "y": 180 + }, + { + "blockId": 17, + "x": 4420, + "y": 180 + }, + { + "blockId": 18, + "x": 4760, + "y": 0 + }, + { + "blockId": 19, + "x": 680, + "y": 0 + }, + { + "blockId": 20, + "x": 680, + "y": 180 + }, + { + "blockId": 21, + "x": 1020, + "y": 0 + }, + { + "blockId": 22, + "x": 1360, + "y": 180 + }, + { + "blockId": 23, + "x": 1700, + "y": 0 + }, + { + "blockId": 24, + "x": 2040, + "y": 180 + }, + { + "blockId": 25, + "x": 2380, + "y": 0 + }, + { + "blockId": 26, + "x": 2720, + "y": 0 + }, + { + "blockId": 27, + "x": 3060, + "y": 0 + }, + { + "blockId": 28, + "x": 3400, + "y": 0 + }, + { + "blockId": 29, + "x": 2720, + "y": 180 + }, + { + "blockId": 30, + "x": 3060, + "y": 360 + }, + { + "blockId": 31, + "x": 3400, + "y": 180 + }, + { + "blockId": 32, + "x": 3740, + "y": 0 + }, + { + "blockId": 33, + "x": 4080, + "y": 360 + }, + { + "blockId": 34, + "x": 4760, + "y": 180 + }, + { + "blockId": 35, + "x": 4420, + "y": 720 + }, + { + "blockId": 36, + "x": 4760, + "y": 360 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/examples/PurpleFireBricks.json b/packages/tools/nme-mcp-server/examples/PurpleFireBricks.json new file mode 100644 index 00000000000..066a3c439b7 --- /dev/null +++ b/packages/tools/nme-mcp-server/examples/PurpleFireBricks.json @@ -0,0 +1,1866 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "number[]" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "number[]" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "world", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "worldViewProjection", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "time", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 1, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "scrollSpeed", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.5, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "noiseScale", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 5, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 8, + "name": "distortStrength", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.15, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 9, + "name": "brickCountX", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 6, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 10, + "name": "brickCountY", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 12, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 11, + "name": "mortarWidth", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.08, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 12, + "name": "oneConst", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 13, + "name": "zeroConst", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 14, + "name": "twoConst", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 2, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 15, + "name": "gradientPower", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 2, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 16, + "name": "fireBias", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.3, + "valueType": "number" + }, + { + "customType": "BABYLON.TransformBlock", + "id": 17, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 3, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 18, + "name": "clipPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 17, + "targetConnectionName": "xyz" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 19, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 18, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 20, + "name": "splitUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 21, + "name": "scaledU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 20, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 22, + "name": "scaledV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 20, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 10, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 23, + "name": "floorV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 22, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 6 + }, + { + "customType": "BABYLON.DivideBlock", + "id": 24, + "name": "rowHalf", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 23, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 14, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 25, + "name": "fractRowHalf", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 24, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.AddBlock", + "id": 26, + "name": "offsetU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 21, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 25, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 27, + "name": "brickX", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 26, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 28, + "name": "brickY", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 22, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.SmoothStepBlock", + "id": 29, + "name": "mXlo", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 27, + "targetConnectionName": "output" + }, + { + "name": "edge0", + "displayName": "edge0", + "inputName": "edge0", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "edge1", + "displayName": "edge1", + "inputName": "edge1", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.OneMinusBlock", + "id": 30, + "name": "invBrickX", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 27, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SmoothStepBlock", + "id": 31, + "name": "mXhi", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 30, + "targetConnectionName": "output" + }, + { + "name": "edge0", + "displayName": "edge0", + "inputName": "edge0", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "edge1", + "displayName": "edge1", + "inputName": "edge1", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 32, + "name": "mortarX", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 29, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 31, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SmoothStepBlock", + "id": 33, + "name": "mYlo", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 28, + "targetConnectionName": "output" + }, + { + "name": "edge0", + "displayName": "edge0", + "inputName": "edge0", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "edge1", + "displayName": "edge1", + "inputName": "edge1", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.OneMinusBlock", + "id": 34, + "name": "invBrickY", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 28, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SmoothStepBlock", + "id": 35, + "name": "mYhi", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 34, + "targetConnectionName": "output" + }, + { + "name": "edge0", + "displayName": "edge0", + "inputName": "edge0", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "edge1", + "displayName": "edge1", + "inputName": "edge1", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 36, + "name": "mortarY", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 33, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 35, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 37, + "name": "brickMask", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 32, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 36, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 38, + "name": "timeScale", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 5, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 6, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SubtractBlock", + "id": 39, + "name": "scrollY", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 20, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 38, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 40, + "name": "scrolledUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 20, + "targetConnectionName": "x" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 39, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 38, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 41, + "name": "scaleNoise", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 40, + "targetConnectionName": "xyz" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 7, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 42, + "name": "noise", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 41, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 43, + "name": "distort", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 42, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 44, + "name": "distortedU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 20, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 43, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 45, + "name": "distUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 44, + "targetConnectionName": "output" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 39, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 38, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 46, + "name": "fireNoise", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 45, + "targetConnectionName": "xyz" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 47, + "name": "biasedFlame", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 46, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ClampBlock", + "id": 48, + "name": "clampedFlame", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 47, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "minimum": 0, + "maximum": 1 + }, + { + "customType": "BABYLON.GradientBlock", + "id": 49, + "name": "fireGradient", + "target": 4, + "inputs": [ + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 48, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "colorSteps": [ + { + "step": 0, + "color": { + "r": 0.05, + "g": 0, + "b": 0.1 + } + }, + { + "step": 0.3, + "color": { + "r": 0.4, + "g": 0, + "b": 0.6 + } + }, + { + "step": 0.6, + "color": { + "r": 0.7, + "g": 0.2, + "b": 1 + } + }, + { + "step": 0.85, + "color": { + "r": 1, + "g": 0.7, + "b": 1 + } + }, + { + "step": 1, + "color": { + "r": 1, + "g": 0.95, + "b": 1 + } + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 50, + "name": "maskedColor", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 49, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 37, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 51, + "name": "maskedAlpha", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 48, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 37, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 52, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 50, + "targetConnectionName": "output" + }, + { + "name": "a", + "displayName": "a", + "inputName": "a", + "targetBlockId": 51, + "targetConnectionName": "output" + } + ], + "outputs": [] + } + ], + "outputNodes": [ + 19, + 52 + ], + "comment": "Purple fire animation masked by a procedural brick pattern - fire shows on brick faces, mortar lines are dark", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 4080, + "y": 0 + }, + { + "blockId": 2, + "x": 340, + "y": 360 + }, + { + "blockId": 3, + "x": 4080, + "y": 180 + }, + { + "blockId": 4, + "x": 4420, + "y": 0 + }, + { + "blockId": 5, + "x": 340, + "y": 0 + }, + { + "blockId": 6, + "x": 340, + "y": 180 + }, + { + "blockId": 7, + "x": 1360, + "y": 0 + }, + { + "blockId": 8, + "x": 2040, + "y": 0 + }, + { + "blockId": 9, + "x": 2040, + "y": 360 + }, + { + "blockId": 10, + "x": 1020, + "y": 180 + }, + { + "blockId": 11, + "x": 3400, + "y": 540 + }, + { + "blockId": 12, + "x": 0, + "y": 0 + }, + { + "blockId": 13, + "x": 3400, + "y": 720 + }, + { + "blockId": 14, + "x": 1700, + "y": 180 + }, + { + "blockId": 15, + "x": 0, + "y": 180 + }, + { + "blockId": 16, + "x": 3400, + "y": 0 + }, + { + "blockId": 17, + "x": 4420, + "y": 180 + }, + { + "blockId": 18, + "x": 4760, + "y": 0 + }, + { + "blockId": 19, + "x": 5100, + "y": 0 + }, + { + "blockId": 20, + "x": 680, + "y": 180 + }, + { + "blockId": 21, + "x": 2380, + "y": 180 + }, + { + "blockId": 22, + "x": 1360, + "y": 360 + }, + { + "blockId": 23, + "x": 1700, + "y": 360 + }, + { + "blockId": 24, + "x": 2040, + "y": 540 + }, + { + "blockId": 25, + "x": 2380, + "y": 360 + }, + { + "blockId": 26, + "x": 2720, + "y": 180 + }, + { + "blockId": 27, + "x": 3060, + "y": 180 + }, + { + "blockId": 28, + "x": 3060, + "y": 360 + }, + { + "blockId": 29, + "x": 3740, + "y": 180 + }, + { + "blockId": 30, + "x": 3400, + "y": 360 + }, + { + "blockId": 31, + "x": 3740, + "y": 360 + }, + { + "blockId": 32, + "x": 4080, + "y": 540 + }, + { + "blockId": 33, + "x": 3740, + "y": 540 + }, + { + "blockId": 34, + "x": 3400, + "y": 900 + }, + { + "blockId": 35, + "x": 3740, + "y": 720 + }, + { + "blockId": 36, + "x": 4080, + "y": 720 + }, + { + "blockId": 37, + "x": 4420, + "y": 540 + }, + { + "blockId": 38, + "x": 680, + "y": 0 + }, + { + "blockId": 39, + "x": 1020, + "y": 0 + }, + { + "blockId": 40, + "x": 1360, + "y": 180 + }, + { + "blockId": 41, + "x": 1700, + "y": 0 + }, + { + "blockId": 42, + "x": 2040, + "y": 180 + }, + { + "blockId": 43, + "x": 2380, + "y": 0 + }, + { + "blockId": 44, + "x": 2720, + "y": 0 + }, + { + "blockId": 45, + "x": 3060, + "y": 0 + }, + { + "blockId": 46, + "x": 3400, + "y": 180 + }, + { + "blockId": 47, + "x": 3740, + "y": 0 + }, + { + "blockId": 48, + "x": 4080, + "y": 360 + }, + { + "blockId": 49, + "x": 4420, + "y": 360 + }, + { + "blockId": 50, + "x": 4760, + "y": 180 + }, + { + "blockId": 51, + "x": 4760, + "y": 360 + }, + { + "blockId": 52, + "x": 5100, + "y": 180 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/examples/Water.json b/packages/tools/nme-mcp-server/examples/Water.json new file mode 100644 index 00000000000..8dfd6769a0f --- /dev/null +++ b/packages/tools/nme-mcp-server/examples/Water.json @@ -0,0 +1,2112 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "attributeName": "uv", + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "normal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "attributeName": "normal", + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "attributeName": "position", + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "wvp", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 6, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "world", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 1, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "view", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 2, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "cameraPosition", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 7, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.TransformBlock", + "id": 8, + "name": "wvpTransform", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 30, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 9, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 30, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 10, + "name": "worldNormal", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 11, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 12, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 13, + "targetConnectionName": "lighting" + }, + { + "name": "a", + "displayName": "a", + "inputName": "a", + "targetBlockId": 13, + "targetConnectionName": "alpha" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.PBRMetallicRoughnessBlock", + "id": 13, + "name": "PBR", + "target": 3, + "inputs": [ + { + "name": "worldPosition", + "displayName": "worldPosition", + "inputName": "worldPosition", + "targetBlockId": 9, + "targetConnectionName": "output" + }, + { + "name": "worldNormal", + "displayName": "worldNormal", + "inputName": "worldNormal", + "targetBlockId": 10, + "targetConnectionName": "output" + }, + { + "name": "view", + "displayName": "view", + "inputName": "view", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "cameraPosition", + "displayName": "cameraPosition", + "inputName": "cameraPosition", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "perturbedNormal", + "displayName": "perturbedNormal", + "inputName": "perturbedNormal", + "targetBlockId": 53, + "targetConnectionName": "output" + }, + { + "name": "baseColor", + "displayName": "baseColor", + "inputName": "baseColor", + "targetBlockId": 41, + "targetConnectionName": "output" + }, + { + "name": "metallic", + "displayName": "metallic", + "inputName": "metallic", + "targetBlockId": 43, + "targetConnectionName": "output" + }, + { + "name": "roughness", + "displayName": "roughness", + "inputName": "roughness", + "targetBlockId": 42, + "targetConnectionName": "output" + }, + { + "name": "ambientOcc", + "displayName": "ambientOcc" + }, + { + "name": "opacity", + "displayName": "opacity", + "inputName": "opacity", + "targetBlockId": 44, + "targetConnectionName": "output" + }, + { + "name": "indexOfRefraction", + "displayName": "indexOfRefraction", + "inputName": "indexOfRefraction", + "targetBlockId": 45, + "targetConnectionName": "output" + }, + { + "name": "ambientColor", + "displayName": "ambientColor" + }, + { + "name": "reflection", + "displayName": "reflection" + }, + { + "name": "clearcoat", + "displayName": "clearcoat" + }, + { + "name": "sheen", + "displayName": "sheen" + }, + { + "name": "subsurface", + "displayName": "subsurface" + }, + { + "name": "anisotropy", + "displayName": "anisotropy" + }, + { + "name": "iridescence", + "displayName": "iridescence" + } + ], + "outputs": [ + { + "name": "ambientClr", + "displayName": "ambientClr" + }, + { + "name": "diffuseDir", + "displayName": "diffuseDir" + }, + { + "name": "specularDir", + "displayName": "specularDir" + }, + { + "name": "clearcoatDir", + "displayName": "clearcoatDir" + }, + { + "name": "sheenDir", + "displayName": "sheenDir" + }, + { + "name": "diffuseInd", + "displayName": "diffuseInd" + }, + { + "name": "specularInd", + "displayName": "specularInd" + }, + { + "name": "clearcoatInd", + "displayName": "clearcoatInd" + }, + { + "name": "sheenInd", + "displayName": "sheenInd" + }, + { + "name": "refraction", + "displayName": "refraction" + }, + { + "name": "lighting", + "displayName": "lighting" + }, + { + "name": "shadow", + "displayName": "shadow" + }, + { + "name": "alpha", + "displayName": "alpha" + } + ], + "lightFalloff": 0, + "useAlphaTest": false, + "alphaTestCutoff": 0.5, + "useAlphaBlending": false, + "useRadianceOverAlpha": true, + "useSpecularOverAlpha": true, + "enableSpecularAntiAliasing": false, + "realTimeFiltering": false, + "realTimeFilteringQuality": 8, + "useEnergyConservation": true, + "useRadianceOcclusion": true, + "useHorizonOcclusion": true, + "unlit": false, + "forceNormalForward": false, + "debugMode": 0, + "debugLimit": -1, + "debugFactor": 1, + "generateOnlyFragmentCode": false, + "directIntensity": 1, + "environmentIntensity": 1, + "specularIntensity": 1 + }, + { + "customType": "BABYLON.InputBlock", + "id": 14, + "name": "time", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 1, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 15, + "name": "waveFreq", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 3, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 16, + "name": "waveAmp", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.08, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 17, + "name": "waveSpeed", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1.5, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 18, + "name": "timeSpeed", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 14, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 17, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 19, + "name": "splitUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz ", + "displayName": "xyz " + }, + { + "name": "xy ", + "displayName": "xy ", + "inputName": "xy ", + "targetBlockId": 1, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 20, + "name": "phaseU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 19, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 18, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 21, + "name": "phaseV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 54, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 18, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 22, + "name": "vOffset", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1.7, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 23, + "name": "freqU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 20, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 15, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 24, + "name": "freqV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 21, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 15, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 25, + "name": "sinU", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 23, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 1 + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 26, + "name": "sinV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 24, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 1 + }, + { + "customType": "BABYLON.AddBlock", + "id": 27, + "name": "waveSum", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 25, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 26, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 28, + "name": "displacement", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 27, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 29, + "name": "displaceVec", + "target": 4, + "inputs": [ + { + "name": "xyzw ", + "displayName": "xyzw " + }, + { + "name": "xyz ", + "displayName": "xyz " + }, + { + "name": "xy ", + "displayName": "xy " + }, + { + "name": "zw ", + "displayName": "zw " + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 28, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.AddBlock", + "id": 30, + "name": "displacedPos", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 29, + "targetConnectionName": "xyz" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 31, + "name": "deepColor", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 0, + 0.15, + 0.35 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 32, + "name": "shallowColor", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 0.1, + 0.5, + 0.6 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.AddBlock", + "id": 33, + "name": "waveColorGrad", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 25, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 26, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 34, + "name": "halfVal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.5, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 35, + "name": "waveColorBias", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 33, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 34, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.LerpBlock", + "id": 36, + "name": "waterColor", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 31, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 32, + "targetConnectionName": "output" + }, + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 35, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.FresnelBlock", + "id": 37, + "name": "fresnel", + "target": 4, + "inputs": [ + { + "name": "worldNormal", + "displayName": "worldNormal", + "inputName": "worldNormal", + "targetBlockId": 10, + "targetConnectionName": "output" + }, + { + "name": "viewDirection", + "displayName": "viewDirection", + "inputName": "viewDirection", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "bias", + "displayName": "bias", + "inputName": "bias", + "targetBlockId": 38, + "targetConnectionName": "output" + }, + { + "name": "power", + "displayName": "power", + "inputName": "power", + "targetBlockId": 39, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "fresnel", + "displayName": "fresnel" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 38, + "name": "fresnelBias", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.1, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 39, + "name": "fresnelPower", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 2, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 40, + "name": "rimColor", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 0.6, + 0.85, + 0.95 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.LerpBlock", + "id": 41, + "name": "finalColor", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 36, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 40, + "targetConnectionName": "output" + }, + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 37, + "targetConnectionName": "fresnel" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 42, + "name": "roughnessVal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.05, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 43, + "name": "metallicVal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 44, + "name": "alphaVal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.85, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 45, + "name": "indexOfRefraction", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1.33, + "valueType": "number" + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 46, + "name": "cosU", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 23, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 0 + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 47, + "name": "cosV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 24, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 0 + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 48, + "name": "normalXScale", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 46, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 50, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 49, + "name": "normalZScale", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 47, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 50, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 50, + "name": "normalStrength", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.3, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 51, + "name": "one", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1, + "valueType": "number" + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 52, + "name": "perturbNormal", + "target": 4, + "inputs": [ + { + "name": "xyzw ", + "displayName": "xyzw " + }, + { + "name": "xyz ", + "displayName": "xyz " + }, + { + "name": "xy ", + "displayName": "xy " + }, + { + "name": "zw ", + "displayName": "zw " + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 48, + "targetConnectionName": "output" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 51, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 49, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.NormalizeBlock", + "id": 53, + "name": "normPerturb", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 52, + "targetConnectionName": "xyz" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 54, + "name": "vPhaseOffset", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 19, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 22, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + } + ], + "outputNodes": [ + 11, + 12 + ], + "comment": "Animated water material with vertex displacement, fresnel rim, and PBR shading", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 0, + "y": 0 + }, + { + "blockId": 2, + "x": 2380, + "y": 720 + }, + { + "blockId": 3, + "x": 2720, + "y": 0 + }, + { + "blockId": 4, + "x": 3400, + "y": 0 + }, + { + "blockId": 5, + "x": 2380, + "y": 180 + }, + { + "blockId": 6, + "x": 3400, + "y": 180 + }, + { + "blockId": 7, + "x": 2720, + "y": 900 + }, + { + "blockId": 8, + "x": 3740, + "y": 0 + }, + { + "blockId": 9, + "x": 3400, + "y": 360 + }, + { + "blockId": 10, + "x": 2720, + "y": 1080 + }, + { + "blockId": 11, + "x": 4080, + "y": 0 + }, + { + "blockId": 12, + "x": 4080, + "y": 180 + }, + { + "blockId": 13, + "x": 3740, + "y": 180 + }, + { + "blockId": 14, + "x": 340, + "y": 0 + }, + { + "blockId": 15, + "x": 1020, + "y": 180 + }, + { + "blockId": 16, + "x": 2040, + "y": 0 + }, + { + "blockId": 17, + "x": 340, + "y": 180 + }, + { + "blockId": 18, + "x": 680, + "y": 0 + }, + { + "blockId": 19, + "x": 340, + "y": 360 + }, + { + "blockId": 20, + "x": 1020, + "y": 0 + }, + { + "blockId": 21, + "x": 1020, + "y": 360 + }, + { + "blockId": 22, + "x": 340, + "y": 540 + }, + { + "blockId": 23, + "x": 1360, + "y": 0 + }, + { + "blockId": 24, + "x": 1360, + "y": 180 + }, + { + "blockId": 25, + "x": 1700, + "y": 0 + }, + { + "blockId": 26, + "x": 1700, + "y": 180 + }, + { + "blockId": 27, + "x": 2040, + "y": 180 + }, + { + "blockId": 28, + "x": 2380, + "y": 0 + }, + { + "blockId": 29, + "x": 2720, + "y": 180 + }, + { + "blockId": 30, + "x": 3060, + "y": 0 + }, + { + "blockId": 31, + "x": 2720, + "y": 360 + }, + { + "blockId": 32, + "x": 2720, + "y": 540 + }, + { + "blockId": 33, + "x": 2380, + "y": 360 + }, + { + "blockId": 34, + "x": 2380, + "y": 540 + }, + { + "blockId": 35, + "x": 2720, + "y": 720 + }, + { + "blockId": 36, + "x": 3060, + "y": 180 + }, + { + "blockId": 37, + "x": 3060, + "y": 360 + }, + { + "blockId": 38, + "x": 2720, + "y": 1260 + }, + { + "blockId": 39, + "x": 2720, + "y": 1440 + }, + { + "blockId": 40, + "x": 3060, + "y": 540 + }, + { + "blockId": 41, + "x": 3400, + "y": 540 + }, + { + "blockId": 42, + "x": 3400, + "y": 720 + }, + { + "blockId": 43, + "x": 3400, + "y": 900 + }, + { + "blockId": 44, + "x": 3400, + "y": 1080 + }, + { + "blockId": 45, + "x": 3400, + "y": 1260 + }, + { + "blockId": 46, + "x": 2380, + "y": 900 + }, + { + "blockId": 47, + "x": 2380, + "y": 1260 + }, + { + "blockId": 48, + "x": 2720, + "y": 1620 + }, + { + "blockId": 49, + "x": 2720, + "y": 1800 + }, + { + "blockId": 50, + "x": 2380, + "y": 1080 + }, + { + "blockId": 51, + "x": 2720, + "y": 1980 + }, + { + "blockId": 52, + "x": 3060, + "y": 720 + }, + { + "blockId": 53, + "x": 3400, + "y": 1440 + }, + { + "blockId": 54, + "x": 680, + "y": 180 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/package.json b/packages/tools/nme-mcp-server/package.json new file mode 100644 index 00000000000..db192e248af --- /dev/null +++ b/packages/tools/nme-mcp-server/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tools/nme-mcp-server", + "version": "1.0.0", + "private": true, + "description": "MCP server for AI-driven Node Material Editor 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", + "@tools/snippet-loader": "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/nme-mcp-server/rollup.config.mjs b/packages/tools/nme-mcp-server/rollup.config.mjs new file mode 100644 index 00000000000..b6a4d32812a --- /dev/null +++ b/packages/tools/nme-mcp-server/rollup.config.mjs @@ -0,0 +1,3 @@ +import { createConfig } from "../rollup.config.mcp.mjs"; + +export default createConfig(); diff --git a/packages/tools/nme-mcp-server/src/blockRegistry.ts b/packages/tools/nme-mcp-server/src/blockRegistry.ts new file mode 100644 index 00000000000..d6da0ef2930 --- /dev/null +++ b/packages/tools/nme-mcp-server/src/blockRegistry.ts @@ -0,0 +1,1684 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * Complete registry of all Node Material block types available in Babylon.js. + * Each entry describes the block's class name, category, target, and its inputs/outputs. + */ + +/** + * Describes a single input or output connection point on a block. + */ +export interface IConnectionPointInfo { + /** Name of the connection point (e.g. "left", "output") */ + name: string; + /** Data type of the connection point (e.g. "Float", "Vector3") */ + type: string; + /** Whether the connection is optional */ + isOptional?: boolean; +} + +/** + * Describes a block type in the NME catalog. + */ +export interface IBlockTypeInfo { + /** The Babylon.js class name for this block */ + className: string; + /** Category for grouping (e.g. "Math", "Input", "Output") */ + category: string; + /** Human-readable description of what this block does */ + description: string; + /** Which shader stage this block targets */ + target: "Neutral" | "Vertex" | "Fragment" | "VertexAndFragment"; + /** List of input connection points */ + inputs: IConnectionPointInfo[]; + /** List of output connection points */ + outputs: IConnectionPointInfo[]; + /** Extra properties that can be configured on the block */ + properties?: Record; + /** + * Default property values to bake into newly created blocks of this type. + * These are REQUIRED by the Babylon deserialiser – omitting them can cause + * build-time crashes (e.g. ClampBlock without minimum/maximum). + */ + defaultSerializedProperties?: Record; +} + +/** + * Full catalog of block types. This is the canonical reference an AI agent uses + * to know which blocks exist and what ports they have. + * BaseMathBlock and ReflectionTextureBaseBlock are non-creatable base classes; the catalog exposes their concrete subclasses only. + */ +export const BlockRegistry: Record = { + // ─── Input ──────────────────────────────────────────────────────────── + InputBlock: { + className: "InputBlock", + category: "Input", + description: + "Provides input values to the graph. Can be configured as an attribute (position, normal, uv, etc.), " + + "a system value (World, View, Projection matrices, etc.), or a constant/uniform value (Float, Vector2/3/4, Color3/4, Matrix).", + target: "Vertex", + inputs: [], + outputs: [{ name: "output", type: "AutoDetect" }], + properties: { + type: "NodeMaterialBlockConnectionPointTypes — the data type of this input (Float, Vector2, Vector3, Vector4, Color3, Color4, Matrix)", + isConstant: "boolean — whether the value is baked into the shader", + visibleInInspector: "boolean — whether to show this in the Inspector UI", + animationType: "AnimatedInputBlockTypes — None, Time", + systemValue: + "NodeMaterialSystemValues — World, View, Projection, ViewProjection, WorldView, WorldViewProjection, CameraPosition, FogColor, DeltaTime, CameraParameters, MaterialAlpha", + attributeName: "string — position, normal, tangent, uv, uv2, uv3, uv4, uv5, uv6, color, matricesIndices, matricesWeights, matricesIndicesExtra, matricesWeightsExtra", + value: "The actual uniform value (number, Vector2, Vector3, Vector4, Color3, Color4, Matrix)", + min: "number — minimum value for inspector slider", + max: "number — maximum value for inspector slider", + }, + }, + + // ─── Math ───────────────────────────────────────────────────────────── + AddBlock: { + className: "AddBlock", + category: "Math", + description: "Adds two values together (left + right).", + target: "Neutral", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + SubtractBlock: { + className: "SubtractBlock", + category: "Math", + description: "Subtracts right from left (left - right).", + target: "Neutral", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + MultiplyBlock: { + className: "MultiplyBlock", + category: "Math", + description: "Multiplies two values (left * right).", + target: "Neutral", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + DivideBlock: { + className: "DivideBlock", + category: "Math", + description: "Divides left by right (left / right).", + target: "Neutral", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + ScaleBlock: { + className: "ScaleBlock", + category: "Math", + description: "Multiplies an input by a float factor.", + target: "Neutral", + inputs: [ + { name: "input", type: "AutoDetect" }, + { name: "factor", type: "Float" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + ModBlock: { + className: "ModBlock", + category: "Math", + description: "Computes the modulo (left % right).", + target: "Neutral", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + PowBlock: { + className: "PowBlock", + category: "Math", + description: "Raises value to a power (value ^ power).", + target: "Neutral", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "power", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + MinBlock: { + className: "MinBlock", + category: "Math", + description: "Returns the minimum of two values.", + target: "Neutral", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + MaxBlock: { + className: "MaxBlock", + category: "Math", + description: "Returns the maximum of two values.", + target: "Neutral", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + ClampBlock: { + className: "ClampBlock", + category: "Math", + description: "Clamps a value between minimum and maximum.", + target: "Neutral", + inputs: [{ name: "value", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + minimum: "number — lower bound (default 0)", + maximum: "number — upper bound (default 1)", + }, + defaultSerializedProperties: { minimum: 0.0, maximum: 1.0 }, + }, + StepBlock: { + className: "StepBlock", + category: "Math", + description: "Returns 0 if value < edge, else 1. step(edge, value).", + target: "Neutral", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "edge", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + SmoothStepBlock: { + className: "SmoothStepBlock", + category: "Math", + description: "Hermite interpolation between 0 and 1 when value is between edge0 and edge1.", + target: "Neutral", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "edge0", type: "AutoDetect" }, + { name: "edge1", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + LerpBlock: { + className: "LerpBlock", + category: "Math", + description: "Linear interpolation: mix(left, right, gradient).", + target: "Neutral", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + { name: "gradient", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + NLerpBlock: { + className: "NLerpBlock", + category: "Math", + description: "Normalized linear interpolation (normalizes the result of lerp).", + target: "Neutral", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + { name: "gradient", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + NegateBlock: { + className: "NegateBlock", + category: "Math", + description: "Negates a value (-input).", + target: "Neutral", + inputs: [{ name: "value", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + OneMinusBlock: { + className: "OneMinusBlock", + category: "Math", + description: "Computes (1 - input).", + target: "Neutral", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + ReciprocalBlock: { + className: "ReciprocalBlock", + category: "Math", + description: "Computes 1/input.", + target: "Neutral", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + ArcTan2Block: { + className: "ArcTan2Block", + category: "Math", + description: "Computes atan2(x, y).", + target: "Neutral", + inputs: [ + { name: "x", type: "AutoDetect" }, + { name: "y", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "Float" }], + }, + + // ─── Trigonometry ───────────────────────────────────────────────────── + TrigonometryBlock: { + className: "TrigonometryBlock", + category: "Math", + description: + "Performs a trig/math operation on the input. Set the 'operation' property to: " + + "Cos, Sin, Abs, Exp, Exp2, Round, Floor, Ceiling, Sqrt, Log, Tan, ArcTan, ArcCos, ArcSin, Fract, Sign, Radians, Degrees, " + + "SawToothWave, TriangleWave, SquareWave.", + target: "Neutral", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + operation: "TrigonometryBlockOperations — Cos, Sin, Abs, Exp, Exp2, Round, Floor, Ceiling, Sqrt, Log, Tan, ArcTan, ArcCos, ArcSin, Fract, Sign, Radians, Degrees", + }, + }, + + // ─── Vector/Color operations ────────────────────────────────────────── + CrossBlock: { + className: "CrossBlock", + category: "Vector", + description: "Cross product of two Vector3 inputs.", + target: "Neutral", + inputs: [ + { name: "left", type: "Vector3" }, + { name: "right", type: "Vector3" }, + ], + outputs: [{ name: "output", type: "Vector3" }], + }, + DotBlock: { + className: "DotBlock", + category: "Vector", + description: "Dot product of two vectors.", + target: "Neutral", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "Float" }], + }, + NormalizeBlock: { + className: "NormalizeBlock", + category: "Vector", + description: "Normalizes a vector.", + target: "Neutral", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + LengthBlock: { + className: "LengthBlock", + category: "Vector", + description: "Returns the length of a vector.", + target: "Neutral", + inputs: [{ name: "value", type: "AutoDetect" }], + outputs: [{ name: "output", type: "Float" }], + }, + DistanceBlock: { + className: "DistanceBlock", + category: "Vector", + description: "Returns the distance between two vectors.", + target: "Neutral", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "Float" }], + }, + ReflectBlock: { + className: "ReflectBlock", + category: "Vector", + description: "Reflects an incident vector off a surface with the given normal.", + target: "Neutral", + inputs: [ + { name: "incident", type: "AutoDetect" }, + { name: "normal", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + RefractBlock: { + className: "RefractBlock", + category: "Vector", + description: "Computes refraction of an incident vector.", + target: "Neutral", + inputs: [ + { name: "incident", type: "AutoDetect" }, + { name: "normal", type: "AutoDetect" }, + { name: "ior", type: "Float" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + TransformBlock: { + className: "TransformBlock", + category: "Vector", + description: "Transforms a vector by a matrix. Commonly used to apply World, View, Projection transforms.", + target: "Neutral", + inputs: [ + { name: "vector", type: "AutoDetect" }, + { name: "transform", type: "Matrix" }, + ], + outputs: [ + { name: "output", type: "Vector4" }, + { name: "xyz", type: "Vector3" }, + { name: "w", type: "Float" }, + ], + defaultSerializedProperties: { complementZ: 0, complementW: 1 }, + }, + VectorMergerBlock: { + className: "VectorMergerBlock", + category: "Vector", + description: "Merges individual float/vector components into a Vector2, Vector3, or Vector4.", + target: "Neutral", + defaultSerializedProperties: { xSwizzle: "x", ySwizzle: "y", zSwizzle: "z", wSwizzle: "w" }, + inputs: [ + { name: "xyzw ", type: "Vector4", isOptional: true }, + { name: "xyz ", type: "Vector3", isOptional: true }, + { name: "xy ", type: "Vector2", isOptional: true }, + { name: "zw ", type: "Vector2", isOptional: true }, + { name: "x", type: "Float", isOptional: true }, + { name: "y", type: "Float", isOptional: true }, + { name: "z", type: "Float", isOptional: true }, + { name: "w", type: "Float", isOptional: true }, + ], + outputs: [ + { name: "xyzw", type: "Vector4" }, + { name: "xyz", type: "Vector3" }, + { name: "xy", type: "Vector2" }, + { name: "zw", type: "Vector2" }, + ], + }, + VectorSplitterBlock: { + className: "VectorSplitterBlock", + category: "Vector", + description: "Splits a vector into its individual components.", + target: "Neutral", + inputs: [ + { name: "xyzw", type: "Vector4", isOptional: true }, + { name: "xyz ", type: "Vector3", isOptional: true }, + { name: "xy ", type: "Vector2", isOptional: true }, + ], + outputs: [ + { name: "xyz", type: "Vector3" }, + { name: "xy", type: "Vector2" }, + { name: "zw", type: "Vector2" }, + { name: "x", type: "Float" }, + { name: "y", type: "Float" }, + { name: "z", type: "Float" }, + { name: "w", type: "Float" }, + ], + }, + ColorMergerBlock: { + className: "ColorMergerBlock", + category: "Color", + description: "Merges R, G, B, A float components into a Color3 or Color4.", + target: "Neutral", + inputs: [ + { name: "rgb ", type: "Color3", isOptional: true }, + { name: "r", type: "Float", isOptional: true }, + { name: "g", type: "Float", isOptional: true }, + { name: "b", type: "Float", isOptional: true }, + { name: "a", type: "Float", isOptional: true }, + ], + outputs: [ + { name: "rgba", type: "Color4" }, + { name: "rgb", type: "Color3" }, + ], + }, + ColorSplitterBlock: { + className: "ColorSplitterBlock", + category: "Color", + description: "Splits a Color3/Color4 into individual R, G, B, A float components.", + target: "Neutral", + inputs: [ + { name: "rgba", type: "Color4", isOptional: true }, + { name: "rgb ", type: "Color3", isOptional: true }, + ], + outputs: [ + { name: "rgb", type: "Color3" }, + { name: "r", type: "Float" }, + { name: "g", type: "Float" }, + { name: "b", type: "Float" }, + { name: "a", type: "Float" }, + ], + }, + ColorConverterBlock: { + className: "ColorConverterBlock", + category: "Color", + description: "Converts between RGB and HSL color spaces.", + target: "Neutral", + inputs: [ + { name: "rgb ", type: "Color3", isOptional: true }, + { name: "hsl ", type: "Color3", isOptional: true }, + ], + outputs: [ + { name: "rgb", type: "Color3" }, + { name: "hsl", type: "Color3" }, + ], + }, + DesaturateBlock: { + className: "DesaturateBlock", + category: "Color", + description: "Desaturates a color by a given amount.", + target: "Neutral", + inputs: [ + { name: "color", type: "Color3" }, + { name: "level", type: "Float" }, + ], + outputs: [{ name: "output", type: "Color3" }], + }, + PosterizeBlock: { + className: "PosterizeBlock", + category: "Color", + description: "Reduces the number of color levels (posterize effect).", + target: "Neutral", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "steps", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + ReplaceColorBlock: { + className: "ReplaceColorBlock", + category: "Color", + description: "Replaces a color in a texture with another color within a distance range.", + target: "Neutral", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "reference", type: "AutoDetect" }, + { name: "distance", type: "Float" }, + { name: "replacement", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + GradientBlock: { + className: "GradientBlock", + category: "Color", + description: "Returns a color from a gradient based on a float input (0-1).", + target: "Neutral", + inputs: [{ name: "gradient", type: "AutoDetect" }], + outputs: [{ name: "output", type: "Color3" }], + properties: { + colorSteps: "Array of {color: Color3, step: number} — define the gradient stops", + }, + }, + + // ─── Interpolation / Mapping ────────────────────────────────────────── + RemapBlock: { + className: "RemapBlock", + category: "Interpolation", + description: "Remaps a value from one range to another.", + target: "Neutral", + inputs: [ + { name: "input", type: "AutoDetect" }, + { name: "sourceMin", type: "Float", isOptional: true }, + { name: "sourceMax", type: "Float", isOptional: true }, + { name: "targetMin", type: "Float", isOptional: true }, + { name: "targetMax", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + defaultSerializedProperties: { sourceRange: [-1, 1], targetRange: [0, 1] }, + properties: { + sourceRange: "number[] — [min, max] of source range (default [-1,1])", + targetRange: "number[] — [min, max] of target range (default [0,1])", + }, + }, + FresnelBlock: { + className: "FresnelBlock", + category: "Interpolation", + description: "Computes a Fresnel term based on view direction and normal.", + target: "Neutral", + inputs: [ + { name: "worldNormal", type: "Vector4" }, + { name: "viewDirection", type: "Vector3" }, + { name: "bias", type: "Float" }, + { name: "power", type: "Float" }, + ], + outputs: [{ name: "fresnel", type: "Float" }], + }, + ConditionalBlock: { + className: "ConditionalBlock", + category: "Logic", + description: "If/else conditional: outputs ifTrue or ifFalse based on comparing a and b.", + target: "Neutral", + inputs: [ + { name: "a", type: "Float" }, + { name: "b", type: "Float" }, + { name: "ifTrue", type: "AutoDetect" }, + { name: "ifFalse", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + condition: "ConditionalBlockConditions — Equal, NotEqual, LessThan, GreaterThan, LessOrEqual, GreaterOrEqual, Xor, Or, And", + }, + }, + + // ─── Noise / Procedural ─────────────────────────────────────────────── + RandomNumberBlock: { + className: "RandomNumberBlock", + category: "Noise", + description: "Generates a random number based on a seed.", + target: "Neutral", + inputs: [{ name: "seed", type: "AutoDetect" }], + outputs: [{ name: "output", type: "Float" }], + }, + SimplexPerlin3DBlock: { + className: "SimplexPerlin3DBlock", + category: "Noise", + description: "3D Simplex Perlin noise.", + target: "Neutral", + inputs: [{ name: "seed", type: "Vector3" }], + outputs: [{ name: "output", type: "Float" }], + }, + WorleyNoise3DBlock: { + className: "WorleyNoise3DBlock", + category: "Noise", + description: "3D Worley (cellular) noise.", + target: "Neutral", + inputs: [ + { name: "seed", type: "Vector3" }, + { name: "jitter", type: "Float" }, + ], + outputs: [ + { name: "output", type: "Vector2" }, + { name: "x", type: "Float" }, + { name: "y", type: "Float" }, + ], + }, + VoronoiNoiseBlock: { + className: "VoronoiNoiseBlock", + category: "Noise", + description: "Voronoi noise pattern.", + target: "Neutral", + inputs: [ + { name: "seed", type: "Vector2" }, + { name: "offset", type: "Float" }, + { name: "density", type: "Float" }, + ], + outputs: [ + { name: "output", type: "Float" }, + { name: "cells", type: "Float" }, + ], + }, + CloudBlock: { + className: "CloudBlock", + category: "Noise", + description: "Generates cloud-like noise pattern.", + target: "Neutral", + inputs: [ + { name: "seed", type: "AutoDetect" }, + { name: "chaos", type: "AutoDetect", isOptional: true }, + { name: "offsetX", type: "Float", isOptional: true }, + { name: "offsetY", type: "Float", isOptional: true }, + { name: "offsetZ", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "Float" }], + properties: { + octaves: "number — number of octaves (default 6)", + }, + }, + WaveBlock: { + className: "WaveBlock", + category: "Noise", + description: "Generates a wave pattern. Set 'kind' to SawTooth, Square, or Triangle.", + target: "Neutral", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + kind: "WaveBlockKind — SawTooth, Square, Triangle", + }, + }, + + // ─── UV / Animation ────────────────────────────────────────────────── + Rotate2dBlock: { + className: "Rotate2dBlock", + category: "UV", + description: "Rotates a 2D vector around a center point by an angle.", + target: "Neutral", + inputs: [ + { name: "input", type: "Vector2" }, + { name: "angle", type: "Float" }, + ], + outputs: [{ name: "output", type: "Vector2" }], + }, + PannerBlock: { + className: "PannerBlock", + category: "UV", + description: "Pans (scrolls) a 2D input over time.", + target: "Neutral", + inputs: [ + { name: "uv", type: "AutoDetect" }, + { name: "time", type: "Float" }, + { name: "speed", type: "Vector2" }, + ], + outputs: [{ name: "output", type: "Vector2" }], + }, + + // ─── Normal / Misc ─────────────────────────────────────────────────── + NormalBlendBlock: { + className: "NormalBlendBlock", + category: "Misc", + description: "Blends two normal maps together.", + target: "Neutral", + inputs: [ + { name: "normalMap0", type: "AutoDetect" }, + { name: "normalMap1", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + ViewDirectionBlock: { + className: "ViewDirectionBlock", + category: "Misc", + description: "Computes the view direction from a world position to the camera.", + target: "Neutral", + inputs: [ + { name: "worldPosition", type: "Vector4" }, + { name: "cameraPosition", type: "Vector3" }, + ], + outputs: [{ name: "output", type: "Vector3" }], + }, + CurveBlock: { + className: "CurveBlock", + category: "Misc", + description: "Applies an easing curve. Set 'type' property to the curve type.", + target: "Neutral", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + type: "CurveBlockTypes — EaseInSine, EaseOutSine, EaseInOutSine, EaseInQuad, EaseOutQuad, EaseInOutQuad, etc.", + }, + }, + MatrixBuilderBlock: { + className: "MatrixBuilder", + category: "Matrix", + description: "Builds a matrix from individual row vectors.", + target: "Neutral", + inputs: [ + { name: "row0", type: "Vector4" }, + { name: "row1", type: "Vector4" }, + { name: "row2", type: "Vector4" }, + { name: "row3", type: "Vector4" }, + ], + outputs: [{ name: "output", type: "Matrix" }], + }, + MatrixDeterminantBlock: { + className: "MatrixDeterminantBlock", + category: "Matrix", + description: "Computes the determinant of a matrix.", + target: "Neutral", + inputs: [{ name: "input", type: "Matrix" }], + outputs: [{ name: "output", type: "Float" }], + }, + MatrixTransposeBlock: { + className: "MatrixTransposeBlock", + category: "Matrix", + description: "Transposes a matrix.", + target: "Neutral", + inputs: [{ name: "input", type: "Matrix" }], + outputs: [{ name: "output", type: "Matrix" }], + }, + + // ─── Vertex Target ──────────────────────────────────────────────────── + VertexOutputBlock: { + className: "VertexOutputBlock", + category: "Output", + description: "The vertex shader output. Connect the final transformed position here. Every material needs exactly one.", + target: "Vertex", + inputs: [{ name: "vector", type: "Vector4" }], + outputs: [], + }, + BonesBlock: { + className: "BonesBlock", + category: "Vertex", + description: "Adds skeleton bone transformation support to the vertex shader.", + target: "Vertex", + inputs: [ + { name: "matricesIndices", type: "Vector4" }, + { name: "matricesWeights", type: "Vector4" }, + { name: "matricesIndicesExtra", type: "Vector4", isOptional: true }, + { name: "matricesWeightsExtra", type: "Vector4", isOptional: true }, + { name: "world", type: "Matrix" }, + ], + outputs: [{ name: "output", type: "Matrix" }], + }, + InstancesBlock: { + className: "InstancesBlock", + category: "Vertex", + description: "Adds instancing support.", + target: "Vertex", + inputs: [ + { name: "world0", type: "Vector4" }, + { name: "world1", type: "Vector4" }, + { name: "world2", type: "Vector4" }, + { name: "world3", type: "Vector4" }, + { name: "world", type: "Matrix" }, + ], + outputs: [ + { name: "output", type: "Matrix" }, + { name: "instanceID", type: "Float" }, + ], + }, + MorphTargetsBlock: { + className: "MorphTargetsBlock", + category: "Vertex", + description: "Adds morph target animation support.", + target: "Vertex", + inputs: [ + { name: "position", type: "Vector3" }, + { name: "normal", type: "Vector3" }, + { name: "tangent", type: "Vector4", isOptional: true }, + { name: "uv", type: "Vector2", isOptional: true }, + ], + outputs: [ + { name: "positionOutput", type: "Vector3" }, + { name: "normalOutput", type: "Vector3" }, + { name: "tangentOutput", type: "Vector4" }, + { name: "uvOutput", type: "Vector2" }, + ], + }, + LightInformationBlock: { + className: "LightInformationBlock", + category: "Vertex", + description: "Provides light information (direction, color, intensity) for a specific light in the scene.", + target: "Vertex", + inputs: [{ name: "worldPosition", type: "Vector4" }], + outputs: [ + { name: "direction", type: "Vector3" }, + { name: "color", type: "Color3" }, + { name: "intensity", type: "Float" }, + { name: "shadowAttenuation", type: "Float" }, + ], + }, + + // ─── Fragment Target ────────────────────────────────────────────────── + FragmentOutputBlock: { + className: "FragmentOutputBlock", + category: "Output", + description: + "The fragment shader output. Connect the final color here. Every material needs exactly one. " + "Has 'rgb' (Color3), 'rgba' (Color4), and 'a' (Float/alpha) inputs.", + target: "Fragment", + inputs: [ + { name: "rgba", type: "Color4", isOptional: true }, + { name: "rgb", type: "Color3", isOptional: true }, + { name: "a", type: "Float", isOptional: true }, + ], + outputs: [], + }, + DiscardBlock: { + className: "DiscardBlock", + category: "Fragment", + description: "Discards the current fragment if the value is below the cutoff.", + target: "Fragment", + inputs: [ + { name: "value", type: "Float" }, + { name: "cutoff", type: "Float" }, + ], + outputs: [], + }, + ImageProcessingBlock: { + className: "ImageProcessingBlock", + category: "Fragment", + description: "Applies image processing (tone mapping, contrast, exposure, etc.) to a color.", + target: "Fragment", + inputs: [{ name: "color", type: "Color4" }], + outputs: [ + { name: "output", type: "Color4" }, + { name: "rgb", type: "Color3" }, + ], + }, + PerturbNormalBlock: { + className: "PerturbNormalBlock", + category: "Fragment", + description: "Perturbs the world normal using a normal map texture.", + target: "Fragment", + inputs: [ + { name: "worldPosition", type: "Vector4" }, + { name: "worldNormal", type: "Vector4" }, + { name: "worldTangent", type: "Vector4", isOptional: true }, + { name: "uv", type: "Vector2" }, + { name: "normalMapColor", type: "Color3" }, + { name: "strength", type: "Float" }, + { name: "viewDirection", type: "Vector3" }, + { name: "TBN", type: "Object", isOptional: true }, + ], + outputs: [ + { name: "output", type: "Vector4" }, + { name: "uvOffset", type: "Vector2" }, + ], + }, + FrontFacingBlock: { + className: "FrontFacingBlock", + category: "Fragment", + description: "Returns 1.0 if the fragment is front-facing, 0.0 otherwise.", + target: "Fragment", + inputs: [], + outputs: [{ name: "output", type: "Float" }], + }, + DerivativeBlock: { + className: "DerivativeBlock", + category: "Fragment", + description: "Computes screen-space derivatives (dFdx, dFdy) of an input.", + target: "Fragment", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [ + { name: "dx", type: "BasedOnInput" }, + { name: "dy", type: "BasedOnInput" }, + ], + }, + ScreenSpaceBlock: { + className: "ScreenSpaceBlock", + category: "Fragment", + description: "Provides the screen-space position of the current fragment.", + target: "Fragment", + inputs: [ + { name: "vector", type: "Vector3" }, + { name: "worldViewProjection", type: "Matrix" }, + ], + outputs: [ + { name: "output", type: "Vector2" }, + { name: "x", type: "Float" }, + { name: "y", type: "Float" }, + ], + }, + ScreenSizeBlock: { + className: "ScreenSizeBlock", + category: "Fragment", + description: "Provides the screen size in pixels.", + target: "Fragment", + inputs: [], + outputs: [ + { name: "xy", type: "Vector2" }, + { name: "x", type: "Float" }, + { name: "y", type: "Float" }, + ], + }, + TwirlBlock: { + className: "TwirlBlock", + category: "Fragment", + description: "Applies a twirl effect to UV coordinates.", + target: "Fragment", + inputs: [ + { name: "input", type: "Vector2" }, + { name: "strength", type: "Float" }, + { name: "center", type: "Vector2" }, + { name: "offset", type: "Vector2" }, + ], + outputs: [{ name: "output", type: "Vector2" }], + }, + FragCoordBlock: { + className: "FragCoordBlock", + category: "Fragment", + description: "Provides the gl_FragCoord value.", + target: "Fragment", + inputs: [], + outputs: [ + { name: "xy", type: "Vector2" }, + { name: "xyz", type: "Vector3" }, + { name: "xyzw", type: "Vector4" }, + { name: "x", type: "Float" }, + { name: "y", type: "Float" }, + { name: "z", type: "Float" }, + { name: "w", type: "Float" }, + ], + }, + ShadowMapBlock: { + className: "ShadowMapBlock", + category: "Fragment", + description: "Generates a shadow map output.", + target: "Fragment", + inputs: [{ name: "worldPosition", type: "Vector4" }], + outputs: [ + { name: "output", type: "Object" }, + { name: "depth", type: "Float" }, + ], + }, + + // ─── Dual (Vertex + Fragment) ───────────────────────────────────────── + TextureBlock: { + className: "TextureBlock", + category: "Texture", + description: "Samples a 2D texture. Provide a URL via the texture property.", + target: "VertexAndFragment", + inputs: [{ name: "uv", type: "AutoDetect" }], + outputs: [ + { name: "rgba", type: "Color4" }, + { name: "rgb", type: "Color3" }, + { name: "r", type: "Float" }, + { name: "g", type: "Float" }, + { name: "b", type: "Float" }, + { name: "a", type: "Float" }, + { name: "level", type: "Float" }, + ], + properties: { + texture: "Texture — set via new Texture(url, scene)", + convertToGammaSpace: "boolean", + convertToLinearSpace: "boolean", + }, + }, + ReflectionTextureBlock: { + className: "ReflectionTextureBlock", + category: "Texture", + description: "Samples a reflection/environment texture (cubemap, equirectangular, etc.).", + target: "VertexAndFragment", + inputs: [ + { name: "position", type: "AutoDetect" }, + { name: "worldPosition", type: "Vector4" }, + { name: "worldNormal", type: "Vector4" }, + { name: "world", type: "Matrix" }, + { name: "cameraPosition", type: "Vector3" }, + { name: "view", type: "Matrix" }, + ], + outputs: [ + { name: "rgb", type: "Color3" }, + { name: "r", type: "Float" }, + { name: "g", type: "Float" }, + { name: "b", type: "Float" }, + { name: "hasTexture", type: "Float" }, + ], + }, + LightBlock: { + className: "LightBlock", + category: "Lighting", + description: "Computes lighting (diffuse and specular) for a given world position and normal. Uses scene lights.", + target: "VertexAndFragment", + inputs: [ + { name: "worldPosition", type: "Vector4" }, + { name: "worldNormal", type: "AutoDetect" }, + { name: "cameraPosition", type: "Vector3" }, + { name: "glossiness", type: "Float", isOptional: true }, + { name: "glossPower", type: "Float", isOptional: true }, + { name: "diffuseColor", type: "Color3", isOptional: true }, + { name: "specularColor", type: "Color3", isOptional: true }, + { name: "view", type: "Matrix" }, + ], + outputs: [ + { name: "diffuseOutput", type: "Color3" }, + { name: "specularOutput", type: "Color3" }, + { name: "shadow", type: "Float" }, + ], + }, + FogBlock: { + className: "FogBlock", + category: "Misc", + description: "Adds fog to the material output.", + target: "VertexAndFragment", + inputs: [ + { name: "worldPosition", type: "Vector4" }, + { name: "view", type: "Matrix" }, + { name: "input", type: "AutoDetect" }, + { name: "fogColor", type: "Color3" }, + ], + outputs: [{ name: "output", type: "Color3" }], + }, + CurrentScreenBlock: { + className: "CurrentScreenBlock", + category: "Texture", + description: "Samples the current screen (for post-process / refraction effects).", + target: "VertexAndFragment", + inputs: [{ name: "uv", type: "AutoDetect" }], + outputs: [ + { name: "rgba", type: "Color4" }, + { name: "rgb", type: "Color3" }, + { name: "r", type: "Float" }, + { name: "g", type: "Float" }, + { name: "b", type: "Float" }, + { name: "a", type: "Float" }, + ], + }, + ImageSourceBlock: { + className: "ImageSourceBlock", + category: "Texture", + description: "Provides a texture source that can be shared by multiple texture blocks.", + target: "VertexAndFragment", + inputs: [], + outputs: [{ name: "source", type: "Object" }], + properties: { + texture: "Texture — set via new Texture(url, scene)", + }, + }, + SceneDepthBlock: { + className: "SceneDepthBlock", + category: "Texture", + description: "Samples the scene depth texture.", + target: "VertexAndFragment", + inputs: [{ name: "uv", type: "AutoDetect" }], + outputs: [{ name: "output", type: "Float" }], + }, + + // ─── PBR ───────────────────────────────────────────────────────────── + PBRMetallicRoughnessBlock: { + className: "PBRMetallicRoughnessBlock", + category: "PBR", + description: + "Full PBR (Metallic-Roughness) lighting model. This is a powerful all-in-one block for physically-based rendering. " + + "Connect world position, normal, camera position, and material properties.", + target: "VertexAndFragment", + defaultSerializedProperties: { + lightFalloff: 0, + useAlphaTest: false, + alphaTestCutoff: 0.5, + useAlphaBlending: false, + useRadianceOverAlpha: true, + useSpecularOverAlpha: true, + enableSpecularAntiAliasing: false, + realTimeFiltering: false, + realTimeFilteringQuality: 8, + useEnergyConservation: true, + useRadianceOcclusion: true, + useHorizonOcclusion: true, + unlit: false, + forceNormalForward: false, + debugMode: 0, + debugLimit: -1, + debugFactor: 1, + generateOnlyFragmentCode: false, + directIntensity: 1.0, + environmentIntensity: 1.0, + specularIntensity: 1.0, + }, + inputs: [ + { name: "worldPosition", type: "Vector4" }, + { name: "worldNormal", type: "Vector4" }, + { name: "view", type: "Matrix" }, + { name: "cameraPosition", type: "Vector3" }, + { name: "perturbedNormal", type: "Vector4", isOptional: true }, + { name: "baseColor", type: "Color3", isOptional: true }, + { name: "metallic", type: "Float", isOptional: true }, + { name: "roughness", type: "Float", isOptional: true }, + { name: "ambientOcc", type: "Float", isOptional: true }, + { name: "opacity", type: "Float", isOptional: true }, + { name: "indexOfRefraction", type: "Float", isOptional: true }, + { name: "ambientColor", type: "Color3", isOptional: true }, + { name: "reflection", type: "Object", isOptional: true }, + { name: "clearcoat", type: "Object", isOptional: true }, + { name: "sheen", type: "Object", isOptional: true }, + { name: "subsurface", type: "Object", isOptional: true }, + { name: "anisotropy", type: "Object", isOptional: true }, + { name: "iridescence", type: "Object", isOptional: true }, + ], + outputs: [ + { name: "ambientClr", type: "Color3" }, + { name: "diffuseDir", type: "Color3" }, + { name: "specularDir", type: "Color3" }, + { name: "clearcoatDir", type: "Color3" }, + { name: "sheenDir", type: "Color3" }, + { name: "diffuseInd", type: "Color3" }, + { name: "specularInd", type: "Color3" }, + { name: "clearcoatInd", type: "Color3" }, + { name: "sheenInd", type: "Color3" }, + { name: "refraction", type: "Color3" }, + { name: "lighting", type: "Color3" }, + { name: "shadow", type: "Float" }, + { name: "alpha", type: "Float" }, + ], + }, + ReflectionBlock: { + className: "ReflectionBlock", + category: "PBR", + description: "PBR reflection block — connects to PBRMetallicRoughnessBlock's reflection input.", + target: "VertexAndFragment", + inputs: [ + { name: "position", type: "AutoDetect" }, + { name: "world", type: "Matrix" }, + { name: "color", type: "Color3", isOptional: true }, + ], + outputs: [{ name: "reflection", type: "Object" }], + }, + RefractionBlock: { + className: "RefractionBlock", + category: "PBR", + description: "PBR refraction block — connects to PBRMetallicRoughnessBlock's subsurface.", + target: "VertexAndFragment", + inputs: [ + { name: "intensity", type: "Float", isOptional: true }, + { name: "tintAtDistance", type: "Float", isOptional: true }, + ], + outputs: [{ name: "refraction", type: "Object" }], + }, + SheenBlock: { + className: "SheenBlock", + category: "PBR", + description: "PBR sheen block for fabric-like materials.", + target: "Fragment", + inputs: [ + { name: "intensity", type: "Float" }, + { name: "color", type: "Color3" }, + { name: "roughness", type: "Float", isOptional: true }, + ], + outputs: [{ name: "sheen", type: "Object" }], + }, + AnisotropyBlock: { + className: "AnisotropyBlock", + category: "PBR", + description: "PBR anisotropy block for directional reflections.", + target: "Fragment", + inputs: [ + { name: "intensity", type: "Float", isOptional: true }, + { name: "direction", type: "Vector2", isOptional: true }, + { name: "uv", type: "Vector2", isOptional: true }, + { name: "worldTangent", type: "Vector4", isOptional: true }, + ], + outputs: [{ name: "anisotropy", type: "Object" }], + }, + ClearCoatBlock: { + className: "ClearCoatBlock", + category: "PBR", + description: "PBR clear coat block for an additional transparent layer (like car paint).", + target: "Fragment", + inputs: [ + { name: "intensity", type: "Float", isOptional: true }, + { name: "roughness", type: "Float", isOptional: true }, + { name: "indexOfRefraction", type: "Float", isOptional: true }, + { name: "normalMapColor", type: "Color3", isOptional: true }, + { name: "uv", type: "Vector2", isOptional: true }, + { name: "tintColor", type: "Color3", isOptional: true }, + { name: "tintAtDistance", type: "Float", isOptional: true }, + { name: "tintThickness", type: "Float", isOptional: true }, + { name: "worldTangent", type: "Vector4", isOptional: true }, + { name: "TBN", type: "Object", isOptional: true }, + ], + outputs: [{ name: "clearcoat", type: "Object" }], + }, + SubSurfaceBlock: { + className: "SubSurfaceBlock", + category: "PBR", + description: "PBR sub-surface scattering for translucent materials (skin, wax, etc.).", + target: "Fragment", + inputs: [ + { name: "thickness", type: "Float", isOptional: true }, + { name: "tintColor", type: "Color3", isOptional: true }, + { name: "translucencyIntensity", type: "Float", isOptional: true }, + { name: "translucencyDiffusionDist", type: "Color3", isOptional: true }, + { name: "refraction", type: "Object", isOptional: true }, + ], + outputs: [{ name: "subsurface", type: "Object" }], + }, + IridescenceBlock: { + className: "IridescenceBlock", + category: "PBR", + description: "PBR iridescence for rainbow-like thin-film effects.", + target: "Fragment", + inputs: [ + { name: "intensity", type: "Float", isOptional: true }, + { name: "indexOfRefraction", type: "Float", isOptional: true }, + { name: "thickness", type: "Float", isOptional: true }, + ], + outputs: [{ name: "iridescence", type: "Object" }], + }, + + // ─── Teleport ──────────────────────────────────────────────────────── + TeleportInBlock: { + className: "NodeMaterialTeleportInBlock", + category: "Teleport", + description: "Teleport input — sends a value to a paired TeleportOutBlock without a visible connection line.", + target: "Neutral", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [], + }, + TeleportOutBlock: { + className: "NodeMaterialTeleportOutBlock", + category: "Teleport", + description: "Teleport output — receives a value from a paired TeleportInBlock.", + target: "Neutral", + inputs: [], + outputs: [{ name: "output", type: "AutoDetect" }], + }, + + // ─── Tri/Bi-Planar ────────────────────────────────────────────────── + TriPlanarBlock: { + className: "TriPlanarBlock", + category: "Texture", + description: "Tri-planar texture mapping — projects a texture from 3 axes to avoid UV stretching.", + target: "Neutral", + inputs: [ + { name: "position", type: "AutoDetect" }, + { name: "normal", type: "AutoDetect" }, + { name: "sharpness", type: "Float", isOptional: true }, + { name: "source", type: "Object", isOptional: true }, + { name: "sourceY", type: "Object", isOptional: true }, + ], + outputs: [ + { name: "rgba", type: "Color4" }, + { name: "rgb", type: "Color3" }, + { name: "r", type: "Float" }, + { name: "g", type: "Float" }, + { name: "b", type: "Float" }, + { name: "a", type: "Float" }, + { name: "level", type: "Float" }, + ], + }, + BiPlanarBlock: { + className: "BiPlanarBlock", + category: "Texture", + description: "Bi-planar texture mapping — projects a texture from 2 dominant axes.", + target: "Neutral", + inputs: [ + { name: "position", type: "AutoDetect" }, + { name: "normal", type: "AutoDetect" }, + { name: "sharpness", type: "Float", isOptional: true }, + { name: "source", type: "Object", isOptional: true }, + { name: "sourceY", type: "Object", isOptional: true }, + ], + outputs: [ + { name: "rgba", type: "Color4" }, + { name: "rgb", type: "Color3" }, + { name: "r", type: "Float" }, + { name: "g", type: "Float" }, + { name: "b", type: "Float" }, + { name: "a", type: "Float" }, + { name: "level", type: "Float" }, + ], + }, + MeshAttributeExistsBlock: { + className: "MeshAttributeExistsBlock", + category: "Misc", + description: "Outputs ifYes or ifNo based on whether a mesh attribute (UV, color, etc.) exists.", + target: "Neutral", + inputs: [ + { name: "ifYes", type: "AutoDetect" }, + { name: "ifNo", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + attributeType: "MeshAttributeExistsBlockTypes — None, Normal, Tangent, VertexColor, UV1, UV2, UV3, UV4, UV5, UV6", + }, + }, + + // ─── Particle Blocks ──────────────────────────────────────────────── + ParticleTextureBlock: { + className: "ParticleTextureBlock", + category: "Particle", + description: "Samples a particle texture.", + target: "VertexAndFragment", + inputs: [{ name: "uv", type: "AutoDetect" }], + outputs: [ + { name: "rgba", type: "Color4" }, + { name: "rgb", type: "Color3" }, + { name: "r", type: "Float" }, + { name: "g", type: "Float" }, + { name: "b", type: "Float" }, + { name: "a", type: "Float" }, + ], + }, + ParticleRampGradientBlock: { + className: "ParticleRampGradientBlock", + category: "Particle", + description: "Applies a ramp gradient to particle color.", + target: "Fragment", + inputs: [{ name: "color", type: "Color4" }], + outputs: [{ name: "rampColor", type: "Color4" }], + }, + ParticleBlendMultiplyBlock: { + className: "ParticleBlendMultiplyBlock", + category: "Particle", + description: "Applies blend-multiply to particle color.", + target: "Fragment", + inputs: [ + { name: "color", type: "Color4" }, + { name: "alphaTexture", type: "Float" }, + { name: "alphaColor", type: "Float" }, + ], + outputs: [{ name: "blendColor", type: "Color4" }], + }, + + // ─── Normal Mapping ────────────────────────────────────────────────── + TBNBlock: { + className: "TBNBlock", + category: "Fragment", + description: "Computes the Tangent-Bitangent-Normal (TBN) matrix for tangent-space normal mapping. " + "Connect to PerturbNormalBlock or ClearCoatBlock TBN input.", + target: "Fragment", + inputs: [ + { name: "normal", type: "AutoDetect" }, + { name: "tangent", type: "Vector4" }, + { name: "world", type: "Matrix" }, + ], + outputs: [ + { name: "TBN", type: "Object" }, + { name: "row0", type: "Vector3" }, + { name: "row1", type: "Vector3" }, + { name: "row2", type: "Vector3" }, + ], + }, + HeightToNormalBlock: { + className: "HeightToNormalBlock", + category: "Fragment", + description: + "Converts a height field (Float) to a normal vector using screen-space derivatives. " + "Output is in tangent space by default (xyz with 0.5 offset) or world space.", + target: "Fragment", + inputs: [ + { name: "input", type: "Float" }, + { name: "worldPosition", type: "Vector3" }, + { name: "worldNormal", type: "Vector3" }, + { name: "worldTangent", type: "AutoDetect", isOptional: true }, + ], + outputs: [ + { name: "output", type: "Vector4" }, + { name: "xyz", type: "Vector3" }, + ], + properties: { + generateInWorldSpace: "boolean — false=tangent space (default), true=world space", + automaticNormalizationNormal: "boolean — auto-normalize normal input (default true)", + automaticNormalizationTangent: "boolean — auto-normalize tangent input (default true)", + }, + defaultSerializedProperties: { + generateInWorldSpace: false, + automaticNormalizationNormal: true, + automaticNormalizationTangent: true, + }, + }, + + // ─── Matrix ────────────────────────────────────────────────────────── + MatrixSplitterBlock: { + className: "MatrixSplitterBlock", + category: "Matrix", + description: "Splits a 4×4 matrix into its individual row and column vectors.", + target: "Neutral", + inputs: [{ name: "input", type: "Matrix" }], + outputs: [ + { name: "row0", type: "Vector4" }, + { name: "row1", type: "Vector4" }, + { name: "row2", type: "Vector4" }, + { name: "row3", type: "Vector4" }, + { name: "col0", type: "Vector4" }, + { name: "col1", type: "Vector4" }, + { name: "col2", type: "Vector4" }, + { name: "col3", type: "Vector4" }, + ], + }, + + // ─── Fragment Depth ────────────────────────────────────────────────── + FragDepthBlock: { + className: "FragDepthBlock", + category: "Fragment", + description: "Writes to gl_FragDepth. Provide either a direct depth float, " + "or worldPos + viewProjection to compute depth automatically.", + target: "Fragment", + inputs: [ + { name: "depth", type: "Float", isOptional: true }, + { name: "worldPos", type: "Vector4", isOptional: true }, + { name: "viewProjection", type: "Matrix", isOptional: true }, + ], + outputs: [], + }, + + // ─── Ambient Occlusion ─────────────────────────────────────────────── + AmbientOcclusionBlock: { + className: "AmbientOcclusionBlock", + category: "Fragment", + description: "Evaluates screen-space ambient occlusion (SSAO) from a depth texture.", + target: "Fragment", + inputs: [ + { name: "source", type: "Object" }, + { name: "screenSize", type: "Vector2" }, + ], + outputs: [{ name: "occlusion", type: "Float" }], + properties: { + radius: "number — SSAO radius (default 0.0001)", + area: "number — SSAO area (default 0.0075)", + fallOff: "number — SSAO falloff (default 0.000001)", + }, + defaultSerializedProperties: { + radius: 0.0001, + area: 0.0075, + fallOff: 0.000001, + }, + }, + + // ─── Clip Planes ───────────────────────────────────────────────────── + ClipPlanesBlock: { + className: "ClipPlanesBlock", + category: "Misc", + description: "Implements clip planes for the material. Connect worldPosition to enable clipping.", + target: "VertexAndFragment", + inputs: [{ name: "worldPosition", type: "Vector4" }], + outputs: [], + }, + + // ─── Control Flow / Loops ──────────────────────────────────────────── + LoopBlock: { + className: "LoopBlock", + category: "Logic", + description: + "Wraps code in a for loop. The input value is passed through and the output is the result after all iterations. " + + "Use StorageReadBlock/StorageWriteBlock to read/write loop variables.", + target: "Neutral", + inputs: [ + { name: "input", type: "AutoDetect" }, + { name: "iterations", type: "Float", isOptional: true }, + ], + outputs: [ + { name: "output", type: "BasedOnInput" }, + { name: "index", type: "Float" }, + { name: "loopID", type: "Object" }, + ], + properties: { + iterations: "number — number of loop iterations (default 4)", + }, + defaultSerializedProperties: { iterations: 4 }, + }, + StorageReadBlock: { + className: "StorageReadBlock", + category: "Logic", + description: "Reads the current iteration value from a LoopBlock. Connect the loopID output to this block.", + target: "Neutral", + inputs: [{ name: "loopID", type: "Object" }], + outputs: [{ name: "value", type: "AutoDetect" }], + }, + StorageWriteBlock: { + className: "StorageWriteBlock", + category: "Logic", + description: "Writes a value into the loop variable inside a LoopBlock iteration.", + target: "Neutral", + inputs: [ + { name: "loopID", type: "Object" }, + { name: "value", type: "AutoDetect" }, + ], + outputs: [], + }, + + // ─── PrePass ───────────────────────────────────────────────────────── + PrePassOutputBlock: { + className: "PrePassOutputBlock", + category: "Output", + description: "Writes to multiple prepass render targets (depth, position, normal, reflectivity, velocity).", + target: "Fragment", + inputs: [ + { name: "viewDepth", type: "Float", isOptional: true }, + { name: "screenDepth", type: "Float", isOptional: true }, + { name: "worldPosition", type: "AutoDetect", isOptional: true }, + { name: "localPosition", type: "AutoDetect", isOptional: true }, + { name: "viewNormal", type: "AutoDetect", isOptional: true }, + { name: "worldNormal", type: "AutoDetect", isOptional: true }, + { name: "reflectivity", type: "AutoDetect", isOptional: true }, + { name: "velocity", type: "AutoDetect", isOptional: true }, + { name: "velocityLinear", type: "AutoDetect", isOptional: true }, + ], + outputs: [], + }, + PrePassTextureBlock: { + className: "PrePassTextureBlock", + category: "Input", + description: "Reads from prepass render target textures (position, depth, normal, etc.).", + target: "VertexAndFragment", + inputs: [], + outputs: [ + { name: "position", type: "Object" }, + { name: "localPosition", type: "Object" }, + { name: "depth", type: "Object" }, + { name: "screenDepth", type: "Object" }, + { name: "normal", type: "Object" }, + { name: "worldNormal", type: "Object" }, + ], + }, + DepthSourceBlock: { + className: "DepthSourceBlock", + category: "Input", + description: "Provides a depth texture from the scene's depth renderer as an image source.", + target: "VertexAndFragment", + inputs: [], + outputs: [{ name: "source", type: "Object" }], + }, + + // ─── Gaussian Splatting ────────────────────────────────────────────── + GaussianSplattingBlock: { + className: "GaussianSplattingBlock", + category: "GaussianSplatting", + description: "Vertex computation for Gaussian Splatting rendering with spherical harmonics support.", + target: "Vertex", + inputs: [ + { name: "splatPosition", type: "Vector3" }, + { name: "splatScale", type: "Vector2", isOptional: true }, + { name: "world", type: "Matrix" }, + { name: "view", type: "Matrix" }, + { name: "projection", type: "Matrix" }, + ], + outputs: [ + { name: "splatVertex", type: "Vector4" }, + { name: "SH", type: "Color3" }, + ], + }, + GaussianBlock: { + className: "GaussianBlock", + category: "GaussianSplatting", + description: "Fragment part of Gaussian Splatting that processes splatted color values.", + target: "Fragment", + inputs: [{ name: "splatColor", type: "Color4" }], + outputs: [ + { name: "rgba", type: "Color4" }, + { name: "rgb", type: "Color3" }, + { name: "alpha", type: "Float" }, + ], + }, + SplatReaderBlock: { + className: "SplatReaderBlock", + category: "GaussianSplatting", + description: "Reads splat data (position and color) from textures for Gaussian Splatting.", + target: "Vertex", + inputs: [{ name: "splatIndex", type: "Float" }], + outputs: [ + { name: "splatPosition", type: "Vector3" }, + { name: "splatColor", type: "Color4" }, + ], + }, + + // ─── Smart Filter (SFE) ───────────────────────────────────────────── + SmartFilterFragmentOutputBlock: { + className: "SmartFilterFragmentOutputBlock", + category: "Output", + description: "Fragment output for the Smart Filter Editor (SFE) framework. Outputs as nmeMain() function.", + target: "Fragment", + inputs: [{ name: "rgba", type: "Color4" }], + outputs: [], + }, + SmartFilterTextureBlock: { + className: "SmartFilterTextureBlock", + category: "Texture", + description: "Texture block for the SFE framework. Supports compositing with optional ImageSourceBlock.", + target: "VertexAndFragment", + inputs: [ + { name: "uv", type: "Vector2" }, + { name: "source", type: "Object", isOptional: true }, + ], + outputs: [ + { name: "rgba", type: "Color4" }, + { name: "rgb", type: "Color3" }, + { name: "r", type: "Float" }, + { name: "g", type: "Float" }, + { name: "b", type: "Float" }, + { name: "a", type: "Float" }, + ], + properties: { + isMainInput: "boolean — whether this is the main SFE input (default false)", + }, + defaultSerializedProperties: { isMainInput: false }, + }, + + // ─── Utility / Editor ──────────────────────────────────────────────── + ElbowBlock: { + className: "ElbowBlock", + category: "Utility", + description: "Pass-through block that preserves input type. Used for visual routing in the NME editor.", + target: "Neutral", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + NodeMaterialDebugBlock: { + className: "NodeMaterialDebugBlock", + category: "Debug", + description: "Renders intermediate shader values for debugging. Only one should be active per material.", + target: "Fragment", + inputs: [{ name: "debug", type: "AutoDetect" }], + outputs: [], + properties: { + renderAlpha: "boolean — whether to render alpha channel (default false)", + isActive: "boolean — whether this debug output is active (default false)", + }, + defaultSerializedProperties: { isActive: false, renderAlpha: false }, + }, + CustomBlock: { + className: "CustomBlock", + category: "Custom", + description: + "User-defined block with arbitrary shader code and dynamic inputs/outputs. " + + "Configure via the 'options' property with code, functionName, target, inParameters, outParameters.", + target: "Neutral", + inputs: [], + outputs: [], + properties: { + options: + "Object — { code: string[], functionName: string, target: string, " + + "inParameters: Array<{name, type}>, outParameters: Array<{name, type}>, " + + "inLinkedConnectionTypes: Array }", + }, + }, +}; + +/** + * Returns a summary list of all block types, grouped by category. + * @returns A markdown-formatted string listing all block types by category + */ +export function GetBlockCatalogSummary(): string { + const categories = new Map(); + for (const [key, info] of Object.entries(BlockRegistry)) { + const cat = info.category; + if (!categories.has(cat)) { + categories.set(cat, []); + } + categories.get(cat)!.push(`${key}: ${info.description.split(".")[0]}`); + } + const lines: string[] = []; + for (const [cat, blocks] of categories) { + lines.push(`\n## ${cat}`); + for (const b of blocks) { + lines.push(` - ${b}`); + } + } + return lines.join("\n"); +} + +/** + * Returns detailed info about a specific block type. + * @param blockType - The block type key (e.g. "InputBlock", "MultiplyBlock") + * @returns The block type info or undefined if not found + */ +export function GetBlockTypeDetails(blockType: string): IBlockTypeInfo | undefined { + return BlockRegistry[blockType]; +} + +/** + * Reverse lookup: className (as it appears in customType after stripping "BABYLON.") + * → IBlockTypeInfo. This handles cases where the registry key differs from the + * className (e.g. key "MatrixBuilderBlock" → className "MatrixBuilder", + * key "TeleportInBlock" → className "NodeMaterialTeleportInBlock"). + */ +export const BlockRegistryByClassName: Record = {}; +for (const info of Object.values(BlockRegistry)) { + BlockRegistryByClassName[info.className] = info; +} diff --git a/packages/tools/nme-mcp-server/src/index.ts b/packages/tools/nme-mcp-server/src/index.ts new file mode 100644 index 00000000000..3ee0b4f4401 --- /dev/null +++ b/packages/tools/nme-mcp-server/src/index.ts @@ -0,0 +1,1021 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-console */ +/** + * Node Material MCP Server (babylonjs-node-material) + * ────────────── + * A Model Context Protocol server that exposes tools for building Babylon.js + * Node Materials programmatically. An AI agent (or any MCP client) can: + * + * • Create / manage material graphs + * • Add blocks from the full NME block catalog + * • Connect blocks together + * • Set block properties (uniform values, system values, etc.) + * • Validate the graph + * • Export the final material JSON (loadable by NME / NodeMaterial.parseSerializedObject) + * • Import existing NME 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 { writeFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +import { BlockRegistry, GetBlockCatalogSummary, GetBlockTypeDetails } from "./blockRegistry.js"; +import { MaterialGraphManager } from "./materialGraph.js"; +import { + CreateErrorResponse, + CreateJsonExportResponse, + CreateInlineJsonSchema, + CreateJsonImportResponse, + CreateJsonFileSchema, + CreateOutputFileSchema, + CreateSnippetIdSchema, + CreateTextResponse, + CreateTypedSnippetImportResponse, + McpEditorSessionController, + ParseJsonText, + RunSnippetResponse, +} from "@tools/mcp-server-core"; +import { LoadSnippet, SaveSnippet, type IDataSnippetResult } from "@tools/snippet-loader"; + +// ─── Singleton graph manager ────────────────────────────────────────────── +const manager = new MaterialGraphManager(); +const sessionController = new McpEditorSessionController( + { + serverName: "NME MCP Session Server", + documentKind: "node-material", + managerUnavailableMessage: "Material manager is not available", + getDocument: (manager, session) => manager.exportJSON(session.name), + setDocument: (manager, session, document) => { + const result = manager.importJSON(session.name, document); + return result && result !== "OK" ? result : undefined; + }, + }, + { + defaultPort: 3001, + legacyDocumentRoutes: ["material"], + statusTitle: "NME MCP Session Server", + } +); + +/** + * Notify SSE subscribers if a session exists for the given material. + * @param materialName - The material name to check for active sessions. + */ +function _notifyIfSession(materialName: string): void { + const sid = sessionController.getSessionIdForName(materialName); + if (sid) { + sessionController.notifySessionUpdate(sid); + } +} + +// ─── MCP Server ─────────────────────────────────────────────────────────── +const server = new McpServer( + { + name: "babylonjs-node-material", + version: "1.0.0", + }, + { + instructions: [ + "You build Babylon.js Node Materials (shader graphs). Workflow: create_material → add blocks (InputBlock for attributes/uniforms, then processing blocks, then output blocks) → connect ports → validate_material → export_material_json.", + "Every material needs at minimum: position attribute → TransformBlock → VertexOutputBlock, and a FragmentOutputBlock.", + "Use get_block_type_info to discover a block's ports before connecting. Enum properties (e.g. TrigonometryBlock.operation) must be set by name (e.g. 'Sin'), not numeric value.", + "Output JSON can be loaded in the Scene MCP via add_material with type NodeMaterial, or opened in the NME web editor.", + ].join(" "), + } +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Resources (read-only reference data) +// ═══════════════════════════════════════════════════════════════════════════ + +server.registerResource("block-catalog", "nme://block-catalog", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: `# NME Block Catalog\n${GetBlockCatalogSummary()}`, + }, + ], +})); + +server.registerResource("enums", "nme://enums", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# NME Enumerations Reference", + "", + "## NodeMaterialModes", + "Material (0), PostProcess (1), Particle (2), ProceduralTexture (3), GaussianSplatting (4), SFE (5)", + "", + "## NodeMaterialBlockConnectionPointTypes", + "Float (1), Int (2), Vector2 (4), Vector3 (8), Vector4 (16), Color3 (32), Color4 (64), Matrix (128), Object (256), AutoDetect (1073741824)", + "", + "## NodeMaterialSystemValues (for InputBlock)", + "World (1), View (2), Projection (3), ViewProjection (4), WorldView (5), WorldViewProjection (6), CameraPosition (7), FogColor (8), DeltaTime (9), CameraParameters (10), MaterialAlpha (11)", + "", + "## Common Attributes (for InputBlock)", + "position, normal, tangent, uv, uv2, uv3, uv4, uv5, uv6, color, matricesIndices, matricesWeights", + "", + "## TrigonometryBlockOperations", + "Cos, Sin, Abs, Exp, Exp2, Round, Floor, Ceiling, Sqrt, Log, Tan, ArcTan, ArcCos, ArcSin, Fract, Sign, Radians, Degrees", + "", + "## ConditionalBlockConditions", + "Equal, NotEqual, LessThan, GreaterThan, LessOrEqual, GreaterOrEqual, Xor, Or, And", + ].join("\n"), + }, + ], +})); + +server.registerResource("concepts", "nme://concepts", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# Node Material Concepts", + "", + "## What is a Node Material?", + "A Node Material is a visual, graph-based shader builder in Babylon.js. Instead of writing", + "GLSL/HLSL code directly, you connect typed blocks that represent shader operations.", + "The graph compiles into a GPU shader program at runtime.", + "", + "## Graph Structure — Two Required Outputs", + "Every standard material (mode='Material') MUST have exactly these two output blocks:", + " • **VertexOutputBlock** — receives the clip-space position (the final transformed vertex position)", + " • **FragmentOutputBlock** — receives the final pixel color (rgb and/or rgba)", + "Without BOTH, the material will fail to compile.", + "", + "## InputBlock — The Most Important Block Type", + "InputBlock is the source of all data entering the graph. It has three modes:", + "", + "### Mode 1: Attribute (mode=1)", + "Reads per-vertex data from the mesh's vertex buffer.", + " • Set `attributeName` to one of: position, normal, tangent, uv, uv2, color, etc.", + " • Set `type` to the matching type: Vector3 for position/normal, Vector2 for uv, etc.", + " • Example: `{ type: 'Vector3', attributeName: 'position' }`", + "", + "### Mode 2: Uniform / Constant (mode=0)", + "Provides a constant or system-provided value.", + " • For **system values**: set `systemValue` (e.g. 'WorldViewProjection', 'World', 'CameraPosition')", + " and set `type` to the matching type (Matrix for transforms, Vector3 for CameraPosition).", + " Example: `{ type: 'Matrix', systemValue: 'WorldViewProjection' }`", + " • For **custom constants**: set `type` and `value`.", + " Example: `{ type: 'Color3', value: { r: 0.8, g: 0.2, b: 0.2 } }`", + " Example: `{ type: 'Float', value: 0.5 }`", + "", + "### ⚠ InputBlock Gotcha", + "An InputBlock MUST have a `type` property. Without it, the block cannot determine", + "what kind of data it provides and connections will fail silently.", + "Additionally, every InputBlock needs at least one of:", + " • `attributeName` — for mesh vertex data", + " • `systemValue` — for engine-provided values (matrices, camera pos, etc.)", + " • `value` — for custom constant values", + "An InputBlock with none of these is effectively useless.", + "", + "## The Minimal Vertex Pipeline", + "Every material needs to transform vertex positions from object space to clip space:", + "```", + "InputBlock(position, attribute, Vector3)", + " └→ TransformBlock.vector", + "InputBlock(worldViewProjection, systemValue, Matrix)", + " └→ TransformBlock.transform", + "TransformBlock.output", + " └→ VertexOutputBlock.vector", + "```", + "This is the minimum vertex shader — it positions the mesh correctly on screen.", + "", + "## The Minimal Fragment Pipeline", + "At minimum, the fragment output needs an rgb (Color3) or rgba (Color4) color:", + "```", + "InputBlock(color, constant, Color3, value={r:1,g:0,b:0})", + " └→ FragmentOutputBlock.rgb", + "```", + "", + "## PBR Materials — Additional Requirements", + "PBRMetallicRoughnessBlock needs several inputs connected:", + " • **worldPosition** — from TransformBlock(position × world matrix), NOT the clip-space position", + " • **worldNormal** — from TransformBlock(normal × world matrix)", + " • **view** — from InputBlock(systemValue: 'View')", + " • **cameraPosition** — from InputBlock(systemValue: 'CameraPosition')", + " • **baseColor** — Color3 input for the material's base color", + " • **metallic** — Float input (0=dielectric, 1=metal)", + " • **roughness** — Float input (0=smooth/mirror, 1=rough/matte)", + "Then connect PBRMetallicRoughnessBlock.lighting → FragmentOutputBlock.rgb", + "", + "## Connection Rules", + "• Connections go from an output of one block to an input of another", + "• Types must be compatible (Color3→Color3, Float→Float, etc.)", + "• Some inputs accept AutoDetect and will adapt to whatever is connected", + "• Use describe_block or get_block_type_info to see available inputs/outputs", + "", + "## Common Mistakes", + "1. Forgetting VertexOutputBlock or FragmentOutputBlock → material won't compile", + "2. Creating InputBlock without `type` → block is broken", + "3. Creating InputBlock without value/systemValue/attributeName → useless block", + "4. Connecting position directly to VertexOutput without TransformBlock → mesh renders at origin", + "5. Using the wrong transform matrix (World instead of WorldViewProjection for vertex output)", + "6. Not connecting worldPosition/worldNormal/view/cameraPosition to PBR block → black material", + ].join("\n"), + }, + ], +})); + +// ═══════════════════════════════════════════════════════════════════════════ +// Prompts (reusable prompt templates) +// ═══════════════════════════════════════════════════════════════════════════ + +server.registerPrompt("create-pbr-material", { description: "Step-by-step instructions for building a basic PBR material" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a basic PBR metallic-roughness material. Steps:", + "1. create_material with name 'MyPBR', mode 'Material'", + "2. Add InputBlock for 'position' attribute (type Vector3, attributeName 'position')", + "3. Add InputBlock for 'normal' attribute (type Vector3, attributeName 'normal')", + "4. Add InputBlock for 'worldViewProjection' system value (type Matrix, systemValue 'WorldViewProjection')", + "5. Add InputBlock for 'world' system value (type Matrix, systemValue 'World')", + "6. Add InputBlock for 'view' system value (type Matrix, systemValue 'View')", + "7. Add InputBlock for 'cameraPosition' system value (type Vector3, systemValue 'CameraPosition')", + "8. Add TransformBlock named 'worldPos' — connect world → transform, position → vector", + "9. Add TransformBlock named 'clipPos' — connect worldViewProjection → transform, position → vector", + "10. Add VertexOutputBlock — connect clipPos.output → vector", + "11. Add PBRMetallicRoughnessBlock — connect worldPos output → worldPosition, normal → worldNormal, etc.", + "12. Add InputBlock for baseColor (type Color3, value {r:0.8, g:0.2, b:0.2})", + "13. Add InputBlock for metallic (type Float, value 0.0)", + "14. Add InputBlock for roughness (type Float, value 0.5)", + "15. Connect baseColor, metallic, roughness to the PBR block", + "16. Add FragmentOutputBlock — connect PBR.lighting → rgb", + "17. validate_material, then export_material_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-simple-color-material", { description: "Create the simplest possible unlit colored material" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create the simplest unlit material that outputs a solid color. Steps:", + "1. create_material 'SimpleColor'", + "2. Add InputBlock type Vector3, attributeName 'position', name 'position'", + "3. Add InputBlock type Matrix, systemValue 'WorldViewProjection', name 'wvp'", + "4. Add TransformBlock name 'transform' — connect wvp→transform, position→vector", + "5. Add VertexOutputBlock — connect transform.output→vector", + "6. Add InputBlock type Color3, value {r:1, g:0, b:0}, name 'color'", + "7. Add FragmentOutputBlock — connect color.output→rgb", + "8. validate_material, then export_material_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-textured-material", { description: "Create a PBR material that samples a diffuse texture using UV coordinates" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a PBR material with a diffuse texture. Steps:", + "1. create_material 'TexturedPBR'", + "2. Add InputBlock type Vector3, attributeName 'position', name 'position'", + "3. Add InputBlock type Matrix, systemValue 'WorldViewProjection', name 'wvp'", + "4. Add InputBlock type Matrix, systemValue 'World', name 'world'", + "5. Add InputBlock type Matrix, systemValue 'View', name 'view'", + "6. Add InputBlock type Vector3, systemValue 'CameraPosition', name 'cameraPosition'", + "7. Add InputBlock type Vector3, attributeName 'normal', name 'normal'", + "8. Add InputBlock type Vector2, attributeName 'uv', name 'uv'", + "9. Add TransformBlock 'worldPos' — connect world→transform, position→vector", + "10. Add TransformBlock 'clipPos' — connect wvp→transform, position→vector", + "11. Add VertexOutputBlock — connect clipPos.output→vector", + "12. Add TextureBlock 'diffuseTex'", + "13. set_block_properties on diffuseTex: { texture: 'https://playground.babylonjs.com/textures/floor.png' }", + " (A bare URL string is auto-converted to a full texture descriptor on export)", + "14. Connect uv.output → diffuseTex.uv", + "15. Add PBRMetallicRoughnessBlock 'pbr'", + "16. Connect worldPos.output→pbr.worldPosition, normal→pbr.worldNormal, view→pbr.view, cameraPosition→pbr.cameraPosition", + "17. Connect diffuseTex.rgb → pbr.baseColor", + "18. Add InputBlock type Float, value 0.0, name 'metallic' → pbr.metallic", + "19. Add InputBlock type Float, value 0.5, name 'roughness' → pbr.roughness", + "20. Add FragmentOutputBlock — connect pbr.lighting→rgb", + "21. validate_material, then export_material_json", + ].join("\n"), + }, + }, + ], +})); + +// ═══════════════════════════════════════════════════════════════════════════ +// Tools +// ═══════════════════════════════════════════════════════════════════════════ + +// ── Material lifecycle ───────────────────────────────────────────────── + +server.registerTool( + "create_material", + { + description: "Create a new empty Node Material graph in memory. This is always the first step.", + inputSchema: { + name: z.string().describe("Unique name for the material (e.g. 'MyPBR', 'GlowEffect')"), + mode: z + .enum(["Material", "PostProcess", "Particle", "ProceduralTexture", "GaussianSplatting", "SFE"]) + .default("Material") + .describe("The material mode. Use 'Material' for standard mesh materials."), + comment: z.string().optional().describe("An optional description of what this material does"), + }, + }, + async ({ name, mode, comment }) => { + manager.createMaterial(name, mode, comment); + + // Auto-create a live session for this material + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(name); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + + return { + content: [ + { + type: "text", + text: `Created material "${name}" (mode: ${mode}). Now add blocks with add_block, connect them with connect_blocks, then export with export_material_json.\n\nMCP Session URL: ${sessionUrl}\nPaste this URL in the Node Material Editor's "Connect to MCP Session" panel to see live updates.`, + }, + ], + }; + } +); + +server.registerTool( + "get_session_url", + { + description: "Get or create a live-session URL for a material. The URL can be pasted into the Node Material Editor's 'Connect to MCP Session' panel.", + inputSchema: { + materialName: z.string().describe("Name of the material"), + }, + }, + async ({ materialName }) => { + // Verify material exists + const materials = manager.listMaterials(); + if (!materials.includes(materialName)) { + return { content: [{ type: "text", text: `Material "${materialName}" not found.` }], isError: true }; + } + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(materialName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return { + content: [ + { + type: "text", + text: `MCP Session URL: ${sessionUrl}\nPaste this URL in the Node Material Editor's "Connect to MCP Session" panel to see live updates.`, + }, + ], + }; + } +); + +server.registerTool( + "start_session", + { + description: + "Start a live session for an existing material. Returns a URL that can be pasted into the Node Material Editor. If a session already exists for this material, returns the existing URL.", + inputSchema: { + materialName: z.string().describe("Name of the material"), + }, + }, + async ({ materialName }) => { + const materials = manager.listMaterials(); + if (!materials.includes(materialName)) { + return { content: [{ type: "text", text: `Material "${materialName}" not found.` }], isError: true }; + } + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(materialName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return { + content: [ + { + type: "text", + text: `MCP Session URL: ${sessionUrl}\nPaste this URL in the Node Material Editor's "Connect to MCP Session" panel to see live updates.`, + }, + ], + }; + } +); + +server.registerTool( + "close_session", + { + description: "Close a live session for a material. Disconnects all SSE subscribers in the editor and removes the session. The material itself is NOT deleted.", + inputSchema: { + materialName: z.string().describe("Name of the material whose session to close"), + }, + }, + async ({ materialName }) => { + const closed = sessionController.closeSessionForName(materialName); + if (!closed) { + return { content: [{ type: "text", text: `No active session for "${materialName}".` }] }; + } + return { content: [{ type: "text", text: `Session for "${materialName}" closed. The editor will disconnect.` }] }; + } +); + +server.registerTool( + "stop_session_server", + { + description: "Stop the live MCP editor session server started by this MCP process. This closes all active sessions, disconnects editors, and releases the port.", + }, + async () => { + await sessionController.stopAsync(); + return CreateTextResponse("MCP session server stopped. Any connected editors have been disconnected."); + } +); + +server.registerTool( + "delete_material", + { + description: "Delete a material graph from memory. Also closes any active session for it.", + inputSchema: { + name: z.string().describe("Name of the material to delete"), + }, + }, + async ({ name }) => { + sessionController.closeSessionForName(name); + const ok = manager.deleteMaterial(name); + return { + content: [{ type: "text", text: ok ? `Deleted "${name}".` : `Material "${name}" not found.` }], + }; + } +); + +server.registerTool("clear_all", { description: "Remove all material graphs from memory, resetting the server to a clean state. Also closes all active sessions." }, async () => { + const names = manager.listMaterials(); + for (const n of names) { + sessionController.closeSessionForName(n); + } + manager.clearAll(); + return { + content: [{ type: "text", text: names.length > 0 ? `Cleared ${names.length} material(s): ${names.join(", ")}` : "Nothing to clear — memory was already empty." }], + }; +}); + +server.registerTool("list_materials", { description: "List all material graphs currently in memory." }, async () => { + const names = manager.listMaterials(); + return { + content: [ + { + type: "text", + text: names.length > 0 ? `Materials in memory:\n${names.map((n) => ` • ${n}`).join("\n")}` : "No materials in memory.", + }, + ], + }; +}); + +// ── Block operations ──────────────────────────────────────────────────── + +server.registerTool( + "add_block", + { + description: "Add a new block to a material graph. Returns the block's id for use in connect_blocks.", + inputSchema: { + materialName: z.string().describe("Name of the material to add the block to"), + blockType: z + .string() + .describe( + "The block type from the registry (e.g. 'InputBlock', 'MultiplyBlock', 'PBRMetallicRoughnessBlock', 'TransformBlock', etc.). Use list_block_types to see all." + ), + name: z.string().optional().describe("Human-friendly name for this block instance (e.g. 'myColor', 'worldMatrix')"), + properties: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Key-value properties to set on the block. For InputBlock: type (Float/Vector2/Vector3/Vector4/Color3/Color4/Matrix), " + + "value (the constant value), systemValue (World/View/Projection/etc.), attributeName (position/normal/uv/etc.), " + + "isConstant (boolean), animationType (None/Time), min/max (number). " + + "For TrigonometryBlock: operation (Cos/Sin/Abs/etc.). " + + "For ConditionalBlock: condition (Equal/LessThan/etc.)." + ), + }, + }, + async ({ materialName, blockType, name, properties }) => { + const result = manager.addBlock(materialName, blockType, name, properties as Record); + if (typeof result === "string") { + return { content: [{ type: "text", text: `Error: ${result}` }], isError: true }; + } + const lines = [`Added block [${result.block.id}] "${result.block.name}" (${blockType}). Use this id (${result.block.id}) to connect it.`]; + if (result.warnings) { + lines.push("", "Warnings:", ...result.warnings); + } + _notifyIfSession(materialName); + return { + content: [ + { + type: "text", + text: lines.join("\n"), + }, + ], + }; + } +); + +server.registerTool( + "remove_block", + { + description: "Remove a block from a material graph by its numeric block id. Also removes any connections to/from it. Use describe_material to find valid block ids.", + inputSchema: { + materialName: z.string().describe("Name of the material"), + blockId: z.number().describe("The block id to remove"), + }, + }, + async ({ materialName, blockId }) => { + const result = manager.removeBlock(materialName, blockId); + if (result === "OK") { + _notifyIfSession(materialName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Removed block ${blockId}.` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "set_block_properties", + { + description: + "Set or update properties on an existing block. Use get_block_type_info to see available properties for the block's type. " + + "Common: for InputBlock set 'value', 'type', 'systemValue'; for TrigonometryBlock set 'operation'; for ConditionalBlock set 'condition'.", + inputSchema: { + materialName: z.string().describe("Name of the material"), + blockId: z.number().describe("The block id to modify"), + properties: z.record(z.string(), z.unknown()).describe("Key-value properties to set. Use get_block_type_info to discover valid keys for a given block type."), + }, + }, + async ({ materialName, blockId, properties }) => { + const result = manager.setBlockProperties(materialName, blockId, properties as Record); + if (result === "OK") { + _notifyIfSession(materialName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Updated block ${blockId}.` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +// ── Connections ────────────────────────────────────────────────────────── + +server.registerTool( + "connect_blocks", + { + description: "Connect an output of one block to an input of another block. Data flows from source output → target input.", + inputSchema: { + materialName: z.string().describe("Name of the material"), + sourceBlockId: z.number().describe("Block id to connect FROM (the one with the output)"), + outputName: z.string().describe("Name of the output on the source block (e.g. 'output', 'rgb', 'xyz')"), + targetBlockId: z.number().describe("Block id to connect TO (the one with the input)"), + inputName: z.string().describe("Name of the input on the target block (e.g. 'vector', 'left', 'color')"), + }, + }, + async ({ materialName, sourceBlockId, outputName, targetBlockId, inputName }) => { + const result = manager.connectBlocks(materialName, sourceBlockId, outputName, targetBlockId, inputName); + const isOk = result.startsWith("OK"); + if (isOk) { + _notifyIfSession(materialName); + } + return { + content: [ + { + type: "text", + text: isOk + ? `Connected [${sourceBlockId}].${outputName} → [${targetBlockId}].${inputName}${result === "OK" ? "" : `. ${result.slice(3)}`}` + : `Error: ${result}`, + }, + ], + isError: !isOk, + }; + } +); + +server.registerTool( + "disconnect_input", + { + description: "Disconnect an input on a block (remove the incoming connection). Use describe_block to find connected input names.", + inputSchema: { + materialName: z.string().describe("Name of the material"), + blockId: z.number().describe("The block id whose input to disconnect"), + inputName: z.string().describe("Name of the input to disconnect"), + }, + }, + async ({ materialName, blockId, inputName }) => { + const result = manager.disconnectInput(materialName, blockId, inputName); + if (result === "OK") { + _notifyIfSession(materialName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Disconnected [${blockId}].${inputName}` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +// ── Query tools ───────────────────────────────────────────────────────── + +server.registerTool( + "describe_material", + { + description: "Get a human-readable description of the current state of a material graph, " + "including all blocks and their connections.", + inputSchema: { + materialName: z.string().describe("Name of the material to describe"), + }, + }, + async ({ materialName }) => { + const desc = manager.describeMaterial(materialName); + return { content: [{ type: "text", text: desc }] }; + } +); + +server.registerTool( + "describe_block", + { + description: "Get detailed information about a specific block instance in a material, including its connections and properties.", + inputSchema: { + materialName: z.string().describe("Name of the material"), + blockId: z.number().describe("The block id to describe"), + }, + }, + async ({ materialName, blockId }) => { + const desc = manager.describeBlock(materialName, blockId); + return { content: [{ type: "text", text: desc }] }; + } +); + +server.registerTool( + "list_block_types", + { + description: "List all available NME block types, grouped by category. Use this to discover which blocks you can add.", + inputSchema: { + category: z.string().optional().describe("Optionally filter by category (Input, Math, Vector, Color, Texture, PBR, Output, etc.)"), + }, + }, + async ({ category }) => { + if (category) { + const matching = Object.entries(BlockRegistry) + .filter(([, info]) => info.category.toLowerCase() === category.toLowerCase()) + .map(([key, info]) => ` ${key}: ${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 inputs, outputs, properties, and description.", + inputSchema: { + blockType: z.string().describe("The block type name (e.g. 'PBRMetallicRoughnessBlock', 'InputBlock')"), + }, + }, + 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}`); + lines.push(`Category: ${info.category}`); + lines.push(`Target: ${info.target}`); + lines.push(`Description: ${info.description}`); + + lines.push("\n### Inputs:"); + if (info.inputs.length === 0) { + lines.push(" (none)"); + } + for (const inp of info.inputs) { + const opt = inp.isOptional ? " (optional)" : " (required)"; + lines.push(` • ${inp.name}: ${inp.type}${opt}`); + } + + lines.push("\n### Outputs:"); + if (info.outputs.length === 0) { + lines.push(" (none)"); + } + for (const out of info.outputs) { + lines.push(` • ${out.name}: ${out.type}`); + } + + if (info.properties) { + lines.push("\n### Configurable Properties:"); + for (const [k, v] of Object.entries(info.properties)) { + lines.push(` • ${k}: ${v}`); + } + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } +); + +// ── Validation ────────────────────────────────────────────────────────── + +server.registerTool( + "validate_material", + { + description: "Run validation checks on a material graph. Reports missing outputs, unconnected required inputs, and broken references.", + inputSchema: { + materialName: z.string().describe("Name of the material to validate"), + }, + }, + async ({ materialName }) => { + const issues = manager.validateMaterial(materialName); + return { + content: [{ type: "text", text: issues.join("\n") }], + isError: issues.some((i) => i.startsWith("ERROR")), + }; + } +); + +// ── Export / Import ───────────────────────────────────────────────────── + +server.registerTool( + "export_material_json", + { + description: + "Export the material graph as NME-compatible JSON. This JSON can be loaded in the Babylon.js Node Material Editor " + + "or via NodeMaterial.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: { + materialName: z.string().describe("Name of the material to export"), + outputFile: CreateOutputFileSchema(z), + }, + }, + async ({ materialName, outputFile }) => { + return CreateJsonExportResponse({ + jsonText: manager.exportJSON(materialName), + outputFile, + missingMessage: `Material "${materialName}" not found.`, + fileLabel: "NME JSON", + }); + } +); + +server.registerTool( + "import_material_json", + { + description: + "Import an existing NME JSON into memory for editing. You can then modify blocks, connections, etc. " + + "Provide either the inline json string OR a jsonFile path (not both).", + inputSchema: { + materialName: z.string().describe("Name to give the imported material"), + json: CreateInlineJsonSchema(z, "The NME JSON string to import"), + jsonFile: CreateJsonFileSchema(z, "Absolute path to a file containing the NME JSON to import (alternative to inline json)"), + }, + }, + async ({ materialName, json, jsonFile }) => { + return CreateJsonImportResponse({ + json, + jsonFile, + fileDescription: "NME JSON file", + importJson: (jsonText) => manager.importJSON(materialName, jsonText), + describeImported: () => manager.describeMaterial(materialName), + }); + } +); + +server.registerTool( + "import_from_snippet", + { + description: + "Import a Node Material from the Babylon.js Snippet Server by its snippet ID. " + + "The snippet is fetched, validated as a nodeMaterial type, and loaded into memory for editing. " + + 'Snippet IDs look like "ABC123" or "ABC123#2" (with revision).', + inputSchema: { + materialName: z.string().describe("Name to give the imported material in memory"), + snippetId: CreateSnippetIdSchema(z), + }, + }, + async ({ materialName, snippetId }) => { + return await RunSnippetResponse({ + snippetId, + loadSnippet: async (requestedSnippetId: string) => (await LoadSnippet(requestedSnippetId)) as IDataSnippetResult, + createResponse: (snippetResult: IDataSnippetResult) => + CreateTypedSnippetImportResponse({ + snippetId, + snippetResult, + expectedType: "nodeMaterial", + importJson: (jsonText) => manager.importJSON(materialName, jsonText), + describeImported: () => manager.describeMaterial(materialName), + successMessage: `Imported snippet "${snippetId}" as "${materialName}" successfully.`, + }), + }); + } +); + +// ── Snippet / URL helpers ─────────────────────────────────────────────── + +server.registerTool( + "get_snippet_url", + { + description: + "Export the material JSON and provide instructions for loading it in the Babylon.js Node Material Editor. " + + "Optionally writes the JSON to a file. The user can then load it via the NME editor's 'Load' button, " + + "or use NodeMaterial.Parse() at runtime.", + inputSchema: { + materialName: z.string().describe("Name of the material"), + outputFile: z.string().optional().describe("Optional absolute file path to write the JSON to. If not provided, instructions are returned with inline JSON."), + }, + }, + async ({ materialName, outputFile }) => { + const json = manager.exportJSON(materialName); + if (!json) { + return { content: [{ type: "text", text: `Material "${materialName}" not found.` }], isError: true }; + } + + const lines: string[] = []; + lines.push("## How to load this material in the Node Material Editor\n"); + lines.push("1. Open https://nme.babylonjs.com"); + lines.push("2. Click the hamburger menu (☰) → 'Load'"); + lines.push("3. Select the exported JSON file or paste the JSON content\n"); + + if (outputFile) { + try { + mkdirSync(dirname(outputFile), { recursive: true }); + writeFileSync(outputFile, json, "utf-8"); + lines.push(`JSON written to: ${outputFile}`); + } catch (e) { + return { content: [{ type: "text", text: `Error writing file: ${(e as Error).message}` }], isError: true }; + } + } else { + lines.push("Use export_material_json with an outputFile to save to disk for easier loading."); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } +); + +// ── Batch operations ──────────────────────────────────────────────────── + +server.registerTool( + "add_blocks_batch", + { + description: + "Add multiple blocks at once (processed sequentially, so earlier blocks exist before later ones). " + + "More efficient than calling add_block repeatedly. Returns all created block ids. " + + "If one block fails, the rest still proceed.", + inputSchema: { + materialName: z.string().describe("Name of the material"), + blocks: z + .array( + z.object({ + blockType: z.string().describe("Block type name"), + blockName: z.string().optional().describe("Instance name for the block"), + name: z.string().optional().describe("Instance name (alias for blockName)"), + properties: z.record(z.string(), z.unknown()).optional().describe("Block properties"), + }) + ) + .describe("Array of blocks to add"), + }, + }, + async ({ materialName, blocks }) => { + const results: string[] = []; + for (const blockDef of blocks) { + const bName = blockDef.blockName ?? blockDef.name; + const result = manager.addBlock(materialName, blockDef.blockType, bName, blockDef.properties as Record); + if (typeof result === "string") { + results.push(`Error adding ${blockDef.blockType}: ${result}`); + } else { + let line = `[${result.block.id}] ${result.block.name} (${blockDef.blockType})`; + if (result.warnings) { + line += `\n ⚠ ${result.warnings.join("\n ⚠ ")}`; + } + results.push(line); + } + } + _notifyIfSession(materialName); + return { content: [{ type: "text", text: `Added blocks:\n${results.join("\n")}` }] }; + } +); + +server.registerTool( + "connect_blocks_batch", + { + description: + "Connect multiple block pairs at once (processed sequentially). More efficient than calling connect_blocks repeatedly. " + + "If one connection fails, the rest still proceed.", + inputSchema: { + materialName: z.string().describe("Name of the material"), + connections: z + .array( + z.object({ + sourceBlockId: z.number().describe("Block id to connect FROM (the one with the output)"), + outputName: z.string().describe("Name of the output on the source block (e.g. 'output', 'rgb', 'xyz')"), + targetBlockId: z.number().describe("Block id to connect TO (the one with the input)"), + inputName: z.string().describe("Name of the input on the target block (e.g. 'vector', 'left', 'color')"), + }) + ) + .describe("Array of connections to make"), + }, + }, + async ({ materialName, connections }) => { + const results: string[] = []; + let hasError = false; + for (const conn of connections) { + const result = manager.connectBlocks(materialName, conn.sourceBlockId, conn.outputName, conn.targetBlockId, conn.inputName); + if (result.startsWith("OK")) { + const suffix = result === "OK" ? "" : ` ${result.slice(3)}`; + results.push(`[${conn.sourceBlockId}].${conn.outputName} → [${conn.targetBlockId}].${conn.inputName}${suffix}`); + } else { + hasError = true; + results.push(`Error ([${conn.sourceBlockId}].${conn.outputName} → [${conn.targetBlockId}].${conn.inputName}): ${result}`); + } + } + _notifyIfSession(materialName); + return { content: [{ type: "text", text: `Connections:\n${results.join("\n")}` }], isError: hasError }; + } +); + +// ── Snippet server ────────────────────────────────────────────────────── + +server.registerTool( + "save_snippet", + { + description: + "Save the material to the Babylon.js Snippet Server and return the snippet ID and version. " + + "The snippet can later be loaded in the Node Material Editor via its snippet ID, or fetched with import_from_snippet. " + + "To create a new revision of an existing snippet, pass the previous snippetId.", + inputSchema: { + materialName: z.string().describe("Name of the material to save"), + snippetId: z.string().optional().describe('Optional existing snippet ID to create a new revision of (e.g. "ABC123" or "ABC123#1")'), + name: z.string().optional().describe("Optional human-readable title for the snippet"), + description: z.string().optional().describe("Optional description"), + tags: z.string().optional().describe("Optional comma-separated tags"), + }, + }, + async ({ materialName, snippetId, name, description, tags }) => { + const json = manager.exportJSON(materialName); + if (!json) { + return { content: [{ type: "text", text: `Material "${materialName}" not found.` }], isError: true }; + } + try { + const result = await SaveSnippet( + { type: "nodeMaterial", data: ParseJsonText({ jsonText: json, jsonLabel: "NME JSON" }) }, + { snippetId, metadata: { name, description, tags } } + ); + return CreateTextResponse( + `Saved material "${materialName}" to snippet server.\n\nSnippet ID: ${result.id}\nVersion: ${result.version}\nFull ID: ${result.snippetId}\n\nLoad in NME editor: https://nme.babylonjs.com/#${result.snippetId}` + ); + } catch (e) { + return CreateErrorResponse(`Error saving snippet: ${(e as Error).message}`); + } + } +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Start the server +// ═══════════════════════════════════════════════════════════════════════════ + +async function Main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Babylon.js Node Material Editor MCP Server running on stdio"); +} + +try { + await Main(); +} catch (err) { + console.error("Fatal error:", err); + process.exit(1); +} + +// Graceful shutdown — stop the session server so the port is released immediately +const _shutdown = () => { + void sessionController.stopAsync(); + process.exit(0); +}; +process.on("SIGINT", _shutdown); +process.on("SIGTERM", _shutdown); diff --git a/packages/tools/nme-mcp-server/src/materialGraph.ts b/packages/tools/nme-mcp-server/src/materialGraph.ts new file mode 100644 index 00000000000..e8cb17e472b --- /dev/null +++ b/packages/tools/nme-mcp-server/src/materialGraph.ts @@ -0,0 +1,1508 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * MaterialGraphManager – holds an in-memory representation of a Node Material + * graph that the MCP tools build up incrementally. When the user is satisfied, + * the graph can be serialised to the NME JSON format that Babylon.js understands. + * + * Design goals + * ──────────── + * 1. **No Babylon.js runtime dependency** – the MCP server must remain a light, + * standalone process. We therefore work purely with a JSON data model that + * mirrors the serialisation format NodeMaterial.serialize() produces. + * 2. **Idempotent & stateful** – the manager stores the current graph in memory + * so an AI agent can add blocks, connect them, tweak properties, and finally + * export. Multiple graphs can coexist (keyed by material name). + */ + +import { ValidateNodeMaterialAttachmentPayload } from "@tools/mcp-server-core"; + +import { BlockRegistry, BlockRegistryByClassName, type IBlockTypeInfo } from "./blockRegistry.js"; + +// ─── Types ──────────────────────────────────────────────────────────────── + +/** + * Serialized form of a single connection point (input or output) on a block. + */ +export interface ISerializedConnectionPoint { + /** Name of the connection point */ + name: string; + /** Display name shown in the NME editor */ + displayName?: string; + /** The input-side name this feeds into on the target block */ + inputName?: string; + /** ID of the block this connection links to */ + targetBlockId?: number; + /** Name of the output on the linked block */ + targetConnectionName?: string; + /** Whether exposed on the NME editor frame */ + isExposedOnFrame?: boolean; + /** Position when exposed on frame */ + exposedPortPosition?: number; +} + +/** + * Serialized form of a single Node Material block. + */ +export interface ISerializedBlock { + /** The Babylon.js class identifier (e.g. "BABYLON.InputBlock") */ + customType: string; + /** Unique block identifier within the material */ + id: number; + /** Human-friendly block name */ + name: string; + /** NodeMaterialBlockTargets enum value */ + target?: number; + /** Input connection points */ + inputs: ISerializedConnectionPoint[]; + /** Output connection points */ + outputs: ISerializedConnectionPoint[]; + /** Block-specific values (e.g. min, max, type, value, systemValue …) */ + [key: string]: unknown; +} + +/** + * Serialized form of a complete Node Material. + */ +export interface ISerializedMaterial { + /** Optional tags for the material */ + tags?: string; + /** Whether to ignore the alpha channel */ + ignoreAlpha: boolean; + /** Maximum simultaneous lights */ + maxSimultaneousLights: number; + /** NodeMaterialModes enum value */ + mode: number; + /** Whether to force alpha blending */ + forceAlphaBlending: boolean; + /** All blocks in the material graph */ + blocks: ISerializedBlock[]; + /** Block IDs of output nodes (VertexOutputBlock + FragmentOutputBlock) */ + outputNodes: number[]; + /** NME editor layout data */ + editorData?: { + /** Block positions in the editor */ + locations: Array<{ /** Block ID */ blockId: number; /** X coordinate */ x: number; /** Y coordinate */ y: number }>; + }; + /** Metadata the agent can read back */ + comment?: string; +} + +/** Nice names for the mode enum */ +export const NodeMaterialModes: Record = { + Material: 0, + PostProcess: 1, + Particle: 2, + ProceduralTexture: 3, + GaussianSplatting: 4, + SFE: 5, +}; + +/** Mapping from human-readable target names to Babylon enum values */ +export const BlockTargets: Record = { + Vertex: 1, + Fragment: 2, + Neutral: 4, + VertexAndFragment: 3, +}; + +/** Mapping from human-readable type names to Babylon enum values for InputBlock */ +export const ConnectionPointTypes: Record = { + Float: 1, + Int: 2, + Vector2: 4, + Vector3: 8, + Vector4: 16, + Color3: 32, + Color4: 64, + Matrix: 128, + Object: 256, + AutoDetect: 1073741824, + BasedOnInput: 2048, +}; + +/** System values valid for InputBlock.systemValue */ +export const SystemValues: Record = { + World: 1, + View: 2, + Projection: 3, + ViewProjection: 4, + WorldView: 5, + WorldViewProjection: 6, + CameraPosition: 7, + FogColor: 8, + DeltaTime: 9, + CameraParameters: 10, + MaterialAlpha: 11, +}; + +/** AnimatedInputBlockTypes */ +export const AnimationTypes: Record = { + None: 0, + Time: 1, +}; + +/** TrigonometryBlockOperations — numeric enum values Babylon.js expects */ +export const TrigonometryOperations: Record = { + Cos: 0, + Sin: 1, + Abs: 2, + Exp: 3, + Exp2: 4, + Round: 5, + Floor: 6, + Ceiling: 7, + Sqrt: 8, + Log: 9, + Tan: 10, + ArcTan: 11, + ArcCos: 12, + ArcSin: 13, + Fract: 14, + Sign: 15, + Radians: 16, + Degrees: 17, + Set: 18, +}; + +/** ConditionalBlockConditions — numeric enum values Babylon.js expects */ +export const ConditionalConditions: Record = { + Equal: 0, + NotEqual: 1, + LessThan: 2, + GreaterThan: 3, + LessOrEqual: 4, + GreaterOrEqual: 5, + Xor: 6, + Or: 7, + And: 8, +}; + +/** MeshAttributeExistsBlockTypes — numeric enum values Babylon.js expects */ +export const MeshAttributeExistsTypes: Record = { + None: 0, + Normal: 1, + Tangent: 2, + VertexColor: 3, + UV1: 4, + UV2: 5, + UV3: 6, + UV4: 7, + UV5: 8, + UV6: 9, +}; + +/** CurveBlockTypes — numeric enum values Babylon.js expects */ +export const CurveTypes: Record = { + EaseInSine: 0, + EaseOutSine: 1, + EaseInOutSine: 2, + EaseInQuad: 3, + EaseOutQuad: 4, + EaseInOutQuad: 5, + EaseInCubic: 6, + EaseOutCubic: 7, + EaseInOutCubic: 8, + EaseInQuart: 9, + EaseOutQuart: 10, + EaseInOutQuart: 11, + EaseInQuint: 12, + EaseOutQuint: 13, + EaseInOutQuint: 14, + EaseInExpo: 15, + EaseOutExpo: 16, + EaseInOutExpo: 17, + EaseInCirc: 18, + EaseOutCirc: 19, + EaseInOutCirc: 20, + EaseInBack: 21, + EaseOutBack: 22, + EaseInOutBack: 23, + EaseInElastic: 24, + EaseOutElastic: 25, + EaseInOutElastic: 26, +}; + +/** + * Mapping from block class name → property name → enum string-to-number map. + * Used by setBlockProperties() to convert human-readable enum strings to the + * numeric values Babylon.js expects during deserialization. + */ +const BlockEnumProperties: Record>> = { + TrigonometryBlock: { operation: TrigonometryOperations }, + ConditionalBlock: { condition: ConditionalConditions }, + MeshAttributeExistsBlock: { attributeType: MeshAttributeExistsTypes }, + CurveBlock: { curveType: CurveTypes }, +}; + +/** + * Valid vertex buffer attribute names in Babylon.js. + * Used to validate and normalise InputBlock attributeName values. + */ +const ValidAttributeNames = new Set([ + "position", + "normal", + "tangent", + "uv", + "uv2", + "uv3", + "uv4", + "uv5", + "uv6", + "color", + "matricesIndices", + "matricesWeights", + "matricesIndicesExtra", + "matricesWeightsExtra", +]); + +/** + * Common LLM mistakes for attribute names → correct Babylon.js attribute name. + * The PBR and Light blocks declare a local `vec4 worldPos` in the vertex shader, + * so using "worldPos" as an attribute name causes a fatal GLSL name collision. + */ +const AttributeNameAliases: Record = { + pos: "position", + worldpos: "position", + worldposition: "position", + world_position: "position", + vertexposition: "position", + vertex_position: "position", + norm: "position", // "norm" is ambiguous but "normal" is more likely + worldnormal: "normal", + worldnorm: "normal", + world_normal: "normal", + vertexnormal: "normal", + vertex_normal: "normal", +}; +// Fix "norm" — it almost certainly means "normal", not "position" +AttributeNameAliases["norm"] = "normal"; + +/** + * Normalise an InputBlock attribute name to a valid Babylon.js vertex attribute. + * Returns the corrected name and an optional warning if it was remapped. + * @param raw - The raw attribute name to normalise. + * @returns An object containing the normalised name and an optional warning message. + */ +function normaliseAttributeName(raw: unknown): { name: string; warning?: string } { + if (typeof raw !== "string") { + return { name: String(raw) }; + } + // Already valid + if (ValidAttributeNames.has(raw)) { + return { name: raw }; + } + // Check aliases (case-insensitive) + const lower = raw.toLowerCase().replace(/[\s_-]/g, ""); + const mapped = AttributeNameAliases[lower]; + if (mapped) { + return { + name: mapped, + warning: `⚠ attributeName "${raw}" is not a valid vertex attribute. ` + `Auto-corrected to "${mapped}". ` + `Valid names: ${[...ValidAttributeNames].join(", ")}.`, + }; + } + // Unknown — return as-is but warn + return { + name: raw, + warning: + `⚠ attributeName "${raw}" is not a recognised vertex attribute. ` + + `Valid names: ${[...ValidAttributeNames].join(", ")}. ` + + `Using it anyway — this may cause shader errors.`, + }; +} + +// ─── Type Compatibility ─────────────────────────────────────────────────── + +/** + * GLSL vec-size group for each NME connection point type. + * Types in the same group are compatible (e.g. Color3 ↔ Vector3 are both vec3). + * "any" means the type adapts to whatever it's connected to (AutoDetect, BasedOnInput). + */ +const _typeGroup: Record = { + Float: "scalar", + Int: "scalar", + Vector2: "vec2", + Vector3: "vec3", + Color3: "vec3", + Vector4: "vec4", + Color4: "vec4", + Matrix: "matrix", + Object: "any", + AutoDetect: "any", + BasedOnInput: "any", +}; + +/** + * Reverse lookup: Numeric ConnectionPointTypes value → type name string. + */ +const _numericTypeToName: Record = {}; +for (const [name, num] of Object.entries(ConnectionPointTypes)) { + _numericTypeToName[num] = name; +} + +/** + * Resolve the effective type name of an output connection on a block. + * For InputBlocks, the type is stored as a numeric property on the block itself. + * For all other blocks, the type comes from the registry. + * @param block - The block containing the output connection. + * @param outputName - The name of the output connection to resolve the type for. + * @returns The resolved type name (e.g. "Float", "Vector3", "AutoDetect"). + */ +function _resolveOutputType(block: ISerializedBlock, outputName: string): string { + // InputBlock: actual type is in block["type"] (numeric) + const blockClass = block.customType?.replace("BABYLON.", ""); + if (blockClass === "InputBlock") { + const numType = block["type"] as number | undefined; + if (numType !== undefined && _numericTypeToName[numType]) { + return _numericTypeToName[numType]; + } + return "AutoDetect"; + } + + // For other blocks, look up registry by className + const info = BlockRegistryByClassName[blockClass]; + if (!info) { + return "AutoDetect"; + } + const outDef = info.outputs.find((o) => o.name === outputName); + return outDef?.type ?? "AutoDetect"; +} + +/** + * Resolve the expected type of an input connection on a block from the registry. + * @param block - The block containing the input connection. + * @param inputName - The name of the input connection to resolve the type for. + * @returns The resolved type name (e.g. "Float", "Vector3", "AutoDetect"). + */ +function _resolveInputType(block: ISerializedBlock, inputName: string): string { + const blockClass = block.customType?.replace("BABYLON.", ""); + if (!blockClass) { + return "AutoDetect"; + } + const info = BlockRegistryByClassName[blockClass]; + if (!info) { + return "AutoDetect"; + } + const inDef = info.inputs.find((i) => i.name === inputName); + return inDef?.type ?? "AutoDetect"; +} + +/** + * Check if two NME types are compatible for connection. + * Returns { compatible, warning?, suggestion? } + * @param outputType - The type of the output connection (e.g. "Float", "Vector3"). + * @param inputType - The type of the input connection (e.g. "Float", "Vector3"). + * @param targetBlock - The block the input connection belongs to (used for suggestions). + * @param targetInputName - The name of the input connection (used for suggestions). + * @returns An object indicating compatibility, with optional warning and suggestion messages. + */ +function _checkTypeCompatibility( + outputType: string, + inputType: string, + targetBlock: ISerializedBlock, + targetInputName: string +): { compatible: boolean; warning?: string; suggestion?: string } { + const outGroup = _typeGroup[outputType] ?? "any"; + const inGroup = _typeGroup[inputType] ?? "any"; + + // "any" group is always compatible (AutoDetect, BasedOnInput, Object) + if (outGroup === "any" || inGroup === "any") { + return { compatible: true }; + } + + // Same group is always compatible (e.g. Color3 ↔ Vector3, Float ↔ Int) + if (outGroup === inGroup) { + return { compatible: true }; + } + + // Scalar → vector promotion (Float → vec2/3/4): Babylon supports this + if (outGroup === "scalar") { + return { compatible: true, warning: `Type promotion: ${outputType} → ${inputType} (scalar will be broadcast to fill the vector).` }; + } + + // Different vec sizes: incompatible (e.g. Color3→Color4, vec3→vec4) + // Find a better input on the target block that matches the output group + const blockClass = targetBlock.customType?.replace("BABYLON.", ""); + const info = blockClass ? BlockRegistryByClassName[blockClass] : undefined; + let suggestion: string | undefined; + + if (info) { + const betterInputs = info.inputs.filter((inp) => { + const g = _typeGroup[inp.type] ?? "any"; + return g === outGroup || g === "any"; + }); + if (betterInputs.length > 0) { + const names = betterInputs.map((i) => `"${i.name}" (${i.type})`).join(", "); + suggestion = `Consider connecting to ${names} instead of "${targetInputName}" (${inputType}).`; + } + } + + return { + compatible: false, + warning: + `Type mismatch: output type ${outputType} (${outGroup}) is not compatible with input type ${inputType} (${inGroup}). ` + + `This will likely cause a shader compilation error.`, + suggestion, + }; +} + +// ─── Manager ────────────────────────────────────────────────────────────── + +/** + * Holds in-memory representations of Node Material graphs that MCP tools build up incrementally. + */ +export class MaterialGraphManager { + /** All managed material graphs, keyed by material name. */ + private _materials = new Map(); + /** Auto-increment block id counter per material */ + private _nextId = new Map(); + /** Layout tracking for aesthetic NME positioning */ + private _nextX = new Map(); + + // ── Lifecycle ────────────────────────────────────────────────────── + + /** + * Create a new empty material graph. + * @param name - Unique name for the material. + * @param mode - Material mode (e.g. "Material", "PostProcess"). + * @param comment - Optional comment/description. + * @returns The newly created serialized material. + */ + createMaterial(name: string, mode: string = "Material", comment?: string): ISerializedMaterial { + const mat: ISerializedMaterial = { + ignoreAlpha: false, + maxSimultaneousLights: 4, + mode: NodeMaterialModes[mode] ?? 0, + forceAlphaBlending: false, + blocks: [], + outputNodes: [], + comment, + }; + this._materials.set(name, mat); + this._nextId.set(name, 1); + this._nextX.set(name, 0); + return mat; + } + + /** + * Retrieve a material graph by name. + * @param name - The material name. + * @returns The serialized material, or undefined if not found. + */ + getMaterial(name: string): ISerializedMaterial | undefined { + return this._materials.get(name); + } + + /** + * List the names of all managed materials. + * @returns An array of material names. + */ + listMaterials(): string[] { + return Array.from(this._materials.keys()); + } + + /** + * Delete a material graph by name. + * @param name - The material name to delete. + * @returns True if the material was found and deleted. + */ + deleteMaterial(name: string): boolean { + this._nextId.delete(name); + this._nextX.delete(name); + return this._materials.delete(name); + } + + /** + * Remove all material graphs from memory, resetting the manager to its initial state. + */ + clearAll(): void { + this._materials.clear(); + this._nextId.clear(); + this._nextX.clear(); + } + + // ── Block CRUD ───────────────────────────────────────────────────── + + /** + * Add a block to the material graph. + * + * @param materialName Name of the material graph to add to. + * @param blockType Registry key (e.g. "MultiplyBlock", "InputBlock"). + * @param blockName Human-friendly name for this instance (e.g. "myColor"). + * @param properties Extra key-value properties to set on the block JSON. + * @returns The serialised block, or an error string. + */ + addBlock(materialName: string, blockType: string, blockName?: string, properties?: Record): { block: ISerializedBlock; warnings?: string[] } | string { + const mat = this._materials.get(materialName); + if (!mat) { + return `Material "${materialName}" not found. Create it first.`; + } + + const info: IBlockTypeInfo | undefined = BlockRegistry[blockType]; + if (!info) { + return `Unknown block type "${blockType}". Use list_block_types to see available blocks.`; + } + + const warnings: string[] = []; + + const id = this._nextId.get(materialName)!; + this._nextId.set(materialName, id + 1); + + const target = BlockTargets[info.target] ?? BlockTargets.Neutral; + const name = blockName ?? `${blockType}_${id}`; + + const block: ISerializedBlock = { + customType: `BABYLON.${info.className}`, + id, + name, + target, + inputs: info.inputs.map((inp) => ({ + name: inp.name, + displayName: inp.name, + })), + outputs: info.outputs.map((out) => ({ + name: out.name, + displayName: out.name, + })), + }; + + // Set default InputBlock fields so the NME parser never sees missing values + if (blockType === "InputBlock") { + block["mode"] = 3; // Undefined — will be overridden below as needed + block["min"] = 0; + block["max"] = 0; + block["isBoolean"] = false; + block["matrixMode"] = 0; + block["isConstant"] = false; + block["groupInInspector"] = ""; + block["convertToGammaSpace"] = false; + block["convertToLinearSpace"] = false; + block["animationType"] = 0; + } + + // Apply registry-defined default properties (e.g. ClampBlock minimum/maximum) + if (info.defaultSerializedProperties) { + for (const [key, value] of Object.entries(info.defaultSerializedProperties)) { + block[key] = value; + } + } + + // Apply user-supplied properties + if (properties) { + for (const [key, value] of Object.entries(properties)) { + // Special handling for InputBlock type — resolve string to enum + if (blockType === "InputBlock" && key === "type" && typeof value === "string") { + block["type"] = ConnectionPointTypes[value] ?? value; + } else if (blockType === "InputBlock" && key === "systemValue" && typeof value === "string") { + block["systemValue"] = SystemValues[value] ?? value; + block["mode"] = 0; // Uniform mode for system values (Uniform = 0) + } else if (blockType === "InputBlock" && key === "animationType" && typeof value === "string") { + block["animationType"] = AnimationTypes[value] ?? value; + } else if (blockType === "InputBlock" && key === "mode" && typeof value === "string") { + const modeMap: Record = { Uniform: 0, Attribute: 1, Varying: 2, Undefined: 3 }; + block["mode"] = modeMap[value] ?? value; + } else { + block[key] = value; + } + } + } + + // For InputBlock: auto-derive mode and normalise the value + if (blockType === "InputBlock") { + // Normalise attributeName to a valid Babylon.js vertex attribute + if (block["attributeName"] !== undefined) { + const attrResult = normaliseAttributeName(block["attributeName"]); + block["attributeName"] = attrResult.name; + if (attrResult.warning) { + warnings.push(attrResult.warning); + } + } + // Auto-derive mode from context when not explicitly set (still Undefined=3) + if (block["mode"] === 3) { + if (block["attributeName"] !== undefined) { + block["mode"] = 1; // Attribute — reads from vertex buffer + } else if (block["systemValue"] !== undefined) { + block["mode"] = 0; // Uniform — built-in system value + } else { + block["mode"] = 0; // Uniform — user-defined constant + } + } + // Normalise value to flat array and set valueType + this._normaliseInputBlockValue(block); + // Ensure uniform InputBlocks always have a default value + // (the NME editor crashes reading .x on undefined for vector/color types) + this._ensureDefaultValue(block); + } + + // Auto-mark as output node if this is an output block + if (blockType === "VertexOutputBlock" || blockType === "FragmentOutputBlock") { + mat.outputNodes.push(id); + } + + // Track editor location for nice layout + const x = this._nextX.get(materialName)!; + this._nextX.set(materialName, x + 280); + if (!mat.editorData) { + mat.editorData = { locations: [] }; + } + mat.editorData.locations.push({ blockId: id, x, y: 0 }); + + // ── InputBlock-specific warnings ──────────────────────────────── + if (blockType === "InputBlock") { + if (block["type"] === undefined) { + warnings.push( + `⚠ InputBlock "${block.name}" has no 'type' property. ` + + `Set type to one of: Float, Int, Vector2, Vector3, Vector4, Color3, Color4, Matrix. ` + + `Without a type, connections will fail.` + ); + } + const hasValue = block["value"] !== undefined; + const hasSysVal = block["systemValue"] !== undefined; + const hasAttr = block["attributeName"] !== undefined; + if (!hasValue && !hasSysVal && !hasAttr) { + warnings.push( + `⚠ InputBlock "${block.name}" has no value, systemValue, or attributeName. ` + + `It won't provide any data. Set one of: ` + + `value (constant), systemValue (e.g. 'WorldViewProjection'), ` + + `or attributeName (e.g. 'position', 'normal', 'uv').` + ); + } + } + + mat.blocks.push(block); + return { block, warnings: warnings.length > 0 ? warnings : undefined }; + } + + /** + * Normalise an InputBlock's `value` to the flat-array format the NME parser expects, + * and set the corresponding `valueType` string. + * + * Babylon's InputBlock._deserialize reads: + * valueType === "number" → value is a scalar + * otherwise → GetClass(valueType).FromArray(value) + * + * So we must emit flat arrays, NOT `{x,y,z}` objects. + * @param block - The InputBlock to normalise. + */ + private _normaliseInputBlockValue(block: ISerializedBlock): void { + const val = block["value"]; + if (val === undefined || val === null) { + return; + } + + const type = block["type"] as number | undefined; + + // Scalar values + if (typeof val === "number") { + // Matrix type with a scalar value — replace with identity matrix + if (type === ConnectionPointTypes.Matrix) { + block["value"] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + block["valueType"] = "BABYLON.Matrix"; + return; + } + block["valueType"] = "number"; + return; + } + + // Already a flat array — just ensure valueType is set + if (Array.isArray(val)) { + if (!block["valueType"]) { + block["valueType"] = this._inferValueType(type, val.length); + } + return; + } + + // Object with named components → convert to flat array + if (typeof val === "object") { + const obj = val as Record; + if ("r" in obj) { + // Color3 or Color4 + if ("a" in obj) { + block["value"] = [obj.r, obj.g, obj.b, obj.a]; + block["valueType"] = "BABYLON.Color4"; + } else { + block["value"] = [obj.r, obj.g, obj.b]; + block["valueType"] = "BABYLON.Color3"; + } + } else if ("x" in obj) { + // Vector2, Vector3, or Vector4 + if ("w" in obj) { + block["value"] = [obj.x, obj.y, obj.z, obj.w]; + block["valueType"] = "BABYLON.Vector4"; + } else if ("z" in obj) { + block["value"] = [obj.x, obj.y, obj.z]; + block["valueType"] = "BABYLON.Vector3"; + } else { + block["value"] = [obj.x, obj.y]; + block["valueType"] = "BABYLON.Vector2"; + } + } + } + } + + /** + * Infer `valueType` from ConnectionPointTypes enum value and array length. + * @param type - The ConnectionPointTypes enum value. + * @param length - Length of the value array. + * @returns The inferred Babylon.js value type string. + */ + private _inferValueType(type: number | undefined, length: number): string { + // Try the explicit type first + const typeMap: Record = { + [ConnectionPointTypes.Vector2]: "BABYLON.Vector2", + [ConnectionPointTypes.Vector3]: "BABYLON.Vector3", + [ConnectionPointTypes.Vector4]: "BABYLON.Vector4", + [ConnectionPointTypes.Color3]: "BABYLON.Color3", + [ConnectionPointTypes.Color4]: "BABYLON.Color4", + [ConnectionPointTypes.Matrix]: "BABYLON.Matrix", + }; + if (type !== undefined && typeMap[type]) { + return typeMap[type]; + } + + // Fall back to array length + const lengthMap: Record = { + 2: "BABYLON.Vector2", + 3: "BABYLON.Vector3", + 4: "BABYLON.Vector4", + 16: "BABYLON.Matrix", + }; + return lengthMap[length] ?? "BABYLON.Vector3"; + } + + /** + * Ensure that InputBlocks of vector/color/matrix types always have + * a default value. The NME editor reads `.x` / `.r` etc. on the value when + * displaying them and will crash if `value` is undefined. + * + * We set defaults for ALL InputBlocks (uniform, attribute, and system value) + * as a safety net — the engine ignores `_storedValue` for non-uniform blocks + * during shader generation, but the NME editor's display code may still read it. + * @param block - The InputBlock to ensure has a default value. + */ + private _ensureDefaultValue(block: ISerializedBlock): void { + // If value is already set, nothing to do + if (block["value"] !== undefined && block["value"] !== null) { + return; + } + + const type = block["type"] as number | undefined; + if (type === undefined) { + return; + } + + // Default values by type + const defaults: Record< + number, + { + /** The default value for the type. */ + value: number | number[]; + /** The Babylon.js value type string. */ + valueType: string; + } + > = { + [ConnectionPointTypes.Float]: { value: 0, valueType: "number" }, + [ConnectionPointTypes.Int]: { value: 0, valueType: "number" }, + [ConnectionPointTypes.Vector2]: { value: [0, 0], valueType: "BABYLON.Vector2" }, + [ConnectionPointTypes.Vector3]: { value: [0, 0, 0], valueType: "BABYLON.Vector3" }, + [ConnectionPointTypes.Vector4]: { value: [0, 0, 0, 0], valueType: "BABYLON.Vector4" }, + [ConnectionPointTypes.Color3]: { value: [1, 1, 1], valueType: "BABYLON.Color3" }, + [ConnectionPointTypes.Color4]: { value: [1, 1, 1, 1], valueType: "BABYLON.Color4" }, + [ConnectionPointTypes.Matrix]: { + value: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + valueType: "BABYLON.Matrix", + }, + }; + + const def = defaults[type]; + if (def) { + block["value"] = def.value; + block["valueType"] = def.valueType; + } + } + + /** + * Remove a block from a material by its id. + * @param materialName - Name of the target material. + * @param blockId - The block id to remove. + * @returns "OK" or an error string. + */ + removeBlock(materialName: string, blockId: number): string { + const mat = this._materials.get(materialName); + if (!mat) { + return `Material "${materialName}" not found.`; + } + + const idx = mat.blocks.findIndex((b) => b.id === blockId); + if (idx === -1) { + return `Block ${blockId} not found.`; + } + + // Remove any connections pointing to this block + for (const block of mat.blocks) { + for (const inp of block.inputs) { + if (inp.targetBlockId === blockId) { + delete inp.targetBlockId; + delete inp.targetConnectionName; + } + } + } + + mat.blocks.splice(idx, 1); + mat.outputNodes = mat.outputNodes.filter((n) => n !== blockId); + + if (mat.editorData) { + mat.editorData.locations = mat.editorData.locations.filter((l) => l.blockId !== blockId); + } + + return "OK"; + } + + // ── Connections ──────────────────────────────────────────────────── + + /** + * Connect an output of one block to an input of another block. + * + * @param materialName + * @param sourceBlockId The block whose output we connect FROM. + * @param outputName Name of the output connection point on the source. + * @param targetBlockId The block whose input we connect TO. + * @param inputName Name of the input connection point on the target. + * @returns "OK" or an error string. + */ + connectBlocks(materialName: string, sourceBlockId: number, outputName: string, targetBlockId: number, inputName: string): string { + const mat = this._materials.get(materialName); + if (!mat) { + return `Material "${materialName}" not found.`; + } + + const sourceBlock = mat.blocks.find((b) => b.id === sourceBlockId); + if (!sourceBlock) { + return `Source block ${sourceBlockId} not found.`; + } + + const targetBlock = mat.blocks.find((b) => b.id === targetBlockId); + if (!targetBlock) { + return `Target block ${targetBlockId} not found.`; + } + + const output = sourceBlock.outputs.find((o) => o.name === outputName); + if (!output) { + const available = sourceBlock.outputs.map((o) => o.name).join(", "); + return `Output "${outputName}" not found on block ${sourceBlockId} ("${sourceBlock.name}"). Available: ${available}`; + } + + const input = targetBlock.inputs.find((i) => i.name === inputName); + if (!input) { + const available = targetBlock.inputs.map((i) => i.name).join(", "); + return `Input "${inputName}" not found on block ${targetBlockId} ("${targetBlock.name}"). Available: ${available}`; + } + + // ── Type-compatibility check ────────────────────────────────── + const outputType = _resolveOutputType(sourceBlock, outputName); + const inputType = _resolveInputType(targetBlock, inputName); + const compat = _checkTypeCompatibility(outputType, inputType, targetBlock, inputName); + + if (!compat.compatible) { + const parts = [`TYPE MISMATCH WARNING: ${compat.warning}`]; + if (compat.suggestion) { + parts.push(compat.suggestion); + } + parts.push("The connection was NOT made. Fix the types or choose a compatible input."); + return parts.join(" "); + } + + // An input can only have one connection — overwrite any existing one + input.inputName = input.name; // Required by _restoreConnections() + input.targetBlockId = sourceBlockId; + input.targetConnectionName = outputName; + + // Return OK, but include any promotion warnings + if (compat.warning) { + return `OK (note: ${compat.warning})`; + } + + return "OK"; + } + + /** + * Disconnect an input on a block. + * @param materialName - Name of the target material. + * @param blockId - The block whose input to disconnect. + * @param inputName - Name of the input connection point. + * @returns "OK" or an error string. + */ + disconnectInput(materialName: string, blockId: number, inputName: string): string { + const mat = this._materials.get(materialName); + if (!mat) { + return `Material "${materialName}" not found.`; + } + + const block = mat.blocks.find((b) => b.id === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + const input = block.inputs.find((i) => i.name === inputName); + if (!input) { + return `Input "${inputName}" not found.`; + } + delete input.inputName; + delete input.targetBlockId; + delete input.targetConnectionName; + return "OK"; + } + + // ── Queries ──────────────────────────────────────────────────────── + + /** + * Get the current state of a material as a formatted description. + * @param materialName - Name of the material to describe. + * @returns A human-readable string describing the material graph. + */ + describeMaterial(materialName: string): string { + const mat = this._materials.get(materialName); + if (!mat) { + return `Material "${materialName}" not found.`; + } + + const lines: string[] = []; + lines.push(`Material: ${materialName}`); + lines.push(`Mode: ${Object.entries(NodeMaterialModes).find(([, v]) => v === mat.mode)?.[0] ?? mat.mode}`); + lines.push(`Blocks (${mat.blocks.length}):`); + + for (const block of mat.blocks) { + const typeName = block.customType.replace("BABYLON.", ""); + lines.push(` [${block.id}] ${block.name} (${typeName})`); + + if (block.inputs.length > 0) { + for (const inp of block.inputs) { + if (inp.targetBlockId !== undefined) { + const srcBlock = mat.blocks.find((b) => b.id === inp.targetBlockId); + lines.push(` ← ${inp.name} ← [${inp.targetBlockId}] ${srcBlock?.name ?? "?"}.${inp.targetConnectionName}`); + } + } + } + } + + lines.push(`Output nodes: [${mat.outputNodes.join(", ")}]`); + if (mat.comment) { + lines.push(`Comment: ${mat.comment}`); + } + return lines.join("\n"); + } + + /** + * Describe a single block in detail. + * @param materialName - Name of the material containing the block. + * @param blockId - The block id to describe. + * @returns A human-readable string describing the block. + */ + describeBlock(materialName: string, blockId: number): string { + const mat = this._materials.get(materialName); + if (!mat) { + return `Material "${materialName}" not found.`; + } + + const block = mat.blocks.find((b) => b.id === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + const typeName = block.customType.replace("BABYLON.", ""); + const lines: string[] = []; + lines.push(`Block [${block.id}]: "${block.name}" — type ${typeName}`); + + lines.push("\nInputs:"); + for (const inp of block.inputs) { + const conn = inp.targetBlockId !== undefined ? ` ← connected to [${inp.targetBlockId}].${inp.targetConnectionName}` : " (unconnected)"; + lines.push(` • ${inp.name}${conn}`); + } + + lines.push("\nOutputs:"); + for (const out of block.outputs) { + // Find all blocks connected to this output + const consumers: string[] = []; + for (const b of mat.blocks) { + for (const i of b.inputs) { + if (i.targetBlockId === blockId && i.targetConnectionName === out.name) { + consumers.push(`[${b.id}] ${b.name}.${i.name}`); + } + } + } + const conn = consumers.length > 0 ? ` → ${consumers.join(", ")}` : " (unconnected)"; + lines.push(` • ${out.name}${conn}`); + } + + // Show any extra properties + const ignoredKeys = new Set(["customType", "id", "name", "target", "inputs", "outputs"]); + const extraProps = Object.entries(block).filter(([k]) => !ignoredKeys.has(k)); + if (extraProps.length > 0) { + lines.push("\nProperties:"); + for (const [k, v] of extraProps) { + lines.push(` ${k}: ${JSON.stringify(v)}`); + } + } + + return lines.join("\n"); + } + + // ── Serialisation ───────────────────────────────────────────────── + + /** + * Export to the NME JSON format that Babylon.js can load. + * @param materialName - Name of the material to export. + * @returns The JSON string, or undefined if the material is not found. + */ + exportJSON(materialName: string): string | undefined { + const mat = this._materials.get(materialName); + if (!mat) { + return undefined; + } + + // Final pass: ensure every block has required properties for safe deserialization + for (const block of mat.blocks) { + if (block.customType === "BABYLON.InputBlock") { + this._normaliseInputBlockValue(block); + this._ensureDefaultValue(block); + } + + // Apply mandatory defaults from the registry for any block type + const typeName = block.customType.replace("BABYLON.", ""); + const info = BlockRegistryByClassName[typeName]; + if (info?.defaultSerializedProperties) { + for (const [key, value] of Object.entries(info.defaultSerializedProperties)) { + if (block[key] === undefined) { + block[key] = value; + } + } + } + + // Convert any remaining string enum values to numbers + const enumProps = BlockEnumProperties[typeName]; + if (enumProps) { + for (const [key, enumMap] of Object.entries(enumProps)) { + if (typeof block[key] === "string") { + block[key] = enumMap[block[key] as string] ?? block[key]; + } + } + } + } + + // Normalise RemapBlock: ensure sourceRange / targetRange are Vector2 arrays. + // The AI may have set sourceMin / sourceMax / targetMin / targetMax as scalars; + // Babylon's RemapBlock._deserialize expects sourceRange and targetRange as [min, max]. + for (const block of mat.blocks) { + if (block.customType === "BABYLON.RemapBlock") { + if (!Array.isArray(block["sourceRange"])) { + const sMin = block["sourceMin"] ?? -1; + const sMax = block["sourceMax"] ?? 1; + block["sourceRange"] = [sMin, sMax]; + } + if (!Array.isArray(block["targetRange"])) { + const tMin = block["targetMin"] ?? 0; + const tMax = block["targetMax"] ?? 1; + block["targetRange"] = [tMin, tMax]; + } + // Clean up scalar keys that are not part of the serialization format + delete block["sourceMin"]; + delete block["sourceMax"]; + delete block["targetMin"]; + delete block["targetMax"]; + } + } + + // Convert bare-URL texture strings into proper serialized Texture objects + // so that NodeMaterial.Parse() → TextureBlock._deserialize() works correctly. + // _deserialize expects { name, url, ... } — a string URL is silently ignored. + for (const block of mat.blocks) { + for (const prop of ["texture", "reflectionTexture"] as const) { + if (typeof block[prop] === "string") { + const url = block[prop] as string; + block[prop] = { + name: url, + url: url, + uOffset: 0, + vOffset: 0, + uScale: 1, + vScale: 1, + uAng: 0, + vAng: 0, + wAng: 0, + invertY: true, + samplingMode: 3, + noMipmap: false, + coordinatesMode: 0, + coordinatesIndex: 0, + hasAlpha: false, + level: 1, + }; + } + } + } + + // Compute a proper layered graph layout for the editor + this._layoutGraph(mat); + + return JSON.stringify(mat, null, 2); + } + + // ── Graph Layout ─────────────────────────────────────────────────── + + /** Horizontal spacing between columns in the editor (px). */ + private static readonly COL_WIDTH = 340; + /** Vertical spacing between blocks within a column (px). */ + private static readonly ROW_HEIGHT = 180; + + /** + * Compute a layered graph layout for the material and write it into + * `editorData.locations`. The algorithm: + * + * 1. Build predecessor/successor maps from block connections. + * 2. Assign each block a **depth** via longest-path BFS backwards from + * the output nodes (VertexOutputBlock / FragmentOutputBlock). + * 3. Reverse depth so that input blocks are on the left (column 0) and + * output blocks are on the right (max column). + * 4. Within each column, sort blocks so that they appear near the blocks + * they connect to in the next column (barycenter heuristic). + * 5. Write `{ blockId, x, y }` locations. + * + * @param mat - The material to lay out. + */ + private _layoutGraph(mat: ISerializedMaterial): void { + const blocks = mat.blocks; + if (blocks.length === 0) { + return; + } + + const blockById = new Map(); + for (const b of blocks) { + blockById.set(b.id, b); + } + + // ── Step 1: Build adjacency ──────────────────────────────────── + // predecessors[id] = set of block IDs whose outputs feed INTO this block + // successors[id] = set of block IDs this block FEEDS into + const predecessors = new Map>(); + const successors = new Map>(); + for (const b of blocks) { + predecessors.set(b.id, new Set()); + successors.set(b.id, new Set()); + } + for (const b of blocks) { + for (const inp of b.inputs) { + if (inp.targetBlockId !== undefined) { + predecessors.get(b.id)!.add(inp.targetBlockId); + successors.get(inp.targetBlockId)?.add(b.id); + } + } + } + + // ── Step 2: Longest-path depth from output nodes ─────────────── + // BFS backwards: output nodes start at depth 0, their predecessors + // at depth 1, etc. We keep the *maximum* depth for each block. + const depth = new Map(); + const queue: number[] = []; + for (const b of blocks) { + const typeName = b.customType.replace("BABYLON.", ""); + if (typeName === "VertexOutputBlock" || typeName === "FragmentOutputBlock") { + depth.set(b.id, 0); + queue.push(b.id); + } + } + // If no output nodes found, just use the last block + if (queue.length === 0 && blocks.length > 0) { + depth.set(blocks[blocks.length - 1].id, 0); + queue.push(blocks[blocks.length - 1].id); + } + + let head = 0; + while (head < queue.length) { + const id = queue[head++]; + const d = depth.get(id)!; + for (const predId of predecessors.get(id) ?? []) { + const existing = depth.get(predId); + if (existing === undefined || d + 1 > existing) { + depth.set(predId, d + 1); + queue.push(predId); + } + } + } + + // Any blocks not reached (disconnected) get max depth + 1 + const maxDepth = Math.max(0, ...depth.values()); + for (const b of blocks) { + if (!depth.has(b.id)) { + depth.set(b.id, maxDepth + 1); + } + } + + // ── Step 3: Reverse so inputs are on the left ────────────────── + const totalMaxDepth = Math.max(0, ...depth.values()); + const column = new Map(); + for (const [id, d] of depth) { + column.set(id, totalMaxDepth - d); + } + + // ── Step 4: Group blocks by column and sort within each column ─ + const columns = new Map(); + for (const b of blocks) { + const col = column.get(b.id)!; + if (!columns.has(col)) { + columns.set(col, []); + } + columns.get(col)!.push(b.id); + } + + // Barycenter heuristic: sort blocks within each column by the + // average Y-position of their successors in the next column. + // We process columns from right-to-left so the rightmost column + // (outputs) keeps a stable order. + const sortedCols = [...columns.keys()].sort((a, b) => b - a); + const yPosition = new Map(); + + for (const col of sortedCols) { + const colBlocks = columns.get(col)!; + + if (col === sortedCols[0]) { + // Rightmost column — just stack vertically + colBlocks.forEach((id, i) => yPosition.set(id, i)); + } else { + // Sort by barycenter of successors' Y positions + const barycenters = new Map(); + for (const id of colBlocks) { + const succs = successors.get(id)!; + const succYs: number[] = []; + for (const sid of succs) { + const sy = yPosition.get(sid); + if (sy !== undefined) { + succYs.push(sy); + } + } + if (succYs.length > 0) { + barycenters.set(id, succYs.reduce((a, b) => a + b, 0) / succYs.length); + } else { + // No successors placed yet — use a large value to push to bottom + barycenters.set(id, 9999); + } + } + colBlocks.sort((a, b) => barycenters.get(a)! - barycenters.get(b)!); + colBlocks.forEach((id, i) => yPosition.set(id, i)); + } + } + + // ── Step 5: Write locations ──────────────────────────────────── + const locations: Array<{ blockId: number; x: number; y: number }> = []; + for (const b of blocks) { + const col = column.get(b.id)!; + const row = yPosition.get(b.id)!; + locations.push({ + blockId: b.id, + x: col * MaterialGraphManager.COL_WIDTH, + y: row * MaterialGraphManager.ROW_HEIGHT, + }); + } + + if (!mat.editorData) { + mat.editorData = { locations }; + } else { + mat.editorData.locations = locations; + } + } + + /** + * Import an NME JSON string. + * @param materialName - Name to assign to the imported material. + * @param json - The NME JSON string to parse. + * @returns "OK" or an error string. + */ + importJSON(materialName: string, json: string): string { + try { + const parsed = ValidateNodeMaterialAttachmentPayload(json) as unknown as ISerializedMaterial; + this._materials.set(materialName, parsed); + + // Set nextId to be one higher than the max block id + const maxId = parsed.blocks.reduce((max, b) => Math.max(max, b.id), 0); + this._nextId.set(materialName, maxId + 1); + this._nextX.set(materialName, parsed.blocks.length * 280); + + return "OK"; + } catch (e) { + return (e as Error).message; + } + } + + // ── Block Property Mutation ──────────────────────────────────────── + + /** + * Set one or more properties on a block. + * @param materialName - Name of the target material. + * @param blockId - The block id to update. + * @param properties - Key-value pairs to set on the block. + * @returns "OK" or an error string. + */ + setBlockProperties(materialName: string, blockId: number, properties: Record): string { + const mat = this._materials.get(materialName); + if (!mat) { + return `Material "${materialName}" not found.`; + } + + const block = mat.blocks.find((b) => b.id === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + const typeName = block.customType.replace("BABYLON.", ""); + + for (const [key, value] of Object.entries(properties)) { + if (typeName === "InputBlock" && key === "type" && typeof value === "string") { + block["type"] = ConnectionPointTypes[value] ?? value; + } else if (typeName === "InputBlock" && key === "systemValue" && typeof value === "string") { + block["systemValue"] = SystemValues[value] ?? value; + block["mode"] = 0; // Undefined mode for system values + } else if (typeName === "InputBlock" && key === "animationType" && typeof value === "string") { + block["animationType"] = AnimationTypes[value] ?? value; + } else if (typeName === "InputBlock" && key === "mode" && typeof value === "string") { + const modeMap: Record = { Uniform: 0, Attribute: 1, Varying: 2, Undefined: 3 }; + block["mode"] = modeMap[value] ?? value; + } else if (typeof value === "string" && BlockEnumProperties[typeName]?.[key]) { + // Convert human-readable enum string to numeric value for non-InputBlock blocks + const enumMap = BlockEnumProperties[typeName][key]; + block[key] = enumMap[value] ?? value; + } else { + block[key] = value; + } + } + + // Re-normalise InputBlock value after property changes + if (typeName === "InputBlock") { + // Normalise attributeName to a valid Babylon.js vertex attribute + if (block["attributeName"] !== undefined) { + const attrResult = normaliseAttributeName(block["attributeName"]); + block["attributeName"] = attrResult.name; + if (attrResult.warning) { + return attrResult.warning; + } + } + // Auto-set Attribute mode when attributeName is provided + if (block["attributeName"] !== undefined && (block["mode"] === 3 || block["mode"] === 0)) { + block["mode"] = 1; // Attribute — reads from vertex buffer + } + this._normaliseInputBlockValue(block); + this._ensureDefaultValue(block); + } + + return "OK"; + } + + // ── Validation ──────────────────────────────────────────────────── + + /** + * Run basic validation on the graph and return any warnings/errors. + * @param materialName - Name of the material to validate. + * @returns An array of issue strings (or a success message). + */ + validateMaterial(materialName: string): string[] { + const mat = this._materials.get(materialName); + if (!mat) { + return [`Material "${materialName}" not found.`]; + } + + const issues: string[] = []; + + // Check for output nodes + const hasVertexOutput = mat.blocks.some((b) => b.customType === "BABYLON.VertexOutputBlock"); + const hasFragmentOutput = mat.blocks.some((b) => b.customType === "BABYLON.FragmentOutputBlock"); + + if (mat.mode === NodeMaterialModes.Material) { + if (!hasVertexOutput) { + issues.push("ERROR: Missing VertexOutputBlock — every material needs one."); + } + if (!hasFragmentOutput) { + issues.push("ERROR: Missing FragmentOutputBlock — every material needs one."); + } + } + + // Check for unconnected required inputs + for (const block of mat.blocks) { + const typeName = block.customType.replace("BABYLON.", ""); + const info = Object.values(BlockRegistry).find((r) => r.className === typeName); + if (!info) { + continue; + } + + for (const inp of block.inputs) { + const inputInfo = info.inputs.find((i) => i.name === inp.name); + if (inp.targetBlockId === undefined && inputInfo && !inputInfo.isOptional) { + // Skip warning if the block has defaultSerializedProperties that provide + // a fallback for this input (e.g. RemapBlock sourceRange covers sourceMin/sourceMax) + if (info.defaultSerializedProperties && Object.keys(info.defaultSerializedProperties).length > 0) { + continue; + } + issues.push(`WARNING: Block [${block.id}] "${block.name}" has required input "${inp.name}" that is not connected.`); + } + } + } + + // Check that output nodes actually exist + for (const outputId of mat.outputNodes) { + if (!mat.blocks.find((b) => b.id === outputId)) { + issues.push(`ERROR: Output node references block ${outputId} which does not exist.`); + } + } + + // Check for dangling connection references + for (const block of mat.blocks) { + for (const inp of block.inputs) { + if (inp.targetBlockId !== undefined) { + const src = mat.blocks.find((b) => b.id === inp.targetBlockId); + if (!src) { + issues.push(`ERROR: Block [${block.id}] "${block.name}" input "${inp.name}" references non-existent block ${inp.targetBlockId}.`); + } else if (!src.outputs.find((o) => o.name === inp.targetConnectionName)) { + issues.push( + `WARNING: Block [${block.id}] "${block.name}" input "${inp.name}" references output "${inp.targetConnectionName}" which doesn't exist on block [${src.id}].` + ); + } + } + } + } + + // Check InputBlock-specific issues + for (const block of mat.blocks) { + if (block.customType !== "BABYLON.InputBlock") { + continue; + } + if (block["type"] === undefined) { + issues.push( + `ERROR: InputBlock [${block.id}] "${block.name}" has no 'type' property. ` + `Set type to: Float, Int, Vector2, Vector3, Vector4, Color3, Color4, or Matrix.` + ); + } + const hasValue = block["value"] !== undefined; + const hasSysVal = block["systemValue"] !== undefined; + const hasAttr = block["attributeName"] !== undefined; + if (!hasValue && !hasSysVal && !hasAttr) { + issues.push(`WARNING: InputBlock [${block.id}] "${block.name}" has no value, systemValue, or attributeName — it provides no data.`); + } + } + + // Check for orphan blocks (no connections in or out, not an output block) + for (const block of mat.blocks) { + if (block.customType === "BABYLON.VertexOutputBlock" || block.customType === "BABYLON.FragmentOutputBlock") { + continue; // Output blocks are sinks — they only have inputs + } + if (block.customType === "BABYLON.InputBlock") { + continue; // InputBlocks are sources — they only have outputs, checked separately above + } + const hasIncomingConnection = block.inputs.some((inp) => inp.targetBlockId !== undefined); + const hasOutgoingConnection = mat.blocks.some((other) => other.inputs.some((inp) => inp.targetBlockId === block.id)); + if (!hasIncomingConnection && !hasOutgoingConnection) { + issues.push(`WARNING: Block [${block.id}] "${block.name}" (${block.customType.replace("BABYLON.", "")}) has no connections — it is an orphan and does nothing.`); + } + } + + if (issues.length === 0) { + issues.push("No issues found — graph looks valid."); + } + + return issues; + } +} diff --git a/packages/tools/nme-mcp-server/test/unit/fixtures/pbrMaterial.json b/packages/tools/nme-mcp-server/test/unit/fixtures/pbrMaterial.json new file mode 100644 index 00000000000..5b870270741 --- /dev/null +++ b/packages/tools/nme-mcp-server/test/unit/fixtures/pbrMaterial.json @@ -0,0 +1,639 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "attributeName": "position", + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "normal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "attributeName": "normal", + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "worldViewProjection", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 128, + "systemValue": 6, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "world", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 128, + "systemValue": 1, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "view", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 128, + "systemValue": 2, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "cameraPosition", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "systemValue": 7, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.TransformBlock", + "id": 7, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 3, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 8, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 7, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.PBRMetallicRoughnessBlock", + "id": 9, + "name": "PBR", + "target": 3, + "inputs": [ + { + "name": "worldPosition", + "displayName": "worldPosition", + "inputName": "worldPosition", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "worldNormal", + "displayName": "worldNormal" + }, + { + "name": "view", + "displayName": "view", + "inputName": "view", + "targetBlockId": 5, + "targetConnectionName": "output" + }, + { + "name": "cameraPosition", + "displayName": "cameraPosition", + "inputName": "cameraPosition", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "perturbedNormal", + "displayName": "perturbedNormal" + }, + { + "name": "baseColor", + "displayName": "baseColor", + "inputName": "baseColor", + "targetBlockId": 11, + "targetConnectionName": "output" + }, + { + "name": "metallic", + "displayName": "metallic", + "inputName": "metallic", + "targetBlockId": 12, + "targetConnectionName": "output" + }, + { + "name": "roughness", + "displayName": "roughness", + "inputName": "roughness", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "ambientOcc", + "displayName": "ambientOcc" + }, + { + "name": "opacity", + "displayName": "opacity" + }, + { + "name": "indexOfRefraction", + "displayName": "indexOfRefraction" + }, + { + "name": "ambientColor", + "displayName": "ambientColor" + }, + { + "name": "reflection", + "displayName": "reflection" + }, + { + "name": "clearcoat", + "displayName": "clearcoat" + }, + { + "name": "sheen", + "displayName": "sheen" + }, + { + "name": "subsurface", + "displayName": "subsurface" + }, + { + "name": "anisotropy", + "displayName": "anisotropy" + }, + { + "name": "iridescence", + "displayName": "iridescence" + } + ], + "outputs": [ + { + "name": "ambientClr", + "displayName": "ambientClr" + }, + { + "name": "diffuseDir", + "displayName": "diffuseDir" + }, + { + "name": "specularDir", + "displayName": "specularDir" + }, + { + "name": "clearcoatDir", + "displayName": "clearcoatDir" + }, + { + "name": "sheenDir", + "displayName": "sheenDir" + }, + { + "name": "diffuseInd", + "displayName": "diffuseInd" + }, + { + "name": "specularInd", + "displayName": "specularInd" + }, + { + "name": "clearcoatInd", + "displayName": "clearcoatInd" + }, + { + "name": "sheenInd", + "displayName": "sheenInd" + }, + { + "name": "refraction", + "displayName": "refraction" + }, + { + "name": "lighting", + "displayName": "lighting" + }, + { + "name": "shadow", + "displayName": "shadow" + }, + { + "name": "alpha", + "displayName": "alpha" + } + ], + "lightFalloff": 0, + "useAlphaTest": false, + "alphaTestCutoff": 0.5, + "useAlphaBlending": false, + "useRadianceOverAlpha": true, + "useSpecularOverAlpha": true, + "enableSpecularAntiAliasing": false, + "realTimeFiltering": false, + "realTimeFilteringQuality": 8, + "useEnergyConservation": true, + "useRadianceOcclusion": true, + "useHorizonOcclusion": true, + "unlit": false, + "forceNormalForward": false, + "debugMode": 0, + "debugLimit": -1, + "debugFactor": 1, + "generateOnlyFragmentCode": false, + "directIntensity": 1, + "environmentIntensity": 1, + "specularIntensity": 1 + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 10, + "name": "fragOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 9, + "targetConnectionName": "lighting" + }, + { + "name": "a", + "displayName": "a" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.InputBlock", + "id": 11, + "name": "baseColor", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 0.8, + 0.2, + 0.1 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 12, + "name": "metallic", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 13, + "name": "roughness", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.5, + "valueType": "number" + } + ], + "outputNodes": [ + 8, + 10 + ], + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 340, + "y": 0 + }, + { + "blockId": 2, + "x": 0, + "y": 0 + }, + { + "blockId": 3, + "x": 340, + "y": 180 + }, + { + "blockId": 4, + "x": 0, + "y": 180 + }, + { + "blockId": 5, + "x": 680, + "y": 0 + }, + { + "blockId": 6, + "x": 680, + "y": 180 + }, + { + "blockId": 7, + "x": 680, + "y": 360 + }, + { + "blockId": 8, + "x": 1360, + "y": 0 + }, + { + "blockId": 9, + "x": 1020, + "y": 0 + }, + { + "blockId": 10, + "x": 1360, + "y": 180 + }, + { + "blockId": 11, + "x": 680, + "y": 540 + }, + { + "blockId": 12, + "x": 680, + "y": 720 + }, + { + "blockId": 13, + "x": 680, + "y": 900 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-bricks.json b/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-bricks.json new file mode 100644 index 00000000000..9dc7d91a7c3 --- /dev/null +++ b/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-bricks.json @@ -0,0 +1,2009 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "normal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "world", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 1, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "worldViewProjection", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 6, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "cameraPosition", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 7, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "view", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 2, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 8, + "name": "brickCountX", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 6, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 9, + "name": "brickCountY", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 12, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 10, + "name": "mortarWidth", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.05, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 11, + "name": "halfConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.5, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 12, + "name": "twoConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 2, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 13, + "name": "oneConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 14, + "name": "roughness", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.92, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 15, + "name": "metallic", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 16, + "name": "noiseAmount", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.12, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 17, + "name": "zeroConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 18, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 21, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 19, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 51, + "targetConnectionName": "lighting" + }, + { + "name": "a", + "displayName": "a", + "inputName": "a", + "targetBlockId": 13, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.TransformBlock", + "id": 20, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 21, + "name": "clipPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 20, + "targetConnectionName": "xyz" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 22, + "name": "splitUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 23, + "name": "scaledU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 22, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 24, + "name": "scaledV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 22, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 25, + "name": "rowIndex", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 24, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 6 + }, + { + "customType": "BABYLON.ModBlock", + "id": 26, + "name": "rowMod2", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 25, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 12, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 27, + "name": "staggerOffset", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 26, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 28, + "name": "staggeredU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 23, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 27, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 29, + "name": "fractU", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 28, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 30, + "name": "fractV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 24, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.OneMinusBlock", + "id": 31, + "name": "oneMinusFractU", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 29, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MinBlock", + "id": 32, + "name": "distFromEdgeU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 29, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 31, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.OneMinusBlock", + "id": 33, + "name": "oneMinusFractV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 30, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MinBlock", + "id": 34, + "name": "distFromEdgeV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 30, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 33, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SmoothStepBlock", + "id": 35, + "name": "brickMaskU", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 32, + "targetConnectionName": "output" + }, + { + "name": "edge0", + "displayName": "edge0", + "inputName": "edge0", + "targetBlockId": 17, + "targetConnectionName": "output" + }, + { + "name": "edge1", + "displayName": "edge1", + "inputName": "edge1", + "targetBlockId": 10, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SmoothStepBlock", + "id": 36, + "name": "brickMaskV", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 34, + "targetConnectionName": "output" + }, + { + "name": "edge0", + "displayName": "edge0", + "inputName": "edge0", + "targetBlockId": 17, + "targetConnectionName": "output" + }, + { + "name": "edge1", + "displayName": "edge1", + "inputName": "edge1", + "targetBlockId": 10, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 37, + "name": "brickMask", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 35, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 36, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 38, + "name": "floorU", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 28, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 6 + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 39, + "name": "floorV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 24, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 6 + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 40, + "name": "cellSeed", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 38, + "targetConnectionName": "output" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 39, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 11, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 41, + "name": "brickVariation", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 40, + "targetConnectionName": "xyz" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 42, + "name": "colorShift", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 41, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 43, + "name": "noiseHalf", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 41, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 44, + "name": "noiseRemapped", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 43, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ClampBlock", + "id": 45, + "name": "noiseClamped", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 44, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "minimum": 0, + "maximum": 1 + }, + { + "customType": "BABYLON.GradientBlock", + "id": 46, + "name": "brickGradient", + "target": 4, + "inputs": [ + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 45, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "colorSteps": [ + { + "color": { + "b": 0.08, + "g": 0.12, + "r": 0.35 + }, + "step": 0 + }, + { + "color": { + "b": 0.1, + "g": 0.18, + "r": 0.55 + }, + "step": 0.3 + }, + { + "color": { + "b": 0.12, + "g": 0.25, + "r": 0.65 + }, + "step": 0.6 + }, + { + "color": { + "b": 0.15, + "g": 0.3, + "r": 0.7 + }, + "step": 0.85 + }, + { + "color": { + "b": 0.18, + "g": 0.35, + "r": 0.75 + }, + "step": 1 + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 47, + "name": "mortarColor", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 0.7, + 0.68, + 0.65 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.LerpBlock", + "id": 48, + "name": "finalColor", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 47, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 46, + "targetConnectionName": "output" + }, + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 37, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 49, + "name": "mortarRoughness", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.7, + "valueType": "number" + }, + { + "customType": "BABYLON.LerpBlock", + "id": 50, + "name": "finalRoughness", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 49, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 14, + "targetConnectionName": "output" + }, + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 37, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.PBRMetallicRoughnessBlock", + "id": 51, + "name": "pbr", + "target": 3, + "inputs": [ + { + "name": "worldPosition", + "displayName": "worldPosition", + "inputName": "worldPosition", + "targetBlockId": 20, + "targetConnectionName": "output" + }, + { + "name": "worldNormal", + "displayName": "worldNormal", + "inputName": "worldNormal", + "targetBlockId": 52, + "targetConnectionName": "output" + }, + { + "name": "view", + "displayName": "view", + "inputName": "view", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "cameraPosition", + "displayName": "cameraPosition", + "inputName": "cameraPosition", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "perturbedNormal", + "displayName": "perturbedNormal" + }, + { + "name": "baseColor", + "displayName": "baseColor", + "inputName": "baseColor", + "targetBlockId": 48, + "targetConnectionName": "output" + }, + { + "name": "metallic", + "displayName": "metallic", + "inputName": "metallic", + "targetBlockId": 15, + "targetConnectionName": "output" + }, + { + "name": "roughness", + "displayName": "roughness", + "inputName": "roughness", + "targetBlockId": 50, + "targetConnectionName": "output" + }, + { + "name": "ambientOcc", + "displayName": "ambientOcc" + }, + { + "name": "opacity", + "displayName": "opacity" + }, + { + "name": "indexOfRefraction", + "displayName": "indexOfRefraction" + }, + { + "name": "ambientColor", + "displayName": "ambientColor" + }, + { + "name": "reflection", + "displayName": "reflection" + }, + { + "name": "clearcoat", + "displayName": "clearcoat" + }, + { + "name": "sheen", + "displayName": "sheen" + }, + { + "name": "subsurface", + "displayName": "subsurface" + }, + { + "name": "anisotropy", + "displayName": "anisotropy" + }, + { + "name": "iridescence", + "displayName": "iridescence" + } + ], + "outputs": [ + { + "name": "ambientClr", + "displayName": "ambientClr" + }, + { + "name": "diffuseDir", + "displayName": "diffuseDir" + }, + { + "name": "specularDir", + "displayName": "specularDir" + }, + { + "name": "diffuseInd", + "displayName": "diffuseInd" + }, + { + "name": "specularInd", + "displayName": "specularInd" + }, + { + "name": "specularEnvironmentR0", + "displayName": "specularEnvironmentR0" + }, + { + "name": "specularEnvironmentR90", + "displayName": "specularEnvironmentR90" + }, + { + "name": "refraction", + "displayName": "refraction" + }, + { + "name": "clearcoat", + "displayName": "clearcoat" + }, + { + "name": "sheen", + "displayName": "sheen" + }, + { + "name": "lighting", + "displayName": "lighting" + }, + { + "name": "shadow", + "displayName": "shadow" + }, + { + "name": "alpha", + "displayName": "alpha" + } + ], + "lightFalloff": 0, + "useAlphaTest": false, + "alphaTestCutoff": 0.5, + "useAlphaBlending": false, + "useRadianceOverAlpha": true, + "useSpecularOverAlpha": true, + "enableSpecularAntiAliasing": false, + "realTimeFiltering": false, + "realTimeFilteringQuality": 8, + "useEnergyConservation": true, + "useRadianceOcclusion": true, + "useHorizonOcclusion": true, + "unlit": false, + "forceNormalForward": false, + "debugMode": 0, + "debugLimit": -1, + "debugFactor": 1, + "generateOnlyFragmentCode": false, + "directIntensity": 1, + "environmentIntensity": 1, + "specularIntensity": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 52, + "name": "worldNormal", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + } + ], + "outputNodes": [ + 18, + 19 + ], + "comment": "Procedural brick wall material with mortar lines and color variation", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 4760, + "y": 0 + }, + { + "blockId": 2, + "x": 4760, + "y": 1260 + }, + { + "blockId": 3, + "x": 340, + "y": 0 + }, + { + "blockId": 4, + "x": 4760, + "y": 180 + }, + { + "blockId": 5, + "x": 5100, + "y": 0 + }, + { + "blockId": 6, + "x": 5100, + "y": 360 + }, + { + "blockId": 7, + "x": 5100, + "y": 540 + }, + { + "blockId": 8, + "x": 1700, + "y": 0 + }, + { + "blockId": 9, + "x": 680, + "y": 0 + }, + { + "blockId": 10, + "x": 4080, + "y": 360 + }, + { + "blockId": 11, + "x": 1700, + "y": 180 + }, + { + "blockId": 12, + "x": 1360, + "y": 0 + }, + { + "blockId": 13, + "x": 5440, + "y": 180 + }, + { + "blockId": 14, + "x": 4760, + "y": 900 + }, + { + "blockId": 15, + "x": 5100, + "y": 720 + }, + { + "blockId": 16, + "x": 0, + "y": 0 + }, + { + "blockId": 17, + "x": 4080, + "y": 540 + }, + { + "blockId": 18, + "x": 5780, + "y": 0 + }, + { + "blockId": 19, + "x": 5780, + "y": 180 + }, + { + "blockId": 20, + "x": 5100, + "y": 180 + }, + { + "blockId": 21, + "x": 5440, + "y": 0 + }, + { + "blockId": 22, + "x": 680, + "y": 180 + }, + { + "blockId": 23, + "x": 2040, + "y": 0 + }, + { + "blockId": 24, + "x": 1020, + "y": 0 + }, + { + "blockId": 25, + "x": 1360, + "y": 180 + }, + { + "blockId": 26, + "x": 1700, + "y": 360 + }, + { + "blockId": 27, + "x": 2040, + "y": 180 + }, + { + "blockId": 28, + "x": 2380, + "y": 0 + }, + { + "blockId": 29, + "x": 3400, + "y": 180 + }, + { + "blockId": 30, + "x": 3400, + "y": 360 + }, + { + "blockId": 31, + "x": 3740, + "y": 180 + }, + { + "blockId": 32, + "x": 4080, + "y": 180 + }, + { + "blockId": 33, + "x": 3740, + "y": 360 + }, + { + "blockId": 34, + "x": 4080, + "y": 720 + }, + { + "blockId": 35, + "x": 4420, + "y": 180 + }, + { + "blockId": 36, + "x": 4420, + "y": 360 + }, + { + "blockId": 37, + "x": 4760, + "y": 720 + }, + { + "blockId": 38, + "x": 2720, + "y": 0 + }, + { + "blockId": 39, + "x": 2720, + "y": 180 + }, + { + "blockId": 40, + "x": 3060, + "y": 0 + }, + { + "blockId": 41, + "x": 3400, + "y": 0 + }, + { + "blockId": 42, + "x": 0, + "y": 180 + }, + { + "blockId": 43, + "x": 3740, + "y": 0 + }, + { + "blockId": 44, + "x": 4080, + "y": 0 + }, + { + "blockId": 45, + "x": 4420, + "y": 0 + }, + { + "blockId": 46, + "x": 4760, + "y": 360 + }, + { + "blockId": 47, + "x": 4760, + "y": 540 + }, + { + "blockId": 48, + "x": 5100, + "y": 900 + }, + { + "blockId": 49, + "x": 4760, + "y": 1080 + }, + { + "blockId": 50, + "x": 5100, + "y": 1080 + }, + { + "blockId": 51, + "x": 5440, + "y": 360 + }, + { + "blockId": 52, + "x": 5100, + "y": 1260 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-grass.json b/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-grass.json new file mode 100644 index 00000000000..894a99598f0 --- /dev/null +++ b/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-grass.json @@ -0,0 +1,1587 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "normal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "world", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 1, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "worldViewProjection", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 6, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "cameraPosition", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 7, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "view", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 2, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 8, + "name": "time", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 1, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 9, + "name": "windSpeed", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.8, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 10, + "name": "windStrength", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.12, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 11, + "name": "noiseScale", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 4, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 12, + "name": "detailScale", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 8, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 13, + "name": "roughness", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.85, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 14, + "name": "metallic", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 15, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 18, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 16, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 37, + "targetConnectionName": "lighting" + }, + { + "name": "a", + "displayName": "a", + "inputName": "a", + "targetBlockId": 39, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.TransformBlock", + "id": 17, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 18, + "name": "clipPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 17, + "targetConnectionName": "xyz" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 19, + "name": "splitUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 20, + "name": "timeScale", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 8, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 21, + "name": "windOffsetX", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 19, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 20, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 22, + "name": "noiseSeed3D", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 21, + "targetConnectionName": "output" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 19, + "targetConnectionName": "y" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 20, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 23, + "name": "scaledSeed", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 22, + "targetConnectionName": "xyz" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 24, + "name": "grassNoise", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 23, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 25, + "name": "detailSeed", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 22, + "targetConnectionName": "xyz" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 12, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 26, + "name": "detailNoise", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 25, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 27, + "name": "largeWeight", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.7, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 28, + "name": "detailWeight", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.3, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 29, + "name": "largeWeighted", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 24, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 27, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 30, + "name": "detailWeighted", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 26, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 28, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 31, + "name": "combinedNoise", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 29, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 30, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 32, + "name": "half", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.5, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 33, + "name": "noiseHalf", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 31, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 32, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 34, + "name": "noiseRemapped", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 33, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 32, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ClampBlock", + "id": 35, + "name": "noiseClamped", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 34, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "minimum": 0, + "maximum": 1 + }, + { + "customType": "BABYLON.GradientBlock", + "id": 36, + "name": "grassGradient", + "target": 4, + "inputs": [ + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 35, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "colorSteps": [ + { + "step": 0, + "color": { + "r": 0.05, + "g": 0.08, + "b": 0.02 + } + }, + { + "step": 0.25, + "color": { + "r": 0.1, + "g": 0.25, + "b": 0.05 + } + }, + { + "step": 0.5, + "color": { + "r": 0.15, + "g": 0.45, + "b": 0.08 + } + }, + { + "step": 0.75, + "color": { + "r": 0.3, + "g": 0.6, + "b": 0.12 + } + }, + { + "step": 1, + "color": { + "r": 0.45, + "g": 0.75, + "b": 0.2 + } + } + ] + }, + { + "customType": "BABYLON.PBRMetallicRoughnessBlock", + "id": 37, + "name": "pbr", + "target": 3, + "inputs": [ + { + "name": "worldPosition", + "displayName": "worldPosition", + "inputName": "worldPosition", + "targetBlockId": 17, + "targetConnectionName": "output" + }, + { + "name": "worldNormal", + "displayName": "worldNormal", + "inputName": "worldNormal", + "targetBlockId": 38, + "targetConnectionName": "output" + }, + { + "name": "view", + "displayName": "view", + "inputName": "view", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "cameraPosition", + "displayName": "cameraPosition", + "inputName": "cameraPosition", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "perturbedNormal", + "displayName": "perturbedNormal" + }, + { + "name": "baseColor", + "displayName": "baseColor", + "inputName": "baseColor", + "targetBlockId": 36, + "targetConnectionName": "output" + }, + { + "name": "metallic", + "displayName": "metallic", + "inputName": "metallic", + "targetBlockId": 14, + "targetConnectionName": "output" + }, + { + "name": "roughness", + "displayName": "roughness", + "inputName": "roughness", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "ambientOcc", + "displayName": "ambientOcc" + }, + { + "name": "opacity", + "displayName": "opacity" + }, + { + "name": "indexOfRefraction", + "displayName": "indexOfRefraction" + }, + { + "name": "ambientColor", + "displayName": "ambientColor" + }, + { + "name": "reflection", + "displayName": "reflection" + }, + { + "name": "clearcoat", + "displayName": "clearcoat" + }, + { + "name": "sheen", + "displayName": "sheen" + }, + { + "name": "subsurface", + "displayName": "subsurface" + }, + { + "name": "anisotropy", + "displayName": "anisotropy" + }, + { + "name": "iridescence", + "displayName": "iridescence" + } + ], + "outputs": [ + { + "name": "ambientClr", + "displayName": "ambientClr" + }, + { + "name": "diffuseDir", + "displayName": "diffuseDir" + }, + { + "name": "specularDir", + "displayName": "specularDir" + }, + { + "name": "diffuseInd", + "displayName": "diffuseInd" + }, + { + "name": "specularInd", + "displayName": "specularInd" + }, + { + "name": "specularEnvironmentR0", + "displayName": "specularEnvironmentR0" + }, + { + "name": "specularEnvironmentR90", + "displayName": "specularEnvironmentR90" + }, + { + "name": "refraction", + "displayName": "refraction" + }, + { + "name": "clearcoat", + "displayName": "clearcoat" + }, + { + "name": "sheen", + "displayName": "sheen" + }, + { + "name": "lighting", + "displayName": "lighting" + }, + { + "name": "shadow", + "displayName": "shadow" + }, + { + "name": "alpha", + "displayName": "alpha" + } + ], + "lightFalloff": 0, + "useAlphaTest": false, + "alphaTestCutoff": 0.5, + "useAlphaBlending": false, + "useRadianceOverAlpha": true, + "useSpecularOverAlpha": true, + "enableSpecularAntiAliasing": false, + "realTimeFiltering": false, + "realTimeFilteringQuality": 8, + "useEnergyConservation": true, + "useRadianceOcclusion": true, + "useHorizonOcclusion": true, + "unlit": false, + "forceNormalForward": false, + "debugMode": 0, + "debugLimit": -1, + "debugFactor": 1, + "generateOnlyFragmentCode": false, + "directIntensity": 1, + "environmentIntensity": 1, + "specularIntensity": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 38, + "name": "worldNormal", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.InputBlock", + "id": 39, + "name": "alphaOne", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1, + "valueType": "number" + } + ], + "outputNodes": [ + 15, + 16 + ], + "comment": "Dynamic procedural grass material with wind animation and PBR lighting", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 3740, + "y": 0 + }, + { + "blockId": 2, + "x": 3740, + "y": 540 + }, + { + "blockId": 3, + "x": 340, + "y": 0 + }, + { + "blockId": 4, + "x": 3740, + "y": 180 + }, + { + "blockId": 5, + "x": 4080, + "y": 0 + }, + { + "blockId": 6, + "x": 4080, + "y": 360 + }, + { + "blockId": 7, + "x": 4080, + "y": 540 + }, + { + "blockId": 8, + "x": 340, + "y": 180 + }, + { + "blockId": 9, + "x": 340, + "y": 360 + }, + { + "blockId": 10, + "x": 0, + "y": 0 + }, + { + "blockId": 11, + "x": 1360, + "y": 0 + }, + { + "blockId": 12, + "x": 1360, + "y": 360 + }, + { + "blockId": 13, + "x": 4080, + "y": 720 + }, + { + "blockId": 14, + "x": 4080, + "y": 900 + }, + { + "blockId": 15, + "x": 4760, + "y": 0 + }, + { + "blockId": 16, + "x": 4760, + "y": 180 + }, + { + "blockId": 17, + "x": 4080, + "y": 180 + }, + { + "blockId": 18, + "x": 4420, + "y": 0 + }, + { + "blockId": 19, + "x": 680, + "y": 0 + }, + { + "blockId": 20, + "x": 680, + "y": 180 + }, + { + "blockId": 21, + "x": 1020, + "y": 0 + }, + { + "blockId": 22, + "x": 1360, + "y": 180 + }, + { + "blockId": 23, + "x": 1700, + "y": 0 + }, + { + "blockId": 24, + "x": 2040, + "y": 0 + }, + { + "blockId": 25, + "x": 1700, + "y": 180 + }, + { + "blockId": 26, + "x": 2040, + "y": 360 + }, + { + "blockId": 27, + "x": 2040, + "y": 180 + }, + { + "blockId": 28, + "x": 2040, + "y": 540 + }, + { + "blockId": 29, + "x": 2380, + "y": 0 + }, + { + "blockId": 30, + "x": 2380, + "y": 180 + }, + { + "blockId": 31, + "x": 2720, + "y": 0 + }, + { + "blockId": 32, + "x": 2720, + "y": 180 + }, + { + "blockId": 33, + "x": 3060, + "y": 0 + }, + { + "blockId": 34, + "x": 3400, + "y": 0 + }, + { + "blockId": 35, + "x": 3740, + "y": 360 + }, + { + "blockId": 36, + "x": 4080, + "y": 1080 + }, + { + "blockId": 37, + "x": 4420, + "y": 180 + }, + { + "blockId": 38, + "x": 4080, + "y": 1260 + }, + { + "blockId": 39, + "x": 4420, + "y": 360 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-psychedelic.json b/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-psychedelic.json new file mode 100644 index 00000000000..3cd0a93796f --- /dev/null +++ b/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-psychedelic.json @@ -0,0 +1,1914 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "normal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "world", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 1, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "worldViewProjection", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 6, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "cameraPosition", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 7, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "view", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 2, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 8, + "name": "time", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 1, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 9, + "name": "speed1", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.3, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 10, + "name": "speed2", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.7, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 11, + "name": "noiseScale1", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 3, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 12, + "name": "noiseScale2", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 7, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 13, + "name": "twirlStr", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 3.5, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 14, + "name": "posterSteps", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 6, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 15, + "name": "oneConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 16, + "name": "halfConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.5, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 17, + "name": "twirlCenter", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 0.5, + 0.5 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 18, + "name": "twirlOffset", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 19, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 22, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 20, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 52, + "targetConnectionName": "output" + }, + { + "name": "a", + "displayName": "a", + "inputName": "a", + "targetBlockId": 15, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.TransformBlock", + "id": 21, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 22, + "name": "clipPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 21, + "targetConnectionName": "xyz" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 23, + "name": "twirlAngle", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 8, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TwirlBlock", + "id": 24, + "name": "twirlUV", + "target": 2, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "strength", + "displayName": "strength", + "inputName": "strength", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "center", + "displayName": "center", + "inputName": "center", + "targetBlockId": 17, + "targetConnectionName": "output" + }, + { + "name": "offset", + "displayName": "offset", + "inputName": "offset", + "targetBlockId": 18, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 25, + "name": "splitTwirl", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 24, + "targetConnectionName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 26, + "name": "timeShift1", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 8, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 27, + "name": "timeShift2", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 8, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 10, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 28, + "name": "scrolledV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 25, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 27, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 29, + "name": "seed3D_1", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 25, + "targetConnectionName": "x" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 28, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 26, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 30, + "name": "scaleSeed1", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 29, + "targetConnectionName": "xyz" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 31, + "name": "noise1", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 30, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 32, + "name": "scaleSeed2", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 29, + "targetConnectionName": "xyz" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 12, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 33, + "name": "noise2", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 32, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 34, + "name": "combNoise", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 31, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 33, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 35, + "name": "noiseHalf", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 34, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 36, + "name": "noiseRemapped", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 35, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 37, + "name": "hueCycle", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 8, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 10, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 38, + "name": "shiftedNoise", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 36, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 37, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 39, + "name": "fractHue", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 38, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.GradientBlock", + "id": 40, + "name": "neonGradient", + "target": 4, + "inputs": [ + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 39, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "colorSteps": [ + { + "color": { + "b": 0.3, + "g": 0, + "r": 1 + }, + "step": 0 + }, + { + "color": { + "b": 0, + "g": 0.5, + "r": 1 + }, + "step": 0.17 + }, + { + "color": { + "b": 0, + "g": 1, + "r": 1 + }, + "step": 0.33 + }, + { + "color": { + "b": 0.3, + "g": 1, + "r": 0 + }, + "step": 0.5 + }, + { + "color": { + "b": 1, + "g": 0.5, + "r": 0 + }, + "step": 0.67 + }, + { + "color": { + "b": 1, + "g": 0, + "r": 0.6 + }, + "step": 0.83 + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 41, + "name": "noise2Half", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 33, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 42, + "name": "noise2Remap", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 41, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 43, + "name": "shiftedNoise2", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 42, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 26, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 44, + "name": "fractHue2", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 43, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.GradientBlock", + "id": 45, + "name": "neonGradient2", + "target": 4, + "inputs": [ + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 44, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "colorSteps": [ + { + "color": { + "b": 1, + "g": 1, + "r": 0 + }, + "step": 0 + }, + { + "color": { + "b": 1, + "g": 0, + "r": 1 + }, + "step": 0.2 + }, + { + "color": { + "b": 0, + "g": 1, + "r": 1 + }, + "step": 0.4 + }, + { + "color": { + "b": 0, + "g": 1, + "r": 0 + }, + "step": 0.6 + }, + { + "color": { + "b": 1, + "g": 0, + "r": 0 + }, + "step": 0.8 + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 46, + "name": "n1Half", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 31, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 47, + "name": "n1Remap", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 46, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ClampBlock", + "id": 48, + "name": "blendFactor", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 47, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "minimum": 0, + "maximum": 1 + }, + { + "customType": "BABYLON.LerpBlock", + "id": 49, + "name": "mixColors", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 40, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 45, + "targetConnectionName": "output" + }, + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 48, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.PosterizeBlock", + "id": 50, + "name": "posterize", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 49, + "targetConnectionName": "output" + }, + { + "name": "steps", + "displayName": "steps", + "inputName": "steps", + "targetBlockId": 14, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.InputBlock", + "id": 51, + "name": "boostFactor", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1.4, + "valueType": "number" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 52, + "name": "boosted", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 50, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 51, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + } + ], + "outputNodes": [ + 19, + 20 + ], + "comment": "Psychedelic animated material with swirling neon colors and time-cycling hues", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 4760, + "y": 0 + }, + { + "blockId": 2, + "x": 0, + "y": 0 + }, + { + "blockId": 3, + "x": 340, + "y": 0 + }, + { + "blockId": 4, + "x": 4760, + "y": 180 + }, + { + "blockId": 5, + "x": 5100, + "y": 0 + }, + { + "blockId": 6, + "x": 0, + "y": 180 + }, + { + "blockId": 7, + "x": 0, + "y": 360 + }, + { + "blockId": 8, + "x": 680, + "y": 0 + }, + { + "blockId": 9, + "x": 1020, + "y": 0 + }, + { + "blockId": 10, + "x": 680, + "y": 360 + }, + { + "blockId": 11, + "x": 1700, + "y": 360 + }, + { + "blockId": 12, + "x": 1700, + "y": 0 + }, + { + "blockId": 13, + "x": 340, + "y": 180 + }, + { + "blockId": 14, + "x": 4760, + "y": 360 + }, + { + "blockId": 15, + "x": 5440, + "y": 180 + }, + { + "blockId": 16, + "x": 2720, + "y": 180 + }, + { + "blockId": 17, + "x": 340, + "y": 360 + }, + { + "blockId": 18, + "x": 340, + "y": 540 + }, + { + "blockId": 19, + "x": 5780, + "y": 0 + }, + { + "blockId": 20, + "x": 5780, + "y": 180 + }, + { + "blockId": 21, + "x": 5100, + "y": 180 + }, + { + "blockId": 22, + "x": 5440, + "y": 0 + }, + { + "blockId": 23, + "x": 0, + "y": 540 + }, + { + "blockId": 24, + "x": 680, + "y": 180 + }, + { + "blockId": 25, + "x": 1020, + "y": 180 + }, + { + "blockId": 26, + "x": 1360, + "y": 0 + }, + { + "blockId": 27, + "x": 1020, + "y": 360 + }, + { + "blockId": 28, + "x": 1360, + "y": 180 + }, + { + "blockId": 29, + "x": 1700, + "y": 180 + }, + { + "blockId": 30, + "x": 2040, + "y": 180 + }, + { + "blockId": 31, + "x": 2380, + "y": 180 + }, + { + "blockId": 32, + "x": 2040, + "y": 0 + }, + { + "blockId": 33, + "x": 2380, + "y": 0 + }, + { + "blockId": 34, + "x": 2720, + "y": 0 + }, + { + "blockId": 35, + "x": 3060, + "y": 0 + }, + { + "blockId": 36, + "x": 3400, + "y": 0 + }, + { + "blockId": 37, + "x": 3400, + "y": 180 + }, + { + "blockId": 38, + "x": 3740, + "y": 0 + }, + { + "blockId": 39, + "x": 4080, + "y": 0 + }, + { + "blockId": 40, + "x": 4420, + "y": 0 + }, + { + "blockId": 41, + "x": 3060, + "y": 180 + }, + { + "blockId": 42, + "x": 3400, + "y": 360 + }, + { + "blockId": 43, + "x": 3740, + "y": 180 + }, + { + "blockId": 44, + "x": 4080, + "y": 180 + }, + { + "blockId": 45, + "x": 4420, + "y": 180 + }, + { + "blockId": 46, + "x": 3740, + "y": 360 + }, + { + "blockId": 47, + "x": 4080, + "y": 360 + }, + { + "blockId": 48, + "x": 4420, + "y": 360 + }, + { + "blockId": 49, + "x": 4760, + "y": 540 + }, + { + "blockId": 50, + "x": 5100, + "y": 360 + }, + { + "blockId": 51, + "x": 5100, + "y": 540 + }, + { + "blockId": 52, + "x": 5440, + "y": 360 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-purple-fire-bricks.json b/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-purple-fire-bricks.json new file mode 100644 index 00000000000..066a3c439b7 --- /dev/null +++ b/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-purple-fire-bricks.json @@ -0,0 +1,1866 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "number[]" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "number[]" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "world", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "worldViewProjection", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "time", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 1, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "scrollSpeed", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.5, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "noiseScale", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 5, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 8, + "name": "distortStrength", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.15, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 9, + "name": "brickCountX", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 6, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 10, + "name": "brickCountY", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 12, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 11, + "name": "mortarWidth", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.08, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 12, + "name": "oneConst", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 13, + "name": "zeroConst", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 14, + "name": "twoConst", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 2, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 15, + "name": "gradientPower", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 2, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 16, + "name": "fireBias", + "target": 4, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.3, + "valueType": "number" + }, + { + "customType": "BABYLON.TransformBlock", + "id": 17, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 3, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 18, + "name": "clipPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 17, + "targetConnectionName": "xyz" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 19, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 18, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 20, + "name": "splitUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 21, + "name": "scaledU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 20, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 22, + "name": "scaledV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 20, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 10, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 23, + "name": "floorV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 22, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 6 + }, + { + "customType": "BABYLON.DivideBlock", + "id": 24, + "name": "rowHalf", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 23, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 14, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 25, + "name": "fractRowHalf", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 24, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.AddBlock", + "id": 26, + "name": "offsetU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 21, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 25, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 27, + "name": "brickX", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 26, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.TrigonometryBlock", + "id": 28, + "name": "brickY", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 22, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 14 + }, + { + "customType": "BABYLON.SmoothStepBlock", + "id": 29, + "name": "mXlo", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 27, + "targetConnectionName": "output" + }, + { + "name": "edge0", + "displayName": "edge0", + "inputName": "edge0", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "edge1", + "displayName": "edge1", + "inputName": "edge1", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.OneMinusBlock", + "id": 30, + "name": "invBrickX", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 27, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SmoothStepBlock", + "id": 31, + "name": "mXhi", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 30, + "targetConnectionName": "output" + }, + { + "name": "edge0", + "displayName": "edge0", + "inputName": "edge0", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "edge1", + "displayName": "edge1", + "inputName": "edge1", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 32, + "name": "mortarX", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 29, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 31, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SmoothStepBlock", + "id": 33, + "name": "mYlo", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 28, + "targetConnectionName": "output" + }, + { + "name": "edge0", + "displayName": "edge0", + "inputName": "edge0", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "edge1", + "displayName": "edge1", + "inputName": "edge1", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.OneMinusBlock", + "id": 34, + "name": "invBrickY", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 28, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SmoothStepBlock", + "id": 35, + "name": "mYhi", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 34, + "targetConnectionName": "output" + }, + { + "name": "edge0", + "displayName": "edge0", + "inputName": "edge0", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "edge1", + "displayName": "edge1", + "inputName": "edge1", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 36, + "name": "mortarY", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 33, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 35, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 37, + "name": "brickMask", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 32, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 36, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 38, + "name": "timeScale", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 5, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 6, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SubtractBlock", + "id": 39, + "name": "scrollY", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 20, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 38, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 40, + "name": "scrolledUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 20, + "targetConnectionName": "x" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 39, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 38, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 41, + "name": "scaleNoise", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 40, + "targetConnectionName": "xyz" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 7, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 42, + "name": "noise", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 41, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 43, + "name": "distort", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 42, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 44, + "name": "distortedU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 20, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 43, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 45, + "name": "distUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 44, + "targetConnectionName": "output" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 39, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 38, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 46, + "name": "fireNoise", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 45, + "targetConnectionName": "xyz" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 47, + "name": "biasedFlame", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 46, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 16, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ClampBlock", + "id": 48, + "name": "clampedFlame", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 47, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "minimum": 0, + "maximum": 1 + }, + { + "customType": "BABYLON.GradientBlock", + "id": 49, + "name": "fireGradient", + "target": 4, + "inputs": [ + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 48, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "colorSteps": [ + { + "step": 0, + "color": { + "r": 0.05, + "g": 0, + "b": 0.1 + } + }, + { + "step": 0.3, + "color": { + "r": 0.4, + "g": 0, + "b": 0.6 + } + }, + { + "step": 0.6, + "color": { + "r": 0.7, + "g": 0.2, + "b": 1 + } + }, + { + "step": 0.85, + "color": { + "r": 1, + "g": 0.7, + "b": 1 + } + }, + { + "step": 1, + "color": { + "r": 1, + "g": 0.95, + "b": 1 + } + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 50, + "name": "maskedColor", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 49, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 37, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 51, + "name": "maskedAlpha", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 48, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 37, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 52, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 50, + "targetConnectionName": "output" + }, + { + "name": "a", + "displayName": "a", + "inputName": "a", + "targetBlockId": 51, + "targetConnectionName": "output" + } + ], + "outputs": [] + } + ], + "outputNodes": [ + 19, + 52 + ], + "comment": "Purple fire animation masked by a procedural brick pattern - fire shows on brick faces, mortar lines are dark", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 4080, + "y": 0 + }, + { + "blockId": 2, + "x": 340, + "y": 360 + }, + { + "blockId": 3, + "x": 4080, + "y": 180 + }, + { + "blockId": 4, + "x": 4420, + "y": 0 + }, + { + "blockId": 5, + "x": 340, + "y": 0 + }, + { + "blockId": 6, + "x": 340, + "y": 180 + }, + { + "blockId": 7, + "x": 1360, + "y": 0 + }, + { + "blockId": 8, + "x": 2040, + "y": 0 + }, + { + "blockId": 9, + "x": 2040, + "y": 360 + }, + { + "blockId": 10, + "x": 1020, + "y": 180 + }, + { + "blockId": 11, + "x": 3400, + "y": 540 + }, + { + "blockId": 12, + "x": 0, + "y": 0 + }, + { + "blockId": 13, + "x": 3400, + "y": 720 + }, + { + "blockId": 14, + "x": 1700, + "y": 180 + }, + { + "blockId": 15, + "x": 0, + "y": 180 + }, + { + "blockId": 16, + "x": 3400, + "y": 0 + }, + { + "blockId": 17, + "x": 4420, + "y": 180 + }, + { + "blockId": 18, + "x": 4760, + "y": 0 + }, + { + "blockId": 19, + "x": 5100, + "y": 0 + }, + { + "blockId": 20, + "x": 680, + "y": 180 + }, + { + "blockId": 21, + "x": 2380, + "y": 180 + }, + { + "blockId": 22, + "x": 1360, + "y": 360 + }, + { + "blockId": 23, + "x": 1700, + "y": 360 + }, + { + "blockId": 24, + "x": 2040, + "y": 540 + }, + { + "blockId": 25, + "x": 2380, + "y": 360 + }, + { + "blockId": 26, + "x": 2720, + "y": 180 + }, + { + "blockId": 27, + "x": 3060, + "y": 180 + }, + { + "blockId": 28, + "x": 3060, + "y": 360 + }, + { + "blockId": 29, + "x": 3740, + "y": 180 + }, + { + "blockId": 30, + "x": 3400, + "y": 360 + }, + { + "blockId": 31, + "x": 3740, + "y": 360 + }, + { + "blockId": 32, + "x": 4080, + "y": 540 + }, + { + "blockId": 33, + "x": 3740, + "y": 540 + }, + { + "blockId": 34, + "x": 3400, + "y": 900 + }, + { + "blockId": 35, + "x": 3740, + "y": 720 + }, + { + "blockId": 36, + "x": 4080, + "y": 720 + }, + { + "blockId": 37, + "x": 4420, + "y": 540 + }, + { + "blockId": 38, + "x": 680, + "y": 0 + }, + { + "blockId": 39, + "x": 1020, + "y": 0 + }, + { + "blockId": 40, + "x": 1360, + "y": 180 + }, + { + "blockId": 41, + "x": 1700, + "y": 0 + }, + { + "blockId": 42, + "x": 2040, + "y": 180 + }, + { + "blockId": 43, + "x": 2380, + "y": 0 + }, + { + "blockId": 44, + "x": 2720, + "y": 0 + }, + { + "blockId": 45, + "x": 3060, + "y": 0 + }, + { + "blockId": 46, + "x": 3400, + "y": 180 + }, + { + "blockId": 47, + "x": 3740, + "y": 0 + }, + { + "blockId": 48, + "x": 4080, + "y": 360 + }, + { + "blockId": 49, + "x": 4420, + "y": 360 + }, + { + "blockId": 50, + "x": 4760, + "y": 180 + }, + { + "blockId": 51, + "x": 4760, + "y": 360 + }, + { + "blockId": 52, + "x": 5100, + "y": 180 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-purple-fire.json b/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-purple-fire.json new file mode 100644 index 00000000000..9772d3f5552 --- /dev/null +++ b/packages/tools/nme-mcp-server/test/unit/fixtures/roundtrip-purple-fire.json @@ -0,0 +1,1388 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "normal", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 3, + "name": "uv", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 4, + "value": [ + 0, + 0 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.InputBlock", + "id": 4, + "name": "world", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 1, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "worldViewProjection", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 6, + "type": 128, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.InputBlock", + "id": 6, + "name": "cameraPosition", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "systemValue": 7, + "type": 8, + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 7, + "name": "time", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 1, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 8, + "name": "scrollSpeed", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1.5, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 9, + "name": "noiseScale", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 3, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 10, + "name": "distortStrength", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.15, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 11, + "name": "alphaEdgeHigh", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0.85, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 12, + "name": "oneConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 1, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 13, + "name": "zeroConst", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": true, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 0, + "valueType": "number" + }, + { + "customType": "BABYLON.InputBlock", + "id": 14, + "name": "gradientPower", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 1, + "value": 2, + "valueType": "number" + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 15, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 18, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 16, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 34, + "targetConnectionName": "output" + }, + { + "name": "a", + "displayName": "a", + "inputName": "a", + "targetBlockId": 36, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.TransformBlock", + "id": 17, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.TransformBlock", + "id": 18, + "name": "clipPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 17, + "targetConnectionName": "xyz" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 19, + "name": "splitUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 20, + "name": "timeScale", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SubtractBlock", + "id": 21, + "name": "scrollUV_Y", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 19, + "targetConnectionName": "y" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 20, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 22, + "name": "scrolledUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 19, + "targetConnectionName": "x" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 21, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 20, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 23, + "name": "scaleUV", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 22, + "targetConnectionName": "xyz" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 24, + "name": "noise", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 23, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.MultiplyBlock", + "id": 25, + "name": "distortAmount", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 24, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 10, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 26, + "name": "distortedU", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 19, + "targetConnectionName": "x" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 25, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorMergerBlock", + "id": 27, + "name": "distortedUV", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x", + "inputName": "x", + "targetBlockId": 26, + "targetConnectionName": "output" + }, + { + "name": "y", + "displayName": "y", + "inputName": "y", + "targetBlockId": 21, + "targetConnectionName": "output" + }, + { + "name": "z", + "displayName": "z", + "inputName": "z", + "targetBlockId": 20, + "targetConnectionName": "output" + }, + { + "name": "w", + "displayName": "w" + } + ], + "outputs": [ + { + "name": "xyzw", + "displayName": "xyzw" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + } + ], + "xSwizzle": "x", + "ySwizzle": "y", + "zSwizzle": "z", + "wSwizzle": "w" + }, + { + "customType": "BABYLON.SimplexPerlin3DBlock", + "id": 28, + "name": "fireNoise", + "target": 4, + "inputs": [ + { + "name": "seed", + "displayName": "seed", + "inputName": "seed", + "targetBlockId": 27, + "targetConnectionName": "xyz" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.VectorSplitterBlock", + "id": 29, + "name": "splitUV2", + "target": 4, + "inputs": [ + { + "name": "xyzw", + "displayName": "xyzw", + "inputName": "xyzw", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + } + ], + "outputs": [ + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "xy", + "displayName": "xy" + }, + { + "name": "zw", + "displayName": "zw" + }, + { + "name": "x", + "displayName": "x" + }, + { + "name": "y", + "displayName": "y" + }, + { + "name": "z", + "displayName": "z" + }, + { + "name": "w", + "displayName": "w" + } + ] + }, + { + "customType": "BABYLON.OneMinusBlock", + "id": 30, + "name": "invertV", + "target": 4, + "inputs": [ + { + "name": "input", + "displayName": "input", + "inputName": "input", + "targetBlockId": 29, + "targetConnectionName": "y" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SubtractBlock", + "id": 31, + "name": "flameShape", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 30, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 12, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.AddBlock", + "id": 32, + "name": "rawFlame", + "target": 4, + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 28, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 31, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ClampBlock", + "id": 33, + "name": "clampedFlame", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 32, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "minimum": 0, + "maximum": 1 + }, + { + "customType": "BABYLON.GradientBlock", + "id": 34, + "name": "fireGradient", + "target": 4, + "inputs": [ + { + "name": "gradient", + "displayName": "gradient", + "inputName": "gradient", + "targetBlockId": 33, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "colorSteps": [ + { + "step": 0, + "color": { + "r": 0, + "g": 0.05, + "b": 0 + } + }, + { + "step": 0.3, + "color": { + "r": 0, + "g": 0.4, + "b": 0.05 + } + }, + { + "step": 0.6, + "color": { + "r": 0.2, + "g": 0.8, + "b": 0.1 + } + }, + { + "step": 0.85, + "color": { + "r": 0.7, + "g": 1, + "b": 0.3 + } + }, + { + "step": 1, + "color": { + "r": 0.95, + "g": 1, + "b": 0.9 + } + } + ] + }, + { + "customType": "BABYLON.PowBlock", + "id": 35, + "name": "alphaShape", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 33, + "targetConnectionName": "output" + }, + { + "name": "power", + "displayName": "power", + "inputName": "power", + "targetBlockId": 14, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.SmoothStepBlock", + "id": 36, + "name": "alphaSmooth", + "target": 4, + "inputs": [ + { + "name": "value", + "displayName": "value", + "inputName": "value", + "targetBlockId": 35, + "targetConnectionName": "output" + }, + { + "name": "edge0", + "displayName": "edge0", + "inputName": "edge0", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "edge1", + "displayName": "edge1", + "inputName": "edge1", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + } + ], + "outputNodes": [ + 15, + 16 + ], + "comment": "Animated procedural purple fire material", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 4080, + "y": 0 + }, + { + "blockId": 2, + "x": 0, + "y": 0 + }, + { + "blockId": 3, + "x": 340, + "y": 0 + }, + { + "blockId": 4, + "x": 4080, + "y": 180 + }, + { + "blockId": 5, + "x": 4420, + "y": 0 + }, + { + "blockId": 6, + "x": 0, + "y": 180 + }, + { + "blockId": 7, + "x": 340, + "y": 180 + }, + { + "blockId": 8, + "x": 340, + "y": 360 + }, + { + "blockId": 9, + "x": 1360, + "y": 0 + }, + { + "blockId": 10, + "x": 2040, + "y": 0 + }, + { + "blockId": 11, + "x": 4420, + "y": 360 + }, + { + "blockId": 12, + "x": 3060, + "y": 180 + }, + { + "blockId": 13, + "x": 4420, + "y": 540 + }, + { + "blockId": 14, + "x": 4080, + "y": 540 + }, + { + "blockId": 15, + "x": 5100, + "y": 0 + }, + { + "blockId": 16, + "x": 5100, + "y": 180 + }, + { + "blockId": 17, + "x": 4420, + "y": 180 + }, + { + "blockId": 18, + "x": 4760, + "y": 0 + }, + { + "blockId": 19, + "x": 680, + "y": 0 + }, + { + "blockId": 20, + "x": 680, + "y": 180 + }, + { + "blockId": 21, + "x": 1020, + "y": 0 + }, + { + "blockId": 22, + "x": 1360, + "y": 180 + }, + { + "blockId": 23, + "x": 1700, + "y": 0 + }, + { + "blockId": 24, + "x": 2040, + "y": 180 + }, + { + "blockId": 25, + "x": 2380, + "y": 0 + }, + { + "blockId": 26, + "x": 2720, + "y": 0 + }, + { + "blockId": 27, + "x": 3060, + "y": 0 + }, + { + "blockId": 28, + "x": 3400, + "y": 0 + }, + { + "blockId": 29, + "x": 2720, + "y": 180 + }, + { + "blockId": 30, + "x": 3060, + "y": 360 + }, + { + "blockId": 31, + "x": 3400, + "y": 180 + }, + { + "blockId": 32, + "x": 3740, + "y": 0 + }, + { + "blockId": 33, + "x": 4080, + "y": 360 + }, + { + "blockId": 34, + "x": 4760, + "y": 180 + }, + { + "blockId": 35, + "x": 4420, + "y": 720 + }, + { + "blockId": 36, + "x": 4760, + "y": 360 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/test/unit/fixtures/simpleColor.json b/packages/tools/nme-mcp-server/test/unit/fixtures/simpleColor.json new file mode 100644 index 00000000000..bf85676e955 --- /dev/null +++ b/packages/tools/nme-mcp-server/test/unit/fixtures/simpleColor.json @@ -0,0 +1,229 @@ +{ + "ignoreAlpha": false, + "maxSimultaneousLights": 4, + "mode": 0, + "forceAlphaBlending": false, + "blocks": [ + { + "customType": "BABYLON.InputBlock", + "id": 1, + "name": "position", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 1, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 8, + "attributeName": "position", + "value": [ + 0, + 0, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.InputBlock", + "id": 2, + "name": "worldViewProjection", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 128, + "systemValue": 6, + "value": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ], + "valueType": "BABYLON.Matrix" + }, + { + "customType": "BABYLON.TransformBlock", + "id": 3, + "name": "worldPos", + "target": 4, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "transform", + "displayName": "transform", + "inputName": "transform", + "targetBlockId": 2, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + }, + { + "name": "xyz", + "displayName": "xyz" + }, + { + "name": "w", + "displayName": "w" + } + ], + "complementZ": 0, + "complementW": 1 + }, + { + "customType": "BABYLON.VertexOutputBlock", + "id": 4, + "name": "vertexOutput", + "target": 1, + "inputs": [ + { + "name": "vector", + "displayName": "vector", + "inputName": "vector", + "targetBlockId": 3, + "targetConnectionName": "output" + } + ], + "outputs": [] + }, + { + "customType": "BABYLON.InputBlock", + "id": 5, + "name": "color", + "target": 1, + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "mode": 0, + "min": 0, + "max": 0, + "isBoolean": false, + "matrixMode": 0, + "isConstant": false, + "groupInInspector": "", + "convertToGammaSpace": false, + "convertToLinearSpace": false, + "animationType": 0, + "type": 32, + "value": [ + 1, + 0, + 0 + ], + "valueType": "BABYLON.Color3" + }, + { + "customType": "BABYLON.FragmentOutputBlock", + "id": 6, + "name": "fragmentOutput", + "target": 2, + "inputs": [ + { + "name": "rgba", + "displayName": "rgba" + }, + { + "name": "rgb", + "displayName": "rgb", + "inputName": "rgb", + "targetBlockId": 5, + "targetConnectionName": "output" + }, + { + "name": "a", + "displayName": "a" + } + ], + "outputs": [] + } + ], + "outputNodes": [ + 4, + 6 + ], + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 0, + "y": 0 + }, + { + "blockId": 2, + "x": 0, + "y": 180 + }, + { + "blockId": 3, + "x": 340, + "y": 0 + }, + { + "blockId": 4, + "x": 680, + "y": 0 + }, + { + "blockId": 5, + "x": 340, + "y": 180 + }, + { + "blockId": 6, + "x": 680, + "y": 180 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/nme-mcp-server/test/unit/graphManager.test.ts b/packages/tools/nme-mcp-server/test/unit/graphManager.test.ts new file mode 100644 index 00000000000..33e9298332f --- /dev/null +++ b/packages/tools/nme-mcp-server/test/unit/graphManager.test.ts @@ -0,0 +1,373 @@ +/** + * Node Material MCP Server – Output Validation Tests + * + * Creates materials via MaterialGraphManager, exports them to JSON, validates + * the JSON structure, and round-trips sample materials. + * + * Run with: npx jest --testPathPattern nme-mcp-server + * (or just run all unit tests) + */ + +import { MaterialGraphManager } from "../../src/materialGraph"; +import { BlockRegistry } from "../../src/blockRegistry"; +import * as fs from "fs"; +import * as path from "path"; + +const SAMPLE_DIR = path.resolve(__dirname, "../.."); + +// ─── Test Helpers ───────────────────────────────────────────────────────── + +function validateMaterialJSON(json: string, label: string): object | null { + let parsed: any; + try { + parsed = JSON.parse(json); + } catch { + throw new Error(`${label}: invalid JSON`); + } + + // Required top-level fields + expect(typeof parsed.ignoreAlpha).toBe("boolean"); + expect(typeof parsed.maxSimultaneousLights).toBe("number"); + expect(typeof parsed.mode).toBe("number"); + expect(typeof parsed.forceAlphaBlending).toBe("boolean"); + expect(Array.isArray(parsed.blocks)).toBe(true); + expect(Array.isArray(parsed.outputNodes)).toBe(true); + + if (!Array.isArray(parsed.blocks)) return null; + + // Each block must have required fields + const allIds = new Set(parsed.blocks.map((b: any) => b.id)); + for (const block of parsed.blocks) { + expect(typeof block.customType).toBe("string"); + expect(block.customType.startsWith("BABYLON.")).toBe(true); + expect(typeof block.id).toBe("number"); + expect(typeof block.name).toBe("string"); + expect(Array.isArray(block.inputs)).toBe(true); + expect(Array.isArray(block.outputs)).toBe(true); + + // Validate connections reference existing block IDs + for (const inp of block.inputs) { + if (inp.targetBlockId !== undefined) { + expect(allIds.has(inp.targetBlockId)).toBe(true); + expect(typeof inp.targetConnectionName).toBe("string"); + } + } + + // InputBlock-specific validations + if (block.customType === "BABYLON.InputBlock") { + expect(block.type).toBeDefined(); + expect(typeof block.mode).toBe("number"); + } + } + + // Output nodes reference valid blocks + for (const outId of parsed.outputNodes) { + expect(allIds.has(outId)).toBe(true); + } + + return parsed; +} + +// ─── Test 1: Create a simple color material ─────────────────────────────── + +describe("Node Material MCP Server – Graph Manager Validation", () => { + it("creates and exports a simple color material with valid JSON", () => { + const mgr = new MaterialGraphManager(); + mgr.createMaterial("simpleColor"); + + // Add blocks + const pos = mgr.addBlock("simpleColor", "InputBlock", "position", { type: "Vector3", mode: "Attribute", attributeName: "position" }); + expect(typeof pos).not.toBe("string"); + + const wvp = mgr.addBlock("simpleColor", "InputBlock", "worldViewProjection", { type: "Matrix", systemValue: "WorldViewProjection" }); + expect(typeof wvp).not.toBe("string"); + + const vtx = mgr.addBlock("simpleColor", "TransformBlock", "worldPos"); + expect(typeof vtx).not.toBe("string"); + + const vertOut = mgr.addBlock("simpleColor", "VertexOutputBlock", "vertexOutput"); + expect(typeof vertOut).not.toBe("string"); + + const color = mgr.addBlock("simpleColor", "InputBlock", "color", { type: "Color3", value: [1, 0, 0] }); + expect(typeof color).not.toBe("string"); + + const fragOut = mgr.addBlock("simpleColor", "FragmentOutputBlock", "fragmentOutput"); + expect(typeof fragOut).not.toBe("string"); + + // Get block IDs + const getId = (r: any) => (typeof r !== "string" ? r.block.id : -1); + + // Connect: position → TransformBlock.vector, wvp → TransformBlock.transform + let result = mgr.connectBlocks("simpleColor", getId(pos), "output", getId(vtx), "vector"); + expect(result).toBe("OK"); + + result = mgr.connectBlocks("simpleColor", getId(wvp), "output", getId(vtx), "transform"); + expect(result).toBe("OK"); + + // Connect: TransformBlock.output → VertexOutputBlock.vector + result = mgr.connectBlocks("simpleColor", getId(vtx), "output", getId(vertOut), "vector"); + expect(result).toBe("OK"); + + // Connect: color → FragmentOutputBlock.rgb + result = mgr.connectBlocks("simpleColor", getId(color), "output", getId(fragOut), "rgb"); + expect(result).toBe("OK"); + + // Export and validate + const json = mgr.exportJSON("simpleColor"); + expect(json).toBeDefined(); + if (json) { + validateMaterialJSON(json, "simpleColor"); + + // Write to fixture for the Babylon.js parse test + const outDir = path.resolve(__dirname, "fixtures"); + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, "simpleColor.json"), json); + } + }); + + // ─── Test 2: Create a PBR material ───────────────────────────────────── + + it("creates and exports a PBR material with valid JSON", () => { + const mgr = new MaterialGraphManager(); + mgr.createMaterial("pbrTest"); + + // Required vertex setup + const pos = mgr.addBlock("pbrTest", "InputBlock", "position", { type: "Vector3", mode: "Attribute", attributeName: "position" }); + const normal = mgr.addBlock("pbrTest", "InputBlock", "normal", { type: "Vector3", mode: "Attribute", attributeName: "normal" }); + const wvp = mgr.addBlock("pbrTest", "InputBlock", "worldViewProjection", { type: "Matrix", systemValue: "WorldViewProjection" }); + const world = mgr.addBlock("pbrTest", "InputBlock", "world", { type: "Matrix", systemValue: "World" }); + const view = mgr.addBlock("pbrTest", "InputBlock", "view", { type: "Matrix", systemValue: "View" }); + const cameraPos = mgr.addBlock("pbrTest", "InputBlock", "cameraPosition", { type: "Vector3", systemValue: "CameraPosition" }); + + const vtx = mgr.addBlock("pbrTest", "TransformBlock", "worldPos"); + const vertOut = mgr.addBlock("pbrTest", "VertexOutputBlock", "vertexOutput"); + const pbr = mgr.addBlock("pbrTest", "PBRMetallicRoughnessBlock", "PBR"); + const fragOut = mgr.addBlock("pbrTest", "FragmentOutputBlock", "fragOutput"); + + // Colors + const baseColor = mgr.addBlock("pbrTest", "InputBlock", "baseColor", { type: "Color3", value: [0.8, 0.2, 0.1] }); + const metallic = mgr.addBlock("pbrTest", "InputBlock", "metallic", { type: "Float", value: 0.0 }); + const roughness = mgr.addBlock("pbrTest", "InputBlock", "roughness", { type: "Float", value: 0.5 }); + + // Validate all blocks created + for (const result of [pos, normal, wvp, world, view, cameraPos, vtx, vertOut, pbr, fragOut, baseColor, metallic, roughness]) { + expect(typeof result).not.toBe("string"); + } + + const getId = (r: any) => (typeof r !== "string" ? r.block.id : -1); + + // Vertex connections + mgr.connectBlocks("pbrTest", getId(pos), "output", getId(vtx), "vector"); + mgr.connectBlocks("pbrTest", getId(wvp), "output", getId(vtx), "transform"); + mgr.connectBlocks("pbrTest", getId(vtx), "output", getId(vertOut), "vector"); + + // PBR connections + mgr.connectBlocks("pbrTest", getId(vtx), "output", getId(pbr), "worldPosition"); + mgr.connectBlocks("pbrTest", getId(normal), "output", getId(pbr), "worldNormal"); + mgr.connectBlocks("pbrTest", getId(view), "output", getId(pbr), "view"); + mgr.connectBlocks("pbrTest", getId(cameraPos), "output", getId(pbr), "cameraPosition"); + mgr.connectBlocks("pbrTest", getId(baseColor), "output", getId(pbr), "baseColor"); + mgr.connectBlocks("pbrTest", getId(metallic), "output", getId(pbr), "metallic"); + mgr.connectBlocks("pbrTest", getId(roughness), "output", getId(pbr), "roughness"); + + // PBR → fragment output + const connectResult = mgr.connectBlocks("pbrTest", getId(pbr), "lighting", getId(fragOut), "rgb"); + expect(connectResult).toBe("OK"); + + const json = mgr.exportJSON("pbrTest"); + expect(json).toBeDefined(); + if (json) { + validateMaterialJSON(json, "pbrTest"); + const outDir = path.resolve(__dirname, "fixtures"); + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, "pbrMaterial.json"), json); + } + }); + + // ─── Test 3: Round-trip sample materials ────────────────────────────── + + const sampleFiles = ["bricks.json", "grass.json", "psychedelic.json", "purple-fire.json", "purple-fire-bricks.json"]; + + for (const file of sampleFiles) { + it(`round-trips sample material: ${file}`, () => { + const filePath = path.join(SAMPLE_DIR, file); + if (!fs.existsSync(filePath)) { + return; // skip if missing + } + + const rawJson = fs.readFileSync(filePath, "utf-8"); + const mgr = new MaterialGraphManager(); + const materialName = path.basename(file, ".json"); + + // Import + const result = mgr.importJSON(materialName, rawJson); + expect(result).not.toMatch(/^Error/); + + // Export + const exported = mgr.exportJSON(materialName); + expect(exported).toBeDefined(); + + if (exported) { + // Validate structure + const parsed = validateMaterialJSON(exported, `roundtrip(${file})`); + if (parsed) { + const original = JSON.parse(rawJson); + // Block count should be preserved + expect((parsed as any).blocks.length).toBe(original.blocks.length); + // Output nodes preserved + expect((parsed as any).outputNodes.length).toBe(original.outputNodes.length); + } + + // Write for the Babylon.js parse test + const outDir = path.resolve(__dirname, "fixtures"); + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, `roundtrip-${file}`), exported); + } + }); + } + + it("rejects invalid material JSON on import", () => { + const mgr = new MaterialGraphManager(); + + expect(mgr.importJSON("bad", '{"blocks":[],"outputNodes":[]}')).toContain("Invalid NME JSON"); + expect(mgr.importJSON("bad", "not json")).toContain("Invalid NME JSON: parse error."); + }); + + // ─── Test 4: Block registry coverage ────────────────────────────────── + + it("every block type can be instantiated with correct customType and port counts", () => { + const mgr = new MaterialGraphManager(); + + let blockTypesTested = 0; + + for (const [key, info] of Object.entries(BlockRegistry)) { + // Skip output blocks (they need special handling) and InputBlock + if (key === "VertexOutputBlock" || key === "FragmentOutputBlock" || key === "InputBlock") { + continue; + } + + mgr.createMaterial(`test_${key}`); + const result = mgr.addBlock(`test_${key}`, key, `testBlock`); + + expect(typeof result).not.toBe("string"); + if (typeof result !== "string") { + expect(result.block.customType).toBe(`BABYLON.${info.className}`); + expect(result.block.inputs.length).toBe(info.inputs.length); + expect(result.block.outputs.length).toBe(info.outputs.length); + blockTypesTested++; + } + } + + // Ensure we tested a meaningful number of blocks + expect(blockTypesTested).toBeGreaterThan(100); + }); + + // ─── Test 5: Validate material ────────────────────────────────────── + + it("validate_material catches missing output blocks and passes for connected materials", () => { + const mgr = new MaterialGraphManager(); + mgr.createMaterial("validateTest"); + + // Empty material should have warnings about missing output blocks + const emptyResult = mgr.validateMaterial("validateTest"); + expect(emptyResult.length).toBeGreaterThan(0); + + // Build a valid minimal material + mgr.addBlock("validateTest", "InputBlock", "position", { type: "Vector3", mode: "Attribute", attributeName: "position" }); + mgr.addBlock("validateTest", "InputBlock", "wvp", { type: "Matrix", systemValue: "WorldViewProjection" }); + mgr.addBlock("validateTest", "TransformBlock", "transform"); + mgr.addBlock("validateTest", "VertexOutputBlock", "vertOut"); + mgr.addBlock("validateTest", "InputBlock", "color", { type: "Color3", value: [1, 0, 0] }); + mgr.addBlock("validateTest", "FragmentOutputBlock", "fragOut"); + + mgr.connectBlocks("validateTest", 1, "output", 3, "vector"); + mgr.connectBlocks("validateTest", 2, "output", 3, "transform"); + mgr.connectBlocks("validateTest", 3, "output", 4, "vector"); + mgr.connectBlocks("validateTest", 5, "output", 6, "rgb"); + + const validResult = mgr.validateMaterial("validateTest"); + // Should have no 'Missing' critical warnings + const criticalWarnings = validResult.filter((w) => w.includes("Missing")); + expect(criticalWarnings.length).toBe(0); + }); + + it("converts enum string properties to numeric values", () => { + const mgr = new MaterialGraphManager(); + mgr.createMaterial("enumTest"); + + // TrigonometryBlock with string "Sin" should become numeric 1 + const trig = mgr.addBlock("enumTest", "TrigonometryBlock", "sinBlock"); + expect(typeof trig).not.toBe("string"); + const trigId = (trig as any).block.id; + const result = mgr.setBlockProperties("enumTest", trigId, { operation: "Sin" }); + expect(result).toBe("OK"); + + // Export and check the operation is numeric + // (need enough blocks for valid export — just add output blocks too) + mgr.addBlock("enumTest", "InputBlock", "pos", { type: "Vector3", mode: "Attribute", attributeName: "position" }); + mgr.addBlock("enumTest", "InputBlock", "wvp", { type: "Matrix", systemValue: "WorldViewProjection" }); + mgr.addBlock("enumTest", "TransformBlock", "transform"); + mgr.addBlock("enumTest", "VertexOutputBlock", "vertOut"); + mgr.addBlock("enumTest", "FragmentOutputBlock", "fragOut"); + mgr.addBlock("enumTest", "InputBlock", "color", { type: "Color3", value: [1, 0, 0] }); + mgr.connectBlocks("enumTest", 2, "output", 4, "vector"); + mgr.connectBlocks("enumTest", 3, "output", 4, "transform"); + mgr.connectBlocks("enumTest", 4, "output", 5, "vector"); + mgr.connectBlocks("enumTest", 7, "output", 6, "rgb"); + + const json = mgr.exportJSON("enumTest"); + expect(json).toBeDefined(); + const parsed = JSON.parse(json!); + const trigBlock = parsed.blocks.find((b: any) => b.name === "sinBlock"); + expect(trigBlock).toBeDefined(); + expect(trigBlock.operation).toBe(1); // Sin = 1 + + // Also verify ConditionalBlock + const mgr2 = new MaterialGraphManager(); + mgr2.createMaterial("condTest"); + const cond = mgr2.addBlock("condTest", "ConditionalBlock", "condBlock"); + const condId = (cond as any).block.id; + mgr2.setBlockProperties("condTest", condId, { condition: "GreaterThan" }); + // Quick export to trigger normalization + mgr2.addBlock("condTest", "InputBlock", "pos", { type: "Vector3", mode: "Attribute", attributeName: "position" }); + mgr2.addBlock("condTest", "InputBlock", "wvp", { type: "Matrix", systemValue: "WorldViewProjection" }); + mgr2.addBlock("condTest", "TransformBlock", "transform"); + mgr2.addBlock("condTest", "VertexOutputBlock", "vertOut"); + mgr2.addBlock("condTest", "FragmentOutputBlock", "fragOut"); + mgr2.addBlock("condTest", "InputBlock", "color", { type: "Color3", value: [1, 0, 0] }); + mgr2.connectBlocks("condTest", 2, "output", 4, "vector"); + mgr2.connectBlocks("condTest", 3, "output", 4, "transform"); + mgr2.connectBlocks("condTest", 4, "output", 5, "vector"); + mgr2.connectBlocks("condTest", 7, "output", 6, "rgb"); + + const json2 = mgr2.exportJSON("condTest"); + const parsed2 = JSON.parse(json2!); + const condBlock = parsed2.blocks.find((b: any) => b.name === "condBlock"); + expect(condBlock).toBeDefined(); + expect(condBlock.condition).toBe(3); // GreaterThan = 3 + }); + + // ── clearAll ──────────────────────────────────────────────────────── + + it("clearAll removes all materials and resets state", () => { + const mgr = new MaterialGraphManager(); + mgr.createMaterial("a"); + mgr.createMaterial("b"); + expect(mgr.listMaterials().length).toBe(2); + + mgr.clearAll(); + expect(mgr.listMaterials()).toEqual([]); + expect(mgr.getMaterial("a")).toBeUndefined(); + expect(mgr.getMaterial("b")).toBeUndefined(); + + // Can create new materials after clear + mgr.createMaterial("c"); + expect(mgr.listMaterials()).toEqual(["c"]); + }); + + it("clearAll on empty manager is a no-op", () => { + const mgr = new MaterialGraphManager(); + mgr.clearAll(); + expect(mgr.listMaterials()).toEqual([]); + }); +}); diff --git a/packages/tools/nme-mcp-server/test/unit/nmeParse.test.ts b/packages/tools/nme-mcp-server/test/unit/nmeParse.test.ts new file mode 100644 index 00000000000..c00dc6d3c49 --- /dev/null +++ b/packages/tools/nme-mcp-server/test/unit/nmeParse.test.ts @@ -0,0 +1,325 @@ +/** + * Node Material MCP Server – Babylon.js Parse Validation + * + * Tests that JSON produced by the Node Material MCP server can be parsed by Babylon.js's + * NodeMaterial.Parse() without errors. This is the definitive end-to-end test: + * if Parse() + build() succeeds, the material is valid for the NME editor. + */ +import { NullEngine } from "core/Engines"; +import { Scene } from "core/scene"; +import { NodeMaterial } from "core/Materials/Node/nodeMaterial"; + +// Side-effect imports to register ALL NME block types via RegisterClass +import "core/Materials/Node/Blocks/index"; + +import * as fs from "fs"; +import * as path from "path"; + +const NME_SERVER_DIR = path.resolve(__dirname, "../.."); + +/** + * Helper: read a JSON file relative to the nme-mcp-server directory. + */ +function readNmeJson(relativePath: string): string { + const fullPath = path.resolve(NME_SERVER_DIR, relativePath); + return fs.readFileSync(fullPath, "utf-8"); +} + +describe("Node Material MCP Server – Babylon.js Parse", () => { + let engine: NullEngine; + let scene: Scene; + + beforeEach(() => { + engine = new NullEngine({ + renderHeight: 256, + renderWidth: 256, + textureSize: 256, + deterministicLockstep: false, + lockstepMaxSteps: 1, + }); + scene = new Scene(engine); + }); + + afterEach(() => { + scene.dispose(); + engine.dispose(); + }); + + // ── Sample materials from the nme-mcp-server directory ────────────── + + const sampleFiles = ["bricks.json", "grass.json", "psychedelic.json", "purple-fire.json", "purple-fire-bricks.json"]; + + for (const file of sampleFiles) { + it(`should parse sample material: ${file}`, () => { + let jsonStr: string; + try { + jsonStr = readNmeJson(file); + } catch { + // File might not exist — skip + console.warn(`Skipping ${file}: not found`); + return; + } + + const source = JSON.parse(jsonStr); + const material = NodeMaterial.Parse(source, scene); + + expect(material).toBeDefined(); + expect(material.attachedBlocks.length).toBeGreaterThan(0); + + // The material should have parsed all blocks from the JSON + const expectedBlockCount = source.blocks.length; + expect(material.attachedBlocks.length).toBe(expectedBlockCount); + + material.dispose(); + }); + } + + // ── MCP-generated fixtures (created by graphManager.test.ts) ──────── + + const fixtureFiles = ["simpleColor.json", "pbrMaterial.json"]; + + for (const file of fixtureFiles) { + it(`should parse MCP-generated fixture: ${file}`, () => { + let jsonStr: string; + try { + jsonStr = readNmeJson(`test/fixtures/${file}`); + } catch { + console.warn(`Skipping fixture ${file}: not found (run graphManager.test.ts first)`); + return; + } + + const source = JSON.parse(jsonStr); + const material = NodeMaterial.Parse(source, scene); + + expect(material).toBeDefined(); + expect(material.attachedBlocks.length).toBeGreaterThan(0); + expect(material.attachedBlocks.length).toBe(source.blocks.length); + + material.dispose(); + }); + } + + // ── Round-trip fixtures ────────────────────────────────────────────── + + for (const file of sampleFiles) { + it(`should parse round-tripped material: roundtrip-${file}`, () => { + let jsonStr: string; + try { + jsonStr = readNmeJson(`test/fixtures/roundtrip-${file}`); + } catch { + console.warn(`Skipping roundtrip-${file}: not found (run graphManager.test.ts first)`); + return; + } + + const source = JSON.parse(jsonStr); + const material = NodeMaterial.Parse(source, scene); + + expect(material).toBeDefined(); + expect(material.attachedBlocks.length).toBeGreaterThan(0); + expect(material.attachedBlocks.length).toBe(source.blocks.length); + + material.dispose(); + }); + } + + // ── MCP-generated example materials ───────────────────────────────── + + const exampleFiles = ["BricksAndMortar.json", "CheckeredBoard.json", "Water.json", "HumanSkin.json", "Psychedelic.json"]; + + for (const file of exampleFiles) { + it(`should parse example material: ${file}`, () => { + let jsonStr: string; + try { + jsonStr = readNmeJson(`examples/${file}`); + } catch { + console.warn(`Skipping example ${file}: not found`); + return; + } + + const source = JSON.parse(jsonStr); + const material = NodeMaterial.Parse(source, scene); + + expect(material).toBeDefined(); + expect(material.attachedBlocks.length).toBeGreaterThan(0); + expect(material.attachedBlocks.length).toBe(source.blocks.length); + + material.dispose(); + }); + } + + // ── Inline minimal material (always runs, no file dependency) ──────── + + it("should parse a minimal inline material", () => { + const minimalMaterial = { + ignoreAlpha: false, + maxSimultaneousLights: 4, + mode: 0, + forceAlphaBlending: false, + blocks: [ + { + customType: "BABYLON.InputBlock", + id: 1, + name: "position", + target: 1, + inputs: [], + outputs: [{ name: "output", displayName: "output" }], + mode: 1, + min: 0, + max: 0, + isBoolean: false, + matrixMode: 0, + isConstant: false, + groupInInspector: "", + convertToGammaSpace: false, + convertToLinearSpace: false, + animationType: 0, + type: 8, // Vector3 + value: [0, 0, 0], + valueType: "BABYLON.Vector3", + }, + { + customType: "BABYLON.InputBlock", + id: 2, + name: "worldViewProjection", + target: 1, + inputs: [], + outputs: [{ name: "output", displayName: "output" }], + mode: 0, + min: 0, + max: 0, + isBoolean: false, + matrixMode: 0, + isConstant: false, + groupInInspector: "", + convertToGammaSpace: false, + convertToLinearSpace: false, + animationType: 0, + type: 128, // Matrix + systemValue: 6, // WorldViewProjection + }, + { + customType: "BABYLON.TransformBlock", + id: 3, + name: "worldPos", + target: 1, + inputs: [ + { name: "vector", displayName: "vector", targetBlockId: 1, targetConnectionName: "output" }, + { name: "transform", displayName: "transform", targetBlockId: 2, targetConnectionName: "output" }, + ], + outputs: [ + { name: "output", displayName: "output" }, + { name: "xyz", displayName: "xyz" }, + ], + complementZ: 0, + complementW: 1, + }, + { + customType: "BABYLON.VertexOutputBlock", + id: 4, + name: "vertexOutput", + target: 1, + inputs: [{ name: "vector", displayName: "vector", targetBlockId: 3, targetConnectionName: "output" }], + outputs: [], + }, + { + customType: "BABYLON.InputBlock", + id: 5, + name: "color", + target: 2, + inputs: [], + outputs: [{ name: "output", displayName: "output" }], + mode: 0, + min: 0, + max: 0, + isBoolean: false, + matrixMode: 0, + isConstant: false, + groupInInspector: "", + convertToGammaSpace: false, + convertToLinearSpace: false, + animationType: 0, + type: 32, // Color3 + value: [1, 0, 0], + valueType: "BABYLON.Color3", + }, + { + customType: "BABYLON.FragmentOutputBlock", + id: 6, + name: "fragmentOutput", + target: 2, + inputs: [ + { name: "rgba", displayName: "rgba" }, + { name: "rgb", displayName: "rgb", targetBlockId: 5, targetConnectionName: "output" }, + { name: "a", displayName: "a" }, + ], + outputs: [], + convertToGammaSpace: false, + convertToLinearSpace: false, + useLogarithmicDepth: false, + }, + ], + outputNodes: [4, 6], + }; + + const material = NodeMaterial.Parse(minimalMaterial, scene); + + expect(material).toBeDefined(); + expect(material.attachedBlocks.length).toBe(6); + + material.dispose(); + }); + + // ── Deep validation of example materials ──────────────────────────── + + const deepExamples = ["BricksAndMortar.json", "CheckeredBoard.json", "Water.json"]; + + for (const file of deepExamples) { + it(`should build and have connections: ${file}`, () => { + let jsonStr: string; + try { + jsonStr = readNmeJson(`examples/${file}`); + } catch { + console.warn(`Skipping deep test for ${file}: not found`); + return; + } + + const source = JSON.parse(jsonStr); + const material = NodeMaterial.Parse(source, scene); + + expect(material).toBeDefined(); + expect(material.attachedBlocks.length).toBe(source.blocks.length); + + // Check that connections have been restored + let connectedInputCount = 0; + for (const block of material.attachedBlocks) { + for (const inp of block.inputs) { + if (inp.isConnected) { + connectedInputCount++; + } + } + } + + // Count expected connections from the source JSON + let expectedConnections = 0; + for (const b of source.blocks) { + for (const inp of b.inputs ?? []) { + if (inp.targetBlockId !== undefined && inp.targetBlockId !== null) { + expectedConnections++; + } + } + } + + console.log(`${file}: ${connectedInputCount}/${expectedConnections} connections restored, ${material.attachedBlocks.length} blocks`); + + // All connections from the JSON should be restored in the parsed material + expect(connectedInputCount).toBe(expectedConnections); + + // Try building the material (shader compilation) + expect(() => { + material.build(false); + }).not.toThrow(); + + material.dispose(); + }); + } +}); diff --git a/packages/tools/nme-mcp-server/tsconfig.json b/packages/tools/nme-mcp-server/tsconfig.json new file mode 100644 index 00000000000..2c6df59671a --- /dev/null +++ b/packages/tools/nme-mcp-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "types": ["node"], + "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/nodeEditor/src/components/mcpSession/mcpSessionComponent.tsx b/packages/tools/nodeEditor/src/components/mcpSession/mcpSessionComponent.tsx new file mode 100644 index 00000000000..45f239db66b --- /dev/null +++ b/packages/tools/nodeEditor/src/components/mcpSession/mcpSessionComponent.tsx @@ -0,0 +1,172 @@ +import { type FunctionComponent, useState, useEffect, useCallback } from "react"; +import { type GlobalState } from "../../globalState"; +import { SerializationTools } from "../../serializationTools"; +import { LogEntry } from "../log/logComponent"; +import { LineContainerComponent } from "shared-ui-components/lines/lineContainerComponent"; +import { ButtonLineComponent } from "shared-ui-components/lines/buttonLineComponent"; +import { TextInputLineComponent } from "shared-ui-components/lines/textInputLineComponent"; +import { TextLineComponent } from "shared-ui-components/lines/textLineComponent"; +import { + CloseMcpEditorSessionEventSource, + NormalizeMcpEditorSessionUrl, + OpenMcpEditorSessionEventSource, + PostMcpEditorSessionDocumentAsync, +} from "shared-ui-components/mcp/mcpEditorSessionConnection"; + +interface IMcpSessionComponentProps { + globalState: GlobalState; +} + +/** + * Panel that connects to a live MCP session for bidirectional material sync. + * @param props - Component props. + * @returns The React element. + */ +export const McpSessionComponent: FunctionComponent = (props) => { + const { globalState } = props; + const [url, setUrl] = useState(globalState.mcpSessionUrl ?? ""); + const [connected, setConnected] = useState(globalState.mcpSessionConnected); + + // Sync local state with globalState when the component mounts or the + // connection state changes externally (e.g. SSE error while unmounted). + useEffect(() => { + const observer = globalState.onMcpSessionStateChangedObservable.add((state) => { + setConnected(state); + }); + // Sync on mount in case state changed while we were unmounted + setConnected(globalState.mcpSessionConnected); + if (globalState.mcpSessionUrl) { + setUrl(globalState.mcpSessionUrl); + } + return () => { + globalState.onMcpSessionStateChangedObservable.remove(observer); + }; + }, [globalState]); + + const loadMaterialFromJson = useCallback( + (json: any) => { + SerializationTools.Deserialize(json, globalState); + if (!globalState.nodeMaterial) { + return; + } + // Don't call nodeMaterial.build() here — the onResetRequiredObservable + // handler in GraphEditor calls build() (which creates graph nodes and + // positions them from editorData) then buildMaterial() (which compiles + // shaders). Calling build() here would trigger UpdateLocations before + // graph nodes exist, zeroing all editorData positions. + globalState.onResetRequiredObservable.notifyObservers(false); + globalState.stateManager.onSelectionChangedObservable.notifyObservers(null); + globalState.onClearUndoStack.notifyObservers(); + // Zoom to fit so the user always sees the full graph after an update + globalState.onZoomToFitRequiredObservable.notifyObservers(); + }, + [globalState] + ); + + const handleConnect = useCallback( + async (pushOnConnect: boolean = false) => { + const sessionUrl = NormalizeMcpEditorSessionUrl(url); + if (!sessionUrl) { + return; + } + + try { + if (pushOnConnect && globalState.nodeMaterial) { + const json = SerializationTools.Serialize(globalState.nodeMaterial, globalState); + await PostMcpEditorSessionDocumentAsync(sessionUrl, json, "material"); + } + + CloseMcpEditorSessionEventSource(globalState.mcpEventSource); + globalState.mcpEventSource = null; + + const eventSource = OpenMcpEditorSessionEventSource({ + sessionUrl, + onDocument: loadMaterialFromJson, + onSessionClosed: (reason) => { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session ended: ${reason}`, false)); + globalState.mcpSessionConnected = false; + globalState.mcpEventSource = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, + onConnectionError: () => { + globalState.mcpSessionConnected = false; + globalState.mcpEventSource = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, + }); + globalState.mcpEventSource = eventSource; + + // Update state — the observable listener sets local `connected` + globalState.mcpSessionUrl = sessionUrl; + globalState.mcpSessionConnected = true; + globalState.onMcpSessionStateChangedObservable.notifyObservers(true); + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Connection failed — ${err}`, true)); + } + }, + [url, globalState, loadMaterialFromJson] + ); + + const handleDisconnect = useCallback(() => { + CloseMcpEditorSessionEventSource(globalState.mcpEventSource); + globalState.mcpEventSource = null; + globalState.mcpSessionConnected = false; + globalState.mcpSessionUrl = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, [globalState]); + + const handlePush = useCallback(async () => { + if (!globalState.mcpSessionUrl || !globalState.nodeMaterial) { + return; + } + const json = SerializationTools.Serialize(globalState.nodeMaterial, globalState); + try { + const res = await PostMcpEditorSessionDocumentAsync(globalState.mcpSessionUrl, json, "material"); + if (!res.ok) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Push failed (${res.status})`, true)); + } + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Push failed — ${err}`, true)); + } + }, [globalState]); + + return ( + + setUrl(value)} + placeholder="http://localhost:3001/session/..." + disabled={connected} + lockObject={globalState.lockObject} + /> + + {!connected ? ( + <> + { + void handleConnect(false); + }} + /> + { + void handleConnect(true); + }} + /> + + ) : ( + <> + + { + void handlePush(); + }} + /> + + )} + + ); +}; diff --git a/packages/tools/nodeEditor/src/components/propertyTab/propertyTabComponent.tsx b/packages/tools/nodeEditor/src/components/propertyTab/propertyTabComponent.tsx index f7ee04196cd..a74c0005744 100644 --- a/packages/tools/nodeEditor/src/components/propertyTab/propertyTabComponent.tsx +++ b/packages/tools/nodeEditor/src/components/propertyTab/propertyTabComponent.tsx @@ -41,6 +41,7 @@ import { SliderLineComponent } from "shared-ui-components/lines/sliderLineCompon import { SetToDefaultGaussianSplatting, SetToDefaultSFE } from "core/Materials/Node/nodeMaterialDefault"; import { AlphaModeOptions } from "shared-ui-components/constToOptionsMaps"; import { PropertyTabComponentBase } from "shared-ui-components/components/propertyTabComponentBase"; +import { McpSessionComponent } from "../mcpSession/mcpSessionComponent"; interface IPropertyTabComponentProps { globalState: GlobalState; @@ -656,6 +657,7 @@ export class PropertyTabComponent extends React.Component )} {GetInputProperties({ lockObject: this.props.lockObject, globalState: this.props.globalState, inputs: this.props.globalState.nodeMaterial.getInputBlocks() })} + ); } diff --git a/packages/tools/nodeEditor/src/globalState.ts b/packages/tools/nodeEditor/src/globalState.ts index b309d577b29..b43f7ed81e5 100644 --- a/packages/tools/nodeEditor/src/globalState.ts +++ b/packages/tools/nodeEditor/src/globalState.ts @@ -72,6 +72,12 @@ export class GlobalState { debugBlocksToRefresh: NodeMaterialDebugBlock[] = []; forcedDebugBlock: Nullable = null; + // ── MCP Session state ────────────────────────────────────────────── + mcpSessionUrl: string | null = null; + mcpSessionConnected: boolean = false; + mcpEventSource: EventSource | null = null; + onMcpSessionStateChangedObservable = new Observable(); + /** Gets the mode */ public get mode(): NodeMaterialModes { return this._mode; diff --git a/packages/tools/nodeEditor/src/graphSystem/display/inputDisplayManager.ts b/packages/tools/nodeEditor/src/graphSystem/display/inputDisplayManager.ts index 5d09961c83f..429f51b59b6 100644 --- a/packages/tools/nodeEditor/src/graphSystem/display/inputDisplayManager.ts +++ b/packages/tools/nodeEditor/src/graphSystem/display/inputDisplayManager.ts @@ -4,7 +4,7 @@ import { NodeMaterialSystemValues } from "core/Materials/Node/Enums/nodeMaterial import { NodeMaterialBlockConnectionPointTypes } from "core/Materials/Node/Enums/nodeMaterialBlockConnectionPointTypes"; import { AnimatedInputBlockTypes } from "core/Materials/Node/Blocks/Input/animatedInputBlockTypes"; import { type Vector2, type Vector3, type Vector4 } from "core/Maths/math.vector"; -import { type Color3, type Color4 } from "core/Maths/math.color"; +import { type Color3 } from "core/Maths/math.color"; import { BlockTools } from "../../blockTools"; import { type IDisplayManager } from "shared-ui-components/nodeGraphSystem/interfaces/displayManager"; import { type INodeData } from "shared-ui-components/nodeGraphSystem/interfaces/nodeData"; @@ -67,16 +67,10 @@ export class InputDisplayManager implements IDisplayManager { const inputBlock = nodeData.data as InputBlock; switch (inputBlock.type) { - case NodeMaterialBlockConnectionPointTypes.Color3: { - if (inputBlock.value) { - color = (inputBlock.value as Color3).toHexString(); - break; - } - } - // eslint-disable-next-line no-fallthrough + case NodeMaterialBlockConnectionPointTypes.Color3: case NodeMaterialBlockConnectionPointTypes.Color4: { if (inputBlock.value) { - color = (inputBlock.value as Color4).toHexString(true); + color = (inputBlock.value as Color3).toHexString(); break; } } @@ -145,17 +139,17 @@ export class InputDisplayManager implements IDisplayManager { if (inputBlock.animationType !== AnimatedInputBlockTypes.None) { value = AnimatedInputBlockTypes[inputBlock.animationType]; } else { - value = inputBlock.value.toFixed(4); + value = inputBlock.value != null ? inputBlock.value.toFixed(4) : "0"; } break; case NodeMaterialBlockConnectionPointTypes.Vector2: { const vec2Value = inputBlock.value as Vector2; - value = `(${vec2Value.x.toFixed(2)}, ${vec2Value.y.toFixed(2)})`; + value = vec2Value ? `(${vec2Value.x.toFixed(2)}, ${vec2Value.y.toFixed(2)})` : "(0, 0)"; break; } case NodeMaterialBlockConnectionPointTypes.Vector3: { const vec3Value = inputBlock.value as Vector3; - value = `(${vec3Value.x.toFixed(2)}, ${vec3Value.y.toFixed(2)}, ${vec3Value.z.toFixed(2)})`; + value = vec3Value ? `(${vec3Value.x.toFixed(2)}, ${vec3Value.y.toFixed(2)}, ${vec3Value.z.toFixed(2)})` : "(0, 0, 0)"; break; } case NodeMaterialBlockConnectionPointTypes.Vector4: { @@ -163,7 +157,7 @@ export class InputDisplayManager implements IDisplayManager { value = AnimatedInputBlockTypes[inputBlock.animationType]; } else { const vec4Value = inputBlock.value as Vector4; - value = `(${vec4Value.x.toFixed(2)}, ${vec4Value.y.toFixed(2)}, ${vec4Value.z.toFixed(2)}, ${vec4Value.w.toFixed(2)})`; + value = vec4Value ? `(${vec4Value.x.toFixed(2)}, ${vec4Value.y.toFixed(2)}, ${vec4Value.z.toFixed(2)}, ${vec4Value.w.toFixed(2)})` : "(0, 0, 0, 0)"; } break; } diff --git a/packages/tools/nodeGeometryEditor/src/components/mcpSession/mcpSessionComponent.tsx b/packages/tools/nodeGeometryEditor/src/components/mcpSession/mcpSessionComponent.tsx new file mode 100644 index 00000000000..0eda488e880 --- /dev/null +++ b/packages/tools/nodeGeometryEditor/src/components/mcpSession/mcpSessionComponent.tsx @@ -0,0 +1,160 @@ +import { type FunctionComponent, useCallback, useEffect, useState } from "react"; +import { type GlobalState } from "../../globalState"; +import { SerializationTools } from "../../serializationTools"; +import { LogEntry } from "../log/logComponent"; +import { LineContainerComponent } from "shared-ui-components/lines/lineContainerComponent"; +import { ButtonLineComponent } from "shared-ui-components/lines/buttonLineComponent"; +import { TextInputLineComponent } from "shared-ui-components/lines/textInputLineComponent"; +import { TextLineComponent } from "shared-ui-components/lines/textLineComponent"; +import { + CloseMcpEditorSessionEventSource, + NormalizeMcpEditorSessionUrl, + OpenMcpEditorSessionEventSource, + PostMcpEditorSessionDocumentAsync, +} from "shared-ui-components/mcp/mcpEditorSessionConnection"; + +interface IMcpSessionComponentProps { + globalState: GlobalState; +} + +/** + * Panel that connects to a live MCP session for bidirectional geometry sync. + * @param props - Component props. + * @returns The React element. + */ +export const McpSessionComponent: FunctionComponent = (props) => { + const { globalState } = props; + const [url, setUrl] = useState(globalState.mcpSessionUrl ?? ""); + const [connected, setConnected] = useState(globalState.mcpSessionConnected); + + useEffect(() => { + const observer = globalState.onMcpSessionStateChangedObservable.add((state) => { + setConnected(state); + }); + setConnected(globalState.mcpSessionConnected); + if (globalState.mcpSessionUrl) { + setUrl(globalState.mcpSessionUrl); + } + return () => { + globalState.onMcpSessionStateChangedObservable.remove(observer); + }; + }, [globalState]); + + const loadGeometryFromJson = useCallback( + (json: unknown) => { + SerializationTools.Deserialize(json, globalState); + globalState.onResetRequiredObservable.notifyObservers(false); + globalState.stateManager.onSelectionChangedObservable.notifyObservers(null); + globalState.onFrame.notifyObservers(); + globalState.onClearUndoStack.notifyObservers(); + globalState.onZoomToFitRequiredObservable.notifyObservers(); + }, + [globalState] + ); + + const handleConnect = useCallback( + async (pushOnConnect: boolean = false) => { + const sessionUrl = NormalizeMcpEditorSessionUrl(url); + if (!sessionUrl) { + return; + } + + try { + if (pushOnConnect && globalState.nodeGeometry) { + const json = SerializationTools.Serialize(globalState.nodeGeometry, globalState); + await PostMcpEditorSessionDocumentAsync(sessionUrl, json); + } + + CloseMcpEditorSessionEventSource(globalState.mcpEventSource); + globalState.mcpEventSource = null; + + const eventSource = OpenMcpEditorSessionEventSource({ + sessionUrl, + onDocument: loadGeometryFromJson, + onSessionClosed: (reason) => { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session ended: ${reason}`, false)); + globalState.mcpSessionConnected = false; + globalState.mcpEventSource = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, + onConnectionError: () => { + globalState.mcpSessionConnected = false; + globalState.mcpEventSource = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, + }); + globalState.mcpEventSource = eventSource; + + globalState.mcpSessionUrl = sessionUrl; + globalState.mcpSessionConnected = true; + globalState.onMcpSessionStateChangedObservable.notifyObservers(true); + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Connection failed - ${err}`, true)); + } + }, + [url, globalState, loadGeometryFromJson] + ); + + const handleDisconnect = useCallback(() => { + CloseMcpEditorSessionEventSource(globalState.mcpEventSource); + globalState.mcpEventSource = null; + globalState.mcpSessionConnected = false; + globalState.mcpSessionUrl = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, [globalState]); + + const handlePush = useCallback(async () => { + if (!globalState.mcpSessionUrl || !globalState.nodeGeometry) { + return; + } + const json = SerializationTools.Serialize(globalState.nodeGeometry, globalState); + try { + const res = await PostMcpEditorSessionDocumentAsync(globalState.mcpSessionUrl, json); + if (!res.ok) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Push failed (${res.status})`, true)); + } + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Push failed - ${err}`, true)); + } + }, [globalState]); + + return ( + + setUrl(value)} + placeholder="http://localhost:3001/session/..." + disabled={connected} + lockObject={globalState.lockObject} + /> + + {!connected ? ( + <> + { + void handleConnect(false); + }} + /> + { + void handleConnect(true); + }} + /> + + ) : ( + <> + + { + void handlePush(); + }} + /> + + )} + + ); +}; diff --git a/packages/tools/nodeGeometryEditor/src/components/propertyTab/propertyTabComponent.tsx b/packages/tools/nodeGeometryEditor/src/components/propertyTab/propertyTabComponent.tsx index 6757f65fddf..408160886ea 100644 --- a/packages/tools/nodeGeometryEditor/src/components/propertyTab/propertyTabComponent.tsx +++ b/packages/tools/nodeGeometryEditor/src/components/propertyTab/propertyTabComponent.tsx @@ -28,6 +28,7 @@ import { type LockObject } from "shared-ui-components/tabs/propertyGrids/lockObj import { TextLineComponent } from "shared-ui-components/lines/textLineComponent"; import { SliderLineComponent } from "shared-ui-components/lines/sliderLineComponent"; import { NodeGeometry } from "core/Meshes/Node/nodeGeometry"; +import { McpSessionComponent } from "../mcpSession/mcpSessionComponent"; interface IPropertyTabComponentProps { globalState: GlobalState; @@ -414,6 +415,7 @@ export class PropertyTabComponent extends React.Component +
); diff --git a/packages/tools/nodeGeometryEditor/src/globalState.ts b/packages/tools/nodeGeometryEditor/src/globalState.ts index 8bbb4ddf233..b52c99a44c7 100644 --- a/packages/tools/nodeGeometryEditor/src/globalState.ts +++ b/packages/tools/nodeGeometryEditor/src/globalState.ts @@ -47,6 +47,11 @@ export class GlobalState { onRefreshPreviewMeshControlComponentRequiredObservable = new Observable(); onExportToGLBRequired = new Observable(); + mcpSessionUrl: string | null = null; + mcpSessionConnected: boolean = false; + mcpEventSource: EventSource | null = null; + onMcpSessionStateChangedObservable = new Observable(); + customSave?: { label: string; action: (data: string) => Promise }; resyncHandler?: () => void; diff --git a/packages/tools/nodeParticleEditor/src/components/mcpSession/mcpSessionComponent.tsx b/packages/tools/nodeParticleEditor/src/components/mcpSession/mcpSessionComponent.tsx new file mode 100644 index 00000000000..ba5b0ad62e9 --- /dev/null +++ b/packages/tools/nodeParticleEditor/src/components/mcpSession/mcpSessionComponent.tsx @@ -0,0 +1,159 @@ +import { type FunctionComponent, useCallback, useEffect, useState } from "react"; +import { type GlobalState } from "../../globalState"; +import { SerializationTools } from "../../serializationTools"; +import { LogEntry } from "../log/logComponent"; +import { LineContainerComponent } from "shared-ui-components/lines/lineContainerComponent"; +import { ButtonLineComponent } from "shared-ui-components/lines/buttonLineComponent"; +import { TextInputLineComponent } from "shared-ui-components/lines/textInputLineComponent"; +import { TextLineComponent } from "shared-ui-components/lines/textLineComponent"; +import { + CloseMcpEditorSessionEventSource, + NormalizeMcpEditorSessionUrl, + OpenMcpEditorSessionEventSource, + PostMcpEditorSessionDocumentAsync, +} from "shared-ui-components/mcp/mcpEditorSessionConnection"; + +interface IMcpSessionComponentProps { + globalState: GlobalState; +} + +/** + * Panel that connects to a live MCP session for bidirectional particle-system sync. + * @param props - Component props. + * @returns The React element. + */ +export const McpSessionComponent: FunctionComponent = (props) => { + const { globalState } = props; + const [url, setUrl] = useState(globalState.mcpSessionUrl ?? ""); + const [connected, setConnected] = useState(globalState.mcpSessionConnected); + + useEffect(() => { + const observer = globalState.onMcpSessionStateChangedObservable.add((state) => { + setConnected(state); + }); + setConnected(globalState.mcpSessionConnected); + if (globalState.mcpSessionUrl) { + setUrl(globalState.mcpSessionUrl); + } + return () => { + globalState.onMcpSessionStateChangedObservable.remove(observer); + }; + }, [globalState]); + + const loadParticleSystemFromJson = useCallback( + (json: unknown) => { + SerializationTools.Deserialize(json, globalState); + globalState.onResetRequiredObservable.notifyObservers(false); + globalState.stateManager.onSelectionChangedObservable.notifyObservers(null); + globalState.onClearUndoStack.notifyObservers(); + globalState.onZoomToFitRequiredObservable.notifyObservers(); + }, + [globalState] + ); + + const handleConnect = useCallback( + async (pushOnConnect: boolean = false) => { + const sessionUrl = NormalizeMcpEditorSessionUrl(url); + if (!sessionUrl) { + return; + } + + try { + if (pushOnConnect && globalState.nodeParticleSet) { + const json = SerializationTools.Serialize(globalState.nodeParticleSet, globalState); + await PostMcpEditorSessionDocumentAsync(sessionUrl, json); + } + + CloseMcpEditorSessionEventSource(globalState.mcpEventSource); + globalState.mcpEventSource = null; + + const eventSource = OpenMcpEditorSessionEventSource({ + sessionUrl, + onDocument: loadParticleSystemFromJson, + onSessionClosed: (reason) => { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session ended: ${reason}`, false)); + globalState.mcpSessionConnected = false; + globalState.mcpEventSource = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, + onConnectionError: () => { + globalState.mcpSessionConnected = false; + globalState.mcpEventSource = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, + }); + globalState.mcpEventSource = eventSource; + + globalState.mcpSessionUrl = sessionUrl; + globalState.mcpSessionConnected = true; + globalState.onMcpSessionStateChangedObservable.notifyObservers(true); + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Connection failed - ${err}`, true)); + } + }, + [url, globalState, loadParticleSystemFromJson] + ); + + const handleDisconnect = useCallback(() => { + CloseMcpEditorSessionEventSource(globalState.mcpEventSource); + globalState.mcpEventSource = null; + globalState.mcpSessionConnected = false; + globalState.mcpSessionUrl = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, [globalState]); + + const handlePush = useCallback(async () => { + if (!globalState.mcpSessionUrl || !globalState.nodeParticleSet) { + return; + } + const json = SerializationTools.Serialize(globalState.nodeParticleSet, globalState); + try { + const res = await PostMcpEditorSessionDocumentAsync(globalState.mcpSessionUrl, json); + if (!res.ok) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Push failed (${res.status})`, true)); + } + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Push failed - ${err}`, true)); + } + }, [globalState]); + + return ( + + setUrl(value)} + placeholder="http://localhost:3001/session/..." + disabled={connected} + lockObject={globalState.lockObject} + /> + + {!connected ? ( + <> + { + void handleConnect(false); + }} + /> + { + void handleConnect(true); + }} + /> + + ) : ( + <> + + { + void handlePush(); + }} + /> + + )} + + ); +}; diff --git a/packages/tools/nodeParticleEditor/src/components/propertyTab/propertyTabComponent.tsx b/packages/tools/nodeParticleEditor/src/components/propertyTab/propertyTabComponent.tsx index 16ca487de0a..5174b2c3a3e 100644 --- a/packages/tools/nodeParticleEditor/src/components/propertyTab/propertyTabComponent.tsx +++ b/packages/tools/nodeParticleEditor/src/components/propertyTab/propertyTabComponent.tsx @@ -28,6 +28,7 @@ import { type LockObject } from "shared-ui-components/tabs/propertyGrids/lockObj import { TextLineComponent } from "shared-ui-components/lines/textLineComponent"; import { SliderLineComponent } from "shared-ui-components/lines/sliderLineComponent"; import { NodeParticleSystemSet } from "core/Particles"; +import { McpSessionComponent } from "../mcpSession/mcpSessionComponent"; interface IPropertyTabComponentProps { globalState: GlobalState; @@ -365,6 +366,7 @@ export class PropertyTabComponent extends React.Component + ); diff --git a/packages/tools/nodeParticleEditor/src/globalState.ts b/packages/tools/nodeParticleEditor/src/globalState.ts index a6a30985e58..f5d25e30e71 100644 --- a/packages/tools/nodeParticleEditor/src/globalState.ts +++ b/packages/tools/nodeParticleEditor/src/globalState.ts @@ -45,6 +45,11 @@ export class GlobalState { onExportToGLBRequired = new Observable(); updateState: (left: string, right: string) => void; + mcpSessionUrl: string | null = null; + mcpSessionConnected: boolean = false; + mcpEventSource: EventSource | null = null; + onMcpSessionStateChangedObservable = new Observable(); + customSave?: { label: string; action: (data: string) => Promise }; public constructor() { diff --git a/packages/tools/nodeRenderGraphEditor/src/components/mcpSession/mcpSessionComponent.tsx b/packages/tools/nodeRenderGraphEditor/src/components/mcpSession/mcpSessionComponent.tsx new file mode 100644 index 00000000000..a8738f9f68a --- /dev/null +++ b/packages/tools/nodeRenderGraphEditor/src/components/mcpSession/mcpSessionComponent.tsx @@ -0,0 +1,160 @@ +import { type FunctionComponent, useCallback, useEffect, useState } from "react"; +import { type GlobalState } from "../../globalState"; +import { SerializationTools } from "../../serializationTools"; +import { LogEntry } from "../log/logComponent"; +import { LineContainerComponent } from "../../sharedComponents/lineContainerComponent"; +import { ButtonLineComponent } from "shared-ui-components/lines/buttonLineComponent"; +import { TextInputLineComponent } from "shared-ui-components/lines/textInputLineComponent"; +import { TextLineComponent } from "shared-ui-components/lines/textLineComponent"; +import { + CloseMcpEditorSessionEventSource, + NormalizeMcpEditorSessionUrl, + OpenMcpEditorSessionEventSource, + PostMcpEditorSessionDocumentAsync, +} from "shared-ui-components/mcp/mcpEditorSessionConnection"; + +interface IMcpSessionComponentProps { + globalState: GlobalState; +} + +/** + * Panel that connects to a live MCP session for bidirectional render-graph sync. + * @param props - Component props. + * @returns The React element. + */ +export const McpSessionComponent: FunctionComponent = (props) => { + const { globalState } = props; + const [url, setUrl] = useState(globalState.mcpSessionUrl ?? ""); + const [connected, setConnected] = useState(globalState.mcpSessionConnected); + + useEffect(() => { + const observer = globalState.onMcpSessionStateChangedObservable.add((state) => { + setConnected(state); + }); + setConnected(globalState.mcpSessionConnected); + if (globalState.mcpSessionUrl) { + setUrl(globalState.mcpSessionUrl); + } + return () => { + globalState.onMcpSessionStateChangedObservable.remove(observer); + }; + }, [globalState]); + + const loadRenderGraphFromJson = useCallback( + (json: unknown) => { + SerializationTools.Deserialize(json, globalState); + globalState.onResetRequiredObservable.notifyObservers(false); + globalState.stateManager.onSelectionChangedObservable.notifyObservers(null); + globalState.onClearUndoStack.notifyObservers(); + globalState.onFrame.notifyObservers(); + globalState.onZoomToFitRequiredObservable.notifyObservers(); + }, + [globalState] + ); + + const handleConnect = useCallback( + async (pushOnConnect: boolean = false) => { + const sessionUrl = NormalizeMcpEditorSessionUrl(url); + if (!sessionUrl) { + return; + } + + try { + if (pushOnConnect && globalState.nodeRenderGraph) { + const json = SerializationTools.Serialize(globalState.nodeRenderGraph, globalState); + await PostMcpEditorSessionDocumentAsync(sessionUrl, json); + } + + CloseMcpEditorSessionEventSource(globalState.mcpEventSource); + globalState.mcpEventSource = null; + + const eventSource = OpenMcpEditorSessionEventSource({ + sessionUrl, + onDocument: loadRenderGraphFromJson, + onSessionClosed: (reason) => { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session ended: ${reason}`, false)); + globalState.mcpSessionConnected = false; + globalState.mcpEventSource = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, + onConnectionError: () => { + globalState.mcpSessionConnected = false; + globalState.mcpEventSource = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, + }); + globalState.mcpEventSource = eventSource; + + globalState.mcpSessionUrl = sessionUrl; + globalState.mcpSessionConnected = true; + globalState.onMcpSessionStateChangedObservable.notifyObservers(true); + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Connection failed - ${err}`, true)); + } + }, + [url, globalState, loadRenderGraphFromJson] + ); + + const handleDisconnect = useCallback(() => { + CloseMcpEditorSessionEventSource(globalState.mcpEventSource); + globalState.mcpEventSource = null; + globalState.mcpSessionConnected = false; + globalState.mcpSessionUrl = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, [globalState]); + + const handlePush = useCallback(async () => { + if (!globalState.mcpSessionUrl || !globalState.nodeRenderGraph) { + return; + } + const json = SerializationTools.Serialize(globalState.nodeRenderGraph, globalState); + try { + const res = await PostMcpEditorSessionDocumentAsync(globalState.mcpSessionUrl, json); + if (!res.ok) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Push failed (${res.status})`, true)); + } + } catch (err) { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session: Push failed - ${err}`, true)); + } + }, [globalState]); + + return ( + + setUrl(value)} + placeholder="http://localhost:3001/session/..." + disabled={connected} + lockObject={globalState.lockObject} + /> + + {!connected ? ( + <> + { + void handleConnect(false); + }} + /> + { + void handleConnect(true); + }} + /> + + ) : ( + <> + + { + void handlePush(); + }} + /> + + )} + + ); +}; diff --git a/packages/tools/nodeRenderGraphEditor/src/components/propertyTab/propertyTabComponent.tsx b/packages/tools/nodeRenderGraphEditor/src/components/propertyTab/propertyTabComponent.tsx index a5c7ff67553..488849d5b5f 100644 --- a/packages/tools/nodeRenderGraphEditor/src/components/propertyTab/propertyTabComponent.tsx +++ b/packages/tools/nodeRenderGraphEditor/src/components/propertyTab/propertyTabComponent.tsx @@ -28,6 +28,7 @@ import { TextLineComponent } from "shared-ui-components/lines/textLineComponent" import { SliderLineComponent } from "shared-ui-components/lines/sliderLineComponent"; import { NodeRenderGraph } from "core/FrameGraph/Node/nodeRenderGraph"; import { OptionsLine } from "shared-ui-components/lines/optionsLineComponent"; +import { McpSessionComponent } from "../mcpSession/mcpSessionComponent"; interface IPropertyTabComponentProps { globalState: GlobalState; @@ -371,6 +372,7 @@ export class PropertyTabComponent extends React.Component )} + diff --git a/packages/tools/nodeRenderGraphEditor/src/globalState.ts b/packages/tools/nodeRenderGraphEditor/src/globalState.ts index 381b512e93a..e791f7afa7c 100644 --- a/packages/tools/nodeRenderGraphEditor/src/globalState.ts +++ b/packages/tools/nodeRenderGraphEditor/src/globalState.ts @@ -56,6 +56,11 @@ export class GlobalState { noAutoFillExternalInputs: boolean; _engine: number; + mcpSessionUrl: string | null = null; + mcpSessionConnected: boolean = false; + mcpEventSource: EventSource | null = null; + onMcpSessionStateChangedObservable = new Observable(); + customSave?: { label: string; action: (data: string) => Promise }; customBlockDescriptions?: INodeRenderGraphCustomBlockDescription[]; diff --git a/packages/tools/npe-mcp-server/README.md b/packages/tools/npe-mcp-server/README.md new file mode 100644 index 00000000000..9762d9e43e0 --- /dev/null +++ b/packages/tools/npe-mcp-server/README.md @@ -0,0 +1,42 @@ +# @tools/npe-mcp-server + +MCP server for AI-driven Babylon.js Node Particle authoring. + +## Provides + +- create and manage particle graph sets in memory +- add blocks, connect ports, and update block properties +- inspect and validate particle systems +- export and import NPE-compatible JSON +- import from and save to Babylon.js snippets + +## Typical Workflow + +```text +create_particle_system -> add_block -> connect_blocks -> set_block_properties -> validate_particle_system -> export_particle_system_json +``` + +Most particle graphs end with a `SystemBlock` and require their mandatory inputs to be connected before export. + +## Binary + +```bash +babylonjs-node-particle +``` + +## Build And Run + +```bash +npm run build -w @tools/npe-mcp-server +npm run start -w @tools/npe-mcp-server +``` + +## Integration + +The server produces Babylon.js Node Particle JSON for editor and runtime workflows, and can participate in broader scene-authoring pipelines alongside the Scene MCP server. + +## Related Files + +- `src/index.ts`: MCP tool registration +- `src/particleGraph.ts`: graph manager and validation logic +- `src/blockRegistry.ts`: Node Particle block catalog diff --git a/packages/tools/npe-mcp-server/examples/BasicParticles.json b/packages/tools/npe-mcp-server/examples/BasicParticles.json new file mode 100644 index 00000000000..5e03631b844 --- /dev/null +++ b/packages/tools/npe-mcp-server/examples/BasicParticles.json @@ -0,0 +1,297 @@ +{ + "customType": "BABYLON.NodeParticleSystemSet", + "blocks": [ + { + "customType": "BABYLON.BoxShapeBlock", + "id": 1, + "name": "shape", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 2, + "targetConnectionName": "particle" + }, + { + "name": "direction1", + "displayName": "direction1" + }, + { + "name": "direction2", + "displayName": "direction2" + }, + { + "name": "minEmitBox", + "displayName": "minEmitBox" + }, + { + "name": "maxEmitBox", + "displayName": "maxEmitBox" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.CreateParticleBlock", + "id": 2, + "name": "create", + "inputs": [ + { + "name": "emitPower", + "displayName": "emitPower" + }, + { + "name": "lifeTime", + "displayName": "lifeTime", + "inputName": "lifeTime", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "color", + "displayName": "color" + }, + { + "name": "colorDead", + "displayName": "colorDead" + }, + { + "name": "scale", + "displayName": "scale" + }, + { + "name": "angle", + "displayName": "angle" + }, + { + "name": "size", + "displayName": "size" + } + ], + "outputs": [ + { + "name": "particle", + "displayName": "particle" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 3, + "name": "lifetime", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 0, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": 2, + "valueType": "number" + }, + { + "customType": "BABYLON.UpdateAgeBlock", + "id": 4, + "name": "updateAge", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "age", + "displayName": "age", + "inputName": "age", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 5, + "name": "ageInput", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 3, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true + }, + { + "customType": "BABYLON.BasicPositionUpdateBlock", + "id": 6, + "name": "updatePos", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 4, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ParticleTextureSourceBlock", + "id": 7, + "name": "texture", + "inputs": [], + "outputs": [ + { + "name": "texture", + "displayName": "texture" + } + ], + "invertY": true, + "serializedCachedData": false, + "url": "https://assets.babylonjs.com/textures/flare.png" + }, + { + "customType": "BABYLON.SystemBlock", + "id": 8, + "name": "system", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "emitRate", + "displayName": "emitRate" + }, + { + "name": "texture", + "displayName": "texture", + "inputName": "texture", + "targetBlockId": 7, + "targetConnectionName": "texture" + }, + { + "name": "translationPivot", + "displayName": "translationPivot" + }, + { + "name": "textureMask", + "displayName": "textureMask" + }, + { + "name": "targetStopDuration", + "displayName": "targetStopDuration" + }, + { + "name": "onStart", + "displayName": "onStart" + }, + { + "name": "onEnd", + "displayName": "onEnd" + } + ], + "outputs": [ + { + "name": "system", + "displayName": "system" + } + ], + "blendMode": 0, + "capacity": 1000, + "manualEmitCount": -1, + "startDelay": 0, + "updateSpeed": 0.0167, + "preWarmCycles": 0, + "preWarmStepOffset": 0, + "isBillboardBased": true, + "billBoardMode": 7, + "isLocal": false, + "disposeOnStop": false, + "doNoStart": false, + "renderingGroupId": 0 + } + ], + "comment": "Minimal box-emitting particle system.", + "name": "BasicParticles", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 680, + "y": 0 + }, + { + "blockId": 2, + "x": 340, + "y": 0 + }, + { + "blockId": 3, + "x": 0, + "y": 0 + }, + { + "blockId": 4, + "x": 1020, + "y": 0 + }, + { + "blockId": 5, + "x": 680, + "y": 180 + }, + { + "blockId": 6, + "x": 1360, + "y": 0 + }, + { + "blockId": 7, + "x": 1360, + "y": 180 + }, + { + "blockId": 8, + "x": 1700, + "y": 0 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/npe-mcp-server/examples/ColoredParticles.json b/packages/tools/npe-mcp-server/examples/ColoredParticles.json new file mode 100644 index 00000000000..21686bdbc4e --- /dev/null +++ b/packages/tools/npe-mcp-server/examples/ColoredParticles.json @@ -0,0 +1,398 @@ +{ + "customType": "BABYLON.NodeParticleSystemSet", + "blocks": [ + { + "customType": "BABYLON.SphereShapeBlock", + "id": 1, + "name": "shape", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 2, + "targetConnectionName": "particle" + }, + { + "name": "radius", + "displayName": "radius" + }, + { + "name": "radiusRange", + "displayName": "radiusRange" + }, + { + "name": "directionRandomizer", + "displayName": "directionRandomizer" + }, + { + "name": "direction1", + "displayName": "direction1" + }, + { + "name": "direction2", + "displayName": "direction2" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.CreateParticleBlock", + "id": 2, + "name": "create", + "inputs": [ + { + "name": "emitPower", + "displayName": "emitPower" + }, + { + "name": "lifeTime", + "displayName": "lifeTime", + "inputName": "lifeTime", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "color", + "displayName": "color", + "inputName": "color", + "targetBlockId": 4, + "targetConnectionName": "output" + }, + { + "name": "colorDead", + "displayName": "colorDead" + }, + { + "name": "scale", + "displayName": "scale" + }, + { + "name": "angle", + "displayName": "angle" + }, + { + "name": "size", + "displayName": "size" + } + ], + "outputs": [ + { + "name": "particle", + "displayName": "particle" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 3, + "name": "lifetime", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 0, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": 3, + "valueType": "number" + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 4, + "name": "startColor", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 128, + "contextualValue": 0, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": [ + 1, + 0.8, + 0, + 1 + ], + "valueType": "BABYLON.Color4" + }, + { + "customType": "BABYLON.UpdateAgeBlock", + "id": 5, + "name": "updateAge", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "age", + "displayName": "age", + "inputName": "age", + "targetBlockId": 6, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 6, + "name": "ageInput", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 3, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true + }, + { + "customType": "BABYLON.BasicPositionUpdateBlock", + "id": 7, + "name": "updatePos", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.UpdateColorBlock", + "id": 8, + "name": "updateColor", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "color", + "displayName": "color", + "inputName": "color", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 9, + "name": "endColor", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 128, + "contextualValue": 0, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": [ + 1, + 0, + 0, + 0 + ], + "valueType": "BABYLON.Color4" + }, + { + "customType": "BABYLON.ParticleTextureSourceBlock", + "id": 10, + "name": "texture", + "inputs": [], + "outputs": [ + { + "name": "texture", + "displayName": "texture" + } + ], + "invertY": true, + "serializedCachedData": false, + "url": "https://assets.babylonjs.com/textures/flare.png" + }, + { + "customType": "BABYLON.SystemBlock", + "id": 11, + "name": "system", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 8, + "targetConnectionName": "output" + }, + { + "name": "emitRate", + "displayName": "emitRate" + }, + { + "name": "texture", + "displayName": "texture", + "inputName": "texture", + "targetBlockId": 10, + "targetConnectionName": "texture" + }, + { + "name": "translationPivot", + "displayName": "translationPivot" + }, + { + "name": "textureMask", + "displayName": "textureMask" + }, + { + "name": "targetStopDuration", + "displayName": "targetStopDuration" + }, + { + "name": "onStart", + "displayName": "onStart" + }, + { + "name": "onEnd", + "displayName": "onEnd" + } + ], + "outputs": [ + { + "name": "system", + "displayName": "system" + } + ], + "blendMode": 0, + "capacity": 1000, + "manualEmitCount": -1, + "startDelay": 0, + "updateSpeed": 0.0167, + "preWarmCycles": 0, + "preWarmStepOffset": 0, + "isBillboardBased": true, + "billBoardMode": 7, + "isLocal": false, + "disposeOnStop": false, + "doNoStart": false, + "renderingGroupId": 0 + } + ], + "comment": "Sphere-emitting particles with color fade.", + "name": "ColoredParticles", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 680, + "y": 0 + }, + { + "blockId": 2, + "x": 340, + "y": 0 + }, + { + "blockId": 3, + "x": 0, + "y": 0 + }, + { + "blockId": 4, + "x": 0, + "y": 180 + }, + { + "blockId": 5, + "x": 1020, + "y": 0 + }, + { + "blockId": 6, + "x": 680, + "y": 180 + }, + { + "blockId": 7, + "x": 1360, + "y": 0 + }, + { + "blockId": 8, + "x": 1700, + "y": 0 + }, + { + "blockId": 9, + "x": 1360, + "y": 180 + }, + { + "blockId": 10, + "x": 1700, + "y": 180 + }, + { + "blockId": 11, + "x": 2040, + "y": 0 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/npe-mcp-server/examples/ConeFountain.json b/packages/tools/npe-mcp-server/examples/ConeFountain.json new file mode 100644 index 00000000000..8034d2e45c4 --- /dev/null +++ b/packages/tools/npe-mcp-server/examples/ConeFountain.json @@ -0,0 +1,397 @@ +{ + "customType": "BABYLON.NodeParticleSystemSet", + "blocks": [ + { + "customType": "BABYLON.ConeShapeBlock", + "id": 1, + "name": "cone", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 2, + "targetConnectionName": "particle" + }, + { + "name": "radius", + "displayName": "radius" + }, + { + "name": "angle", + "displayName": "angle" + }, + { + "name": "radiusRange", + "displayName": "radiusRange" + }, + { + "name": "heightRange", + "displayName": "heightRange" + }, + { + "name": "directionRandomizer", + "displayName": "directionRandomizer" + }, + { + "name": "direction1", + "displayName": "direction1" + }, + { + "name": "direction2", + "displayName": "direction2" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.CreateParticleBlock", + "id": 2, + "name": "create", + "inputs": [ + { + "name": "emitPower", + "displayName": "emitPower" + }, + { + "name": "lifeTime", + "displayName": "lifeTime", + "inputName": "lifeTime", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "color", + "displayName": "color" + }, + { + "name": "colorDead", + "displayName": "colorDead" + }, + { + "name": "scale", + "displayName": "scale", + "inputName": "scale", + "targetBlockId": 4, + "targetConnectionName": "output" + }, + { + "name": "angle", + "displayName": "angle" + }, + { + "name": "size", + "displayName": "size" + } + ], + "outputs": [ + { + "name": "particle", + "displayName": "particle" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 3, + "name": "lifetime", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 0, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": 4, + "valueType": "number" + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 4, + "name": "scale", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 4, + "contextualValue": 0, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": [ + 0.1, + 0.1 + ], + "valueType": "BABYLON.Vector2" + }, + { + "customType": "BABYLON.UpdateAgeBlock", + "id": 5, + "name": "updateAge", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "age", + "displayName": "age", + "inputName": "age", + "targetBlockId": 6, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 6, + "name": "ageInput", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 3, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true + }, + { + "customType": "BABYLON.UpdateDirectionBlock", + "id": 7, + "name": "updateDir", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 5, + "targetConnectionName": "output" + }, + { + "name": "direction", + "displayName": "direction", + "inputName": "direction", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 8, + "name": "dirInput", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 8, + "contextualValue": 2, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true + }, + { + "customType": "BABYLON.BasicPositionUpdateBlock", + "id": 9, + "name": "updatePos", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 7, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ParticleTextureSourceBlock", + "id": 10, + "name": "texture", + "inputs": [], + "outputs": [ + { + "name": "texture", + "displayName": "texture" + } + ], + "invertY": true, + "serializedCachedData": false, + "url": "https://assets.babylonjs.com/textures/flare.png" + }, + { + "customType": "BABYLON.SystemBlock", + "id": 11, + "name": "system", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 9, + "targetConnectionName": "output" + }, + { + "name": "emitRate", + "displayName": "emitRate" + }, + { + "name": "texture", + "displayName": "texture", + "inputName": "texture", + "targetBlockId": 10, + "targetConnectionName": "texture" + }, + { + "name": "translationPivot", + "displayName": "translationPivot" + }, + { + "name": "textureMask", + "displayName": "textureMask" + }, + { + "name": "targetStopDuration", + "displayName": "targetStopDuration" + }, + { + "name": "onStart", + "displayName": "onStart" + }, + { + "name": "onEnd", + "displayName": "onEnd" + } + ], + "outputs": [ + { + "name": "system", + "displayName": "system" + } + ], + "blendMode": 0, + "capacity": 1000, + "manualEmitCount": -1, + "startDelay": 0, + "updateSpeed": 0.0167, + "preWarmCycles": 0, + "preWarmStepOffset": 0, + "isBillboardBased": true, + "billBoardMode": 7, + "isLocal": false, + "disposeOnStop": false, + "doNoStart": false, + "renderingGroupId": 0 + } + ], + "comment": "Fountain effect using a cone shape.", + "name": "ConeFountain", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 680, + "y": 0 + }, + { + "blockId": 2, + "x": 340, + "y": 0 + }, + { + "blockId": 3, + "x": 0, + "y": 0 + }, + { + "blockId": 4, + "x": 0, + "y": 180 + }, + { + "blockId": 5, + "x": 1020, + "y": 0 + }, + { + "blockId": 6, + "x": 680, + "y": 180 + }, + { + "blockId": 7, + "x": 1360, + "y": 0 + }, + { + "blockId": 8, + "x": 1020, + "y": 180 + }, + { + "blockId": 9, + "x": 1700, + "y": 0 + }, + { + "blockId": 10, + "x": 1700, + "y": 180 + }, + { + "blockId": 11, + "x": 2040, + "y": 0 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/npe-mcp-server/examples/GravityFountain.json b/packages/tools/npe-mcp-server/examples/GravityFountain.json new file mode 100644 index 00000000000..16fef8c597b --- /dev/null +++ b/packages/tools/npe-mcp-server/examples/GravityFountain.json @@ -0,0 +1,514 @@ +{ + "customType": "BABYLON.NodeParticleSystemSet", + "blocks": [ + { + "customType": "BABYLON.CreateParticleBlock", + "id": 1, + "name": "create", + "inputs": [ + { + "name": "emitPower", + "displayName": "emitPower", + "inputName": "emitPower", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "lifeTime", + "displayName": "lifeTime", + "inputName": "lifeTime", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "color", + "displayName": "color" + }, + { + "name": "colorDead", + "displayName": "colorDead" + }, + { + "name": "scale", + "displayName": "scale" + }, + { + "name": "angle", + "displayName": "angle" + }, + { + "name": "size", + "displayName": "size" + } + ], + "outputs": [ + { + "name": "particle", + "displayName": "particle" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 2, + "name": "lifetime", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 0, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": 3, + "valueType": "number" + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 3, + "name": "emitPower", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 0, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": 8, + "valueType": "number" + }, + { + "customType": "BABYLON.ConeShapeBlock", + "id": 4, + "name": "cone", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 1, + "targetConnectionName": "particle" + }, + { + "name": "radius", + "displayName": "radius" + }, + { + "name": "angle", + "displayName": "angle" + }, + { + "name": "radiusRange", + "displayName": "radiusRange" + }, + { + "name": "heightRange", + "displayName": "heightRange" + }, + { + "name": "directionRandomizer", + "displayName": "directionRandomizer" + }, + { + "name": "direction1", + "displayName": "direction1" + }, + { + "name": "direction2", + "displayName": "direction2" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.UpdateAgeBlock", + "id": 5, + "name": "updateAge", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 4, + "targetConnectionName": "output" + }, + { + "name": "age", + "displayName": "age", + "inputName": "age", + "targetBlockId": 6, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 6, + "name": "ageInput", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 3, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 7, + "name": "gravity", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 8, + "contextualValue": 0, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": [ + 0, + -9.81, + 0 + ], + "valueType": "BABYLON.Vector3" + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 8, + "name": "deltaTime", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 0, + "systemSource": 2, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true + }, + { + "customType": "BABYLON.ParticleMathBlock", + "id": 9, + "name": "gravityDelta", + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 2 + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 10, + "name": "currentDir", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 8, + "contextualValue": 2, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true + }, + { + "customType": "BABYLON.ParticleMathBlock", + "id": 11, + "name": "addGravity", + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 10, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 0 + }, + { + "customType": "BABYLON.UpdateDirectionBlock", + "id": 12, + "name": "updateDir", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 5, + "targetConnectionName": "output" + }, + { + "name": "direction", + "displayName": "direction", + "inputName": "direction", + "targetBlockId": 11, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.BasicPositionUpdateBlock", + "id": 13, + "name": "updatePos", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 12, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ParticleTextureSourceBlock", + "id": 14, + "name": "texture", + "inputs": [], + "outputs": [ + { + "name": "texture", + "displayName": "texture" + } + ], + "invertY": true, + "serializedCachedData": false, + "url": "https://assets.babylonjs.com/textures/flare.png" + }, + { + "customType": "BABYLON.SystemBlock", + "id": 15, + "name": "system", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 13, + "targetConnectionName": "output" + }, + { + "name": "emitRate", + "displayName": "emitRate" + }, + { + "name": "texture", + "displayName": "texture", + "inputName": "texture", + "targetBlockId": 14, + "targetConnectionName": "texture" + }, + { + "name": "translationPivot", + "displayName": "translationPivot" + }, + { + "name": "textureMask", + "displayName": "textureMask" + }, + { + "name": "targetStopDuration", + "displayName": "targetStopDuration" + }, + { + "name": "onStart", + "displayName": "onStart" + }, + { + "name": "onEnd", + "displayName": "onEnd" + } + ], + "outputs": [ + { + "name": "system", + "displayName": "system" + } + ], + "blendMode": 0, + "capacity": 1000, + "manualEmitCount": -1, + "startDelay": 0, + "updateSpeed": 0.0167, + "preWarmCycles": 0, + "preWarmStepOffset": 0, + "isBillboardBased": true, + "billBoardMode": 7, + "isLocal": false, + "disposeOnStop": false, + "doNoStart": false, + "renderingGroupId": 0 + } + ], + "comment": "Cone fountain with gravity pulling particles down.", + "name": "GravityFountain", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 340, + "y": 0 + }, + { + "blockId": 2, + "x": 0, + "y": 0 + }, + { + "blockId": 3, + "x": 0, + "y": 180 + }, + { + "blockId": 4, + "x": 680, + "y": 0 + }, + { + "blockId": 5, + "x": 1020, + "y": 0 + }, + { + "blockId": 6, + "x": 680, + "y": 180 + }, + { + "blockId": 7, + "x": 340, + "y": 180 + }, + { + "blockId": 8, + "x": 340, + "y": 360 + }, + { + "blockId": 9, + "x": 680, + "y": 360 + }, + { + "blockId": 10, + "x": 680, + "y": 540 + }, + { + "blockId": 11, + "x": 1020, + "y": 180 + }, + { + "blockId": 12, + "x": 1360, + "y": 0 + }, + { + "blockId": 13, + "x": 1700, + "y": 0 + }, + { + "blockId": 14, + "x": 1700, + "y": 180 + }, + { + "blockId": 15, + "x": 2040, + "y": 0 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/npe-mcp-server/examples/MathParticles.json b/packages/tools/npe-mcp-server/examples/MathParticles.json new file mode 100644 index 00000000000..322cc9e91c9 --- /dev/null +++ b/packages/tools/npe-mcp-server/examples/MathParticles.json @@ -0,0 +1,412 @@ +{ + "customType": "BABYLON.NodeParticleSystemSet", + "blocks": [ + { + "customType": "BABYLON.BoxShapeBlock", + "id": 1, + "name": "shape", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 2, + "targetConnectionName": "particle" + }, + { + "name": "direction1", + "displayName": "direction1" + }, + { + "name": "direction2", + "displayName": "direction2" + }, + { + "name": "minEmitBox", + "displayName": "minEmitBox" + }, + { + "name": "maxEmitBox", + "displayName": "maxEmitBox" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.CreateParticleBlock", + "id": 2, + "name": "create", + "inputs": [ + { + "name": "emitPower", + "displayName": "emitPower" + }, + { + "name": "lifeTime", + "displayName": "lifeTime", + "inputName": "lifeTime", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "color", + "displayName": "color" + }, + { + "name": "colorDead", + "displayName": "colorDead" + }, + { + "name": "scale", + "displayName": "scale" + }, + { + "name": "angle", + "displayName": "angle" + }, + { + "name": "size", + "displayName": "size" + } + ], + "outputs": [ + { + "name": "particle", + "displayName": "particle" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 3, + "name": "lifetime", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 0, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": 3, + "valueType": "number" + }, + { + "customType": "BABYLON.UpdateAgeBlock", + "id": 4, + "name": "updateAge", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "age", + "displayName": "age", + "inputName": "age", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 5, + "name": "ageInput", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 3, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 6, + "name": "direction", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 8, + "contextualValue": 2, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 7, + "name": "factor", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 0, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": 2.5, + "valueType": "number" + }, + { + "customType": "BABYLON.ParticleMathBlock", + "id": 8, + "name": "mulDir", + "inputs": [ + { + "name": "left", + "displayName": "left", + "inputName": "left", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "right", + "displayName": "right", + "inputName": "right", + "targetBlockId": 7, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "operation": 2 + }, + { + "customType": "BABYLON.UpdateDirectionBlock", + "id": 9, + "name": "updateDir", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 4, + "targetConnectionName": "output" + }, + { + "name": "direction", + "displayName": "direction", + "inputName": "direction", + "targetBlockId": 8, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.BasicPositionUpdateBlock", + "id": 10, + "name": "updatePos", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 9, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ParticleTextureSourceBlock", + "id": 11, + "name": "texture", + "inputs": [], + "outputs": [ + { + "name": "texture", + "displayName": "texture" + } + ], + "invertY": true, + "serializedCachedData": false, + "url": "https://assets.babylonjs.com/textures/flare.png" + }, + { + "customType": "BABYLON.SystemBlock", + "id": 12, + "name": "system", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 10, + "targetConnectionName": "output" + }, + { + "name": "emitRate", + "displayName": "emitRate" + }, + { + "name": "texture", + "displayName": "texture", + "inputName": "texture", + "targetBlockId": 11, + "targetConnectionName": "texture" + }, + { + "name": "translationPivot", + "displayName": "translationPivot" + }, + { + "name": "textureMask", + "displayName": "textureMask" + }, + { + "name": "targetStopDuration", + "displayName": "targetStopDuration" + }, + { + "name": "onStart", + "displayName": "onStart" + }, + { + "name": "onEnd", + "displayName": "onEnd" + } + ], + "outputs": [ + { + "name": "system", + "displayName": "system" + } + ], + "blendMode": 0, + "capacity": 1000, + "manualEmitCount": -1, + "startDelay": 0, + "updateSpeed": 0.0167, + "preWarmCycles": 0, + "preWarmStepOffset": 0, + "isBillboardBased": true, + "billBoardMode": 7, + "isLocal": false, + "disposeOnStop": false, + "doNoStart": false, + "renderingGroupId": 0 + } + ], + "comment": "Particles with math-modified direction.", + "name": "MathParticles", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 680, + "y": 0 + }, + { + "blockId": 2, + "x": 340, + "y": 0 + }, + { + "blockId": 3, + "x": 0, + "y": 0 + }, + { + "blockId": 4, + "x": 1020, + "y": 0 + }, + { + "blockId": 5, + "x": 680, + "y": 180 + }, + { + "blockId": 6, + "x": 680, + "y": 360 + }, + { + "blockId": 7, + "x": 680, + "y": 540 + }, + { + "blockId": 8, + "x": 1020, + "y": 180 + }, + { + "blockId": 9, + "x": 1360, + "y": 0 + }, + { + "blockId": 10, + "x": 1700, + "y": 0 + }, + { + "blockId": 11, + "x": 1700, + "y": 180 + }, + { + "blockId": 12, + "x": 2040, + "y": 0 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/npe-mcp-server/examples/MultiSystemParticles.json b/packages/tools/npe-mcp-server/examples/MultiSystemParticles.json new file mode 100644 index 00000000000..579684f42ff --- /dev/null +++ b/packages/tools/npe-mcp-server/examples/MultiSystemParticles.json @@ -0,0 +1,537 @@ +{ + "customType": "BABYLON.NodeParticleSystemSet", + "blocks": [ + { + "customType": "BABYLON.BoxShapeBlock", + "id": 1, + "name": "shape1", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 2, + "targetConnectionName": "particle" + }, + { + "name": "direction1", + "displayName": "direction1" + }, + { + "name": "direction2", + "displayName": "direction2" + }, + { + "name": "minEmitBox", + "displayName": "minEmitBox" + }, + { + "name": "maxEmitBox", + "displayName": "maxEmitBox" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.CreateParticleBlock", + "id": 2, + "name": "create1", + "inputs": [ + { + "name": "emitPower", + "displayName": "emitPower" + }, + { + "name": "lifeTime", + "displayName": "lifeTime", + "inputName": "lifeTime", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "color", + "displayName": "color" + }, + { + "name": "colorDead", + "displayName": "colorDead" + }, + { + "name": "scale", + "displayName": "scale" + }, + { + "name": "angle", + "displayName": "angle" + }, + { + "name": "size", + "displayName": "size" + } + ], + "outputs": [ + { + "name": "particle", + "displayName": "particle" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 3, + "name": "lifetime1", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 0, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": 2, + "valueType": "number" + }, + { + "customType": "BABYLON.UpdateAgeBlock", + "id": 4, + "name": "updateAge1", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "age", + "displayName": "age", + "inputName": "age", + "targetBlockId": 5, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 5, + "name": "ageInput1", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 3, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true + }, + { + "customType": "BABYLON.ParticleTextureSourceBlock", + "id": 6, + "name": "texture1", + "inputs": [], + "outputs": [ + { + "name": "texture", + "displayName": "texture" + } + ], + "invertY": true, + "serializedCachedData": false, + "url": "https://assets.babylonjs.com/textures/flare.png" + }, + { + "customType": "BABYLON.SystemBlock", + "id": 7, + "name": "system1", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 4, + "targetConnectionName": "output" + }, + { + "name": "emitRate", + "displayName": "emitRate" + }, + { + "name": "texture", + "displayName": "texture", + "inputName": "texture", + "targetBlockId": 6, + "targetConnectionName": "texture" + }, + { + "name": "translationPivot", + "displayName": "translationPivot" + }, + { + "name": "textureMask", + "displayName": "textureMask" + }, + { + "name": "targetStopDuration", + "displayName": "targetStopDuration" + }, + { + "name": "onStart", + "displayName": "onStart" + }, + { + "name": "onEnd", + "displayName": "onEnd" + } + ], + "outputs": [ + { + "name": "system", + "displayName": "system" + } + ], + "blendMode": 0, + "capacity": 1000, + "manualEmitCount": -1, + "startDelay": 0, + "updateSpeed": 0.0167, + "preWarmCycles": 0, + "preWarmStepOffset": 0, + "isBillboardBased": true, + "billBoardMode": 7, + "isLocal": false, + "disposeOnStop": false, + "doNoStart": false, + "renderingGroupId": 0 + }, + { + "customType": "BABYLON.SphereShapeBlock", + "id": 8, + "name": "shape2", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 9, + "targetConnectionName": "particle" + }, + { + "name": "radius", + "displayName": "radius" + }, + { + "name": "radiusRange", + "displayName": "radiusRange" + }, + { + "name": "directionRandomizer", + "displayName": "directionRandomizer" + }, + { + "name": "direction1", + "displayName": "direction1" + }, + { + "name": "direction2", + "displayName": "direction2" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.CreateParticleBlock", + "id": 9, + "name": "create2", + "inputs": [ + { + "name": "emitPower", + "displayName": "emitPower" + }, + { + "name": "lifeTime", + "displayName": "lifeTime", + "inputName": "lifeTime", + "targetBlockId": 10, + "targetConnectionName": "output" + }, + { + "name": "color", + "displayName": "color" + }, + { + "name": "colorDead", + "displayName": "colorDead" + }, + { + "name": "scale", + "displayName": "scale" + }, + { + "name": "angle", + "displayName": "angle" + }, + { + "name": "size", + "displayName": "size" + } + ], + "outputs": [ + { + "name": "particle", + "displayName": "particle" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 10, + "name": "lifetime2", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 0, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true, + "value": 5, + "valueType": "number" + }, + { + "customType": "BABYLON.UpdateAgeBlock", + "id": 11, + "name": "updateAge2", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 8, + "targetConnectionName": "output" + }, + { + "name": "age", + "displayName": "age", + "inputName": "age", + "targetBlockId": 12, + "targetConnectionName": "output" + } + ], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ] + }, + { + "customType": "BABYLON.ParticleInputBlock", + "id": 12, + "name": "ageInput2", + "inputs": [], + "outputs": [ + { + "name": "output", + "displayName": "output" + } + ], + "type": 2, + "contextualValue": 3, + "systemSource": 0, + "min": 0, + "max": 0, + "groupInInspector": "", + "displayInInspector": true + }, + { + "customType": "BABYLON.ParticleTextureSourceBlock", + "id": 13, + "name": "texture2", + "inputs": [], + "outputs": [ + { + "name": "texture", + "displayName": "texture" + } + ], + "invertY": true, + "serializedCachedData": false, + "url": "https://assets.babylonjs.com/textures/flare.png" + }, + { + "customType": "BABYLON.SystemBlock", + "id": 14, + "name": "system2", + "inputs": [ + { + "name": "particle", + "displayName": "particle", + "inputName": "particle", + "targetBlockId": 11, + "targetConnectionName": "output" + }, + { + "name": "emitRate", + "displayName": "emitRate" + }, + { + "name": "texture", + "displayName": "texture", + "inputName": "texture", + "targetBlockId": 13, + "targetConnectionName": "texture" + }, + { + "name": "translationPivot", + "displayName": "translationPivot" + }, + { + "name": "textureMask", + "displayName": "textureMask" + }, + { + "name": "targetStopDuration", + "displayName": "targetStopDuration" + }, + { + "name": "onStart", + "displayName": "onStart" + }, + { + "name": "onEnd", + "displayName": "onEnd" + } + ], + "outputs": [ + { + "name": "system", + "displayName": "system" + } + ], + "blendMode": 0, + "capacity": 1000, + "manualEmitCount": -1, + "startDelay": 0, + "updateSpeed": 0.0167, + "preWarmCycles": 0, + "preWarmStepOffset": 0, + "isBillboardBased": true, + "billBoardMode": 7, + "isLocal": false, + "disposeOnStop": false, + "doNoStart": false, + "renderingGroupId": 0 + } + ], + "comment": "Two independent particle systems in a single set.", + "name": "MultiSystemParticles", + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 680, + "y": 0 + }, + { + "blockId": 2, + "x": 340, + "y": 0 + }, + { + "blockId": 3, + "x": 0, + "y": 0 + }, + { + "blockId": 4, + "x": 1020, + "y": 0 + }, + { + "blockId": 5, + "x": 680, + "y": 180 + }, + { + "blockId": 6, + "x": 1020, + "y": 180 + }, + { + "blockId": 7, + "x": 1360, + "y": 0 + }, + { + "blockId": 8, + "x": 680, + "y": 360 + }, + { + "blockId": 9, + "x": 340, + "y": 180 + }, + { + "blockId": 10, + "x": 0, + "y": 180 + }, + { + "blockId": 11, + "x": 1020, + "y": 360 + }, + { + "blockId": 12, + "x": 680, + "y": 540 + }, + { + "blockId": 13, + "x": 1020, + "y": 540 + }, + { + "blockId": 14, + "x": 1360, + "y": 180 + } + ] + } +} \ No newline at end of file diff --git a/packages/tools/npe-mcp-server/package.json b/packages/tools/npe-mcp-server/package.json new file mode 100644 index 00000000000..d2287ba0af4 --- /dev/null +++ b/packages/tools/npe-mcp-server/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tools/npe-mcp-server", + "version": "1.0.0", + "private": true, + "description": "MCP server for AI-driven Node Particle Editor 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", + "@tools/snippet-loader": "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/npe-mcp-server/rollup.config.mjs b/packages/tools/npe-mcp-server/rollup.config.mjs new file mode 100644 index 00000000000..b6a4d32812a --- /dev/null +++ b/packages/tools/npe-mcp-server/rollup.config.mjs @@ -0,0 +1,3 @@ +import { createConfig } from "../rollup.config.mcp.mjs"; + +export default createConfig(); diff --git a/packages/tools/npe-mcp-server/src/blockRegistry.ts b/packages/tools/npe-mcp-server/src/blockRegistry.ts new file mode 100644 index 00000000000..a341ebcf457 --- /dev/null +++ b/packages/tools/npe-mcp-server/src/blockRegistry.ts @@ -0,0 +1,795 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * Complete registry of all Node Particle block types available in Babylon.js. + * Each entry describes the block's class name, category, and its inputs/outputs. + */ + +/** + * Describes a single input or output connection point on a block. + */ +export interface IConnectionPointInfo { + /** Name of the connection point (e.g. "particle", "output") */ + name: string; + /** Data type of the connection point (e.g. "Float", "Vector3", "Particle") */ + type: string; + /** Whether the connection is optional */ + isOptional?: boolean; +} + +/** + * Describes a block type in the NPE catalog. + */ +export interface IBlockTypeInfo { + /** The Babylon.js class name for this block */ + className: string; + /** Category for grouping (e.g. "Shape", "Update", "Math", "System") */ + category: string; + /** Human-readable description of what this block does */ + description: string; + /** List of input connection points */ + inputs: IConnectionPointInfo[]; + /** List of output connection points */ + outputs: IConnectionPointInfo[]; + /** Extra properties that can be configured on the block */ + properties?: Record; + /** + * Default property values to bake into newly created blocks of this type. + * These are REQUIRED by the Babylon deserialiser – omitting them can cause + * build-time crashes. + */ + defaultSerializedProperties?: Record; +} + +/** + * Full catalog of block types. This is the canonical reference an AI agent uses + * to know which blocks exist and what ports they have. + * NodeParticleBlock is the non-creatable base class; the catalog exposes its concrete subclasses only. + */ +export const BlockRegistry: Record = { + // ═══════════════════════════════════════════════════════════════════════ + // Input + // ═══════════════════════════════════════════════════════════════════════ + ParticleInputBlock: { + className: "ParticleInputBlock", + category: "Input", + description: + "Provides input values to the particle graph. Can be configured as a contextual source " + + "(Position, Direction, Age, Lifetime, Color, etc.), a system source (Time, Delta, Emitter, CameraPosition), " + + "or a constant value (Float, Int, Vector2, Vector3, Color4, Matrix).", + inputs: [], + outputs: [{ name: "output", type: "AutoDetect" }], + properties: { + type: "NodeParticleBlockConnectionPointTypes — the data type (Int, Float, Vector2, Vector3, Color4, Matrix, Texture)", + contextualValue: + "NodeParticleContextualSources — None, Position, Direction, Age, Lifetime, Color, ScaledDirection, Scale, " + + "AgeGradient, Angle, SpriteCellIndex, SpriteCellStart, SpriteCellEnd, InitialColor, ColorDead, " + + "InitialDirection, ColorStep, ScaledColorStep, LocalPositionUpdated, Size, DirectionScale", + systemSource: "NodeParticleSystemSources — None, Time, Delta, Emitter, CameraPosition", + value: "The actual constant value (number, Vector2, Vector3, Color4, Matrix)", + min: "number — minimum value for inspector slider", + max: "number — maximum value for inspector slider", + }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // System (Output) + // ═══════════════════════════════════════════════════════════════════════ + SystemBlock: { + className: "SystemBlock", + category: "System", + description: + "The output block that produces a particle system. Every particle graph needs at least one. " + + "A graph can have multiple SystemBlocks, each producing a separate ParticleSystem.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "emitRate", type: "Int", isOptional: true }, + { name: "texture", type: "Texture" }, + { name: "translationPivot", type: "Vector2", isOptional: true }, + { name: "textureMask", type: "Color4", isOptional: true }, + { name: "targetStopDuration", type: "Float", isOptional: true }, + { name: "onStart", type: "System", isOptional: true }, + { name: "onEnd", type: "System", isOptional: true }, + ], + outputs: [{ name: "system", type: "System" }], + properties: { + blendMode: "number — blend mode (0=OneOne, 1=Standard, 2=Add, 3=Multiply, 4=MultiplyAdd). Default: 0", + capacity: "number — max particles in system. Default: 1000", + manualEmitCount: "number — manual emit count (-1 for auto). Default: -1", + startDelay: "number — delay before start in ms. Default: 0", + updateSpeed: "number — update speed. Default: 0.0167", + preWarmCycles: "number — pre-warm cycles. Default: 0", + preWarmStepOffset: "number — pre-warm step multiplier. Default: 0", + isBillboardBased: "boolean — billboard-based particles. Default: true", + billBoardMode: "number — billboard mode (7=All, 2=Y, 6=Stretched, 9=StretchedLocal). Default: 7", + isLocal: "boolean — local coordinate space. Default: false", + disposeOnStop: "boolean — dispose when stopped. Default: false", + doNoStart: "boolean — do not auto-start. Default: false", + renderingGroupId: "number — rendering group. Default: 0", + }, + defaultSerializedProperties: { + blendMode: 0, + capacity: 1000, + manualEmitCount: -1, + startDelay: 0, + updateSpeed: 0.0167, + preWarmCycles: 0, + preWarmStepOffset: 0, + isBillboardBased: true, + billBoardMode: 7, + isLocal: false, + disposeOnStop: false, + doNoStart: false, + renderingGroupId: 0, + }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Texture + // ═══════════════════════════════════════════════════════════════════════ + ParticleTextureSourceBlock: { + className: "ParticleTextureSourceBlock", + category: "Texture", + description: "Provides a texture for particles. The texture URL or cached data is configured via properties.", + inputs: [], + outputs: [{ name: "texture", type: "Texture" }], + properties: { + url: "string — URL of the particle texture image (e.g. 'https://assets.babylonjs.com/textures/flare.png')", + textureDataUrl: "string — base64 data URL alternative to url (set serializedCachedData: true)", + serializedCachedData: "boolean — whether texture is stored as base64 data URL. Default: false", + invertY: "boolean — whether to invert Y. Default: true", + }, + defaultSerializedProperties: { invertY: true, serializedCachedData: false }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Emitter Shapes + // ═══════════════════════════════════════════════════════════════════════ + BoxShapeBlock: { + className: "BoxShapeBlock", + category: "Shape", + description: "Box-shaped emitter that spawns particles within a box region.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "direction1", type: "Vector3", isOptional: true }, + { name: "direction2", type: "Vector3", isOptional: true }, + { name: "minEmitBox", type: "Vector3", isOptional: true }, + { name: "maxEmitBox", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + SphereShapeBlock: { + className: "SphereShapeBlock", + category: "Shape", + description: "Sphere-shaped emitter that spawns particles on or within a sphere.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "radius", type: "Float", isOptional: true }, + { name: "radiusRange", type: "Float", isOptional: true }, + { name: "directionRandomizer", type: "Float", isOptional: true }, + { name: "direction1", type: "Vector3", isOptional: true }, + { name: "direction2", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + ConeShapeBlock: { + className: "ConeShapeBlock", + category: "Shape", + description: "Cone-shaped emitter that spawns particles within a cone.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "radius", type: "Float", isOptional: true }, + { name: "angle", type: "Float", isOptional: true }, + { name: "radiusRange", type: "Float", isOptional: true }, + { name: "heightRange", type: "Float", isOptional: true }, + { name: "directionRandomizer", type: "Float", isOptional: true }, + { name: "direction1", type: "Vector3", isOptional: true }, + { name: "direction2", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + CylinderShapeBlock: { + className: "CylinderShapeBlock", + category: "Shape", + description: "Cylinder-shaped emitter that spawns particles within a cylinder.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "radius", type: "Float", isOptional: true }, + { name: "height", type: "Float", isOptional: true }, + { name: "radiusRange", type: "Float", isOptional: true }, + { name: "directionRandomizer", type: "Float", isOptional: true }, + { name: "direction1", type: "Vector3", isOptional: true }, + { name: "direction2", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + PointShapeBlock: { + className: "PointShapeBlock", + category: "Shape", + description: "Point emitter that spawns particles from a single point.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "direction1", type: "Vector3", isOptional: true }, + { name: "direction2", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + CustomShapeBlock: { + className: "CustomShapeBlock", + category: "Shape", + description: "Custom emitter that spawns particles using a user-defined function.", + inputs: [{ name: "particle", type: "Particle" }], + outputs: [{ name: "output", type: "Particle" }], + }, + + MeshShapeBlock: { + className: "MeshShapeBlock", + category: "Shape", + description: "Mesh-based emitter that spawns particles on the surface of a mesh.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "direction1", type: "Vector3", isOptional: true }, + { name: "direction2", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "output", type: "Particle" }], + properties: { + useMeshNormalsForDirection: "boolean — use mesh normals for direction. Default: true", + }, + defaultSerializedProperties: { useMeshNormalsForDirection: true }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Setup + // ═══════════════════════════════════════════════════════════════════════ + CreateParticleBlock: { + className: "CreateParticleBlock", + category: "Setup", + description: "Creates a new particle with initial properties (emit power, lifetime, color, scale, angle, size).", + inputs: [ + { name: "emitPower", type: "Float", isOptional: true }, + { name: "lifeTime", type: "Float", isOptional: true }, + { name: "color", type: "Color4", isOptional: true }, + { name: "colorDead", type: "Color4", isOptional: true }, + { name: "scale", type: "Vector2", isOptional: true }, + { name: "angle", type: "Float", isOptional: true }, + { name: "size", type: "Float", isOptional: true }, + ], + outputs: [{ name: "particle", type: "Particle" }], + }, + + SetupSpriteSheetBlock: { + className: "SetupSpriteSheetBlock", + category: "Setup", + description: "Configures sprite sheet animation for particles.", + inputs: [{ name: "particle", type: "Particle" }], + outputs: [{ name: "output", type: "Particle" }], + properties: { + start: "number — start cell index. Default: 0", + end: "number — end cell index. Default: 8", + width: "number — sprite sheet width. Default: 64", + height: "number — sprite sheet height. Default: 64", + spriteCellChangeSpeed: "number — cell change speed. Default: 1", + loop: "boolean — loop sprite animation. Default: false", + randomStartCell: "boolean — random start cell. Default: false", + }, + defaultSerializedProperties: { + start: 0, + end: 8, + width: 64, + height: 64, + spriteCellChangeSpeed: 1, + loop: false, + randomStartCell: false, + }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Update Blocks + // ═══════════════════════════════════════════════════════════════════════ + UpdatePositionBlock: { + className: "UpdatePositionBlock", + category: "Update", + description: "Updates a particle's position with a new Vector3 value.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "position", type: "Vector3" }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + UpdateDirectionBlock: { + className: "UpdateDirectionBlock", + category: "Update", + description: "Updates a particle's direction with a new Vector3 value.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "direction", type: "Vector3" }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + UpdateColorBlock: { + className: "UpdateColorBlock", + category: "Update", + description: "Updates a particle's color with a new Color4 value.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "color", type: "Color4" }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + UpdateScaleBlock: { + className: "UpdateScaleBlock", + category: "Update", + description: "Updates a particle's scale with a new Vector2 value.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "scale", type: "Vector2" }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + UpdateSizeBlock: { + className: "UpdateSizeBlock", + category: "Update", + description: "Updates a particle's size with a new Float value.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "size", type: "Float" }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + UpdateAngleBlock: { + className: "UpdateAngleBlock", + category: "Update", + description: "Updates a particle's angle (rotation) with a new Float value.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "angle", type: "Float" }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + UpdateAgeBlock: { + className: "UpdateAgeBlock", + category: "Update", + description: "Updates a particle's age with a new Float value.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "age", type: "Float" }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + BasicPositionUpdateBlock: { + className: "BasicPositionUpdateBlock", + category: "Update", + description: "Applies basic position update to a particle (position += direction * delta).", + inputs: [{ name: "particle", type: "Particle" }], + outputs: [{ name: "output", type: "Particle" }], + }, + + BasicColorUpdateBlock: { + className: "BasicColorUpdateBlock", + category: "Update", + description: "Applies basic color interpolation between initial color and dead color over particle lifetime.", + inputs: [{ name: "particle", type: "Particle" }], + outputs: [{ name: "output", type: "Particle" }], + }, + + BasicSpriteUpdateBlock: { + className: "BasicSpriteUpdateBlock", + category: "Update", + description: "Applies basic sprite sheet update to a particle over its lifetime.", + inputs: [{ name: "particle", type: "Particle" }], + outputs: [{ name: "output", type: "Particle" }], + }, + + UpdateSpriteCellIndexBlock: { + className: "UpdateSpriteCellIndexBlock", + category: "Update", + description: "Updates a particle's sprite cell index with a new Int value.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "cellIndex", type: "Int" }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + UpdateFlowMapBlock: { + className: "UpdateFlowMapBlock", + category: "Update", + description: "Updates a particle's position using a flow map texture.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "flowMap", type: "Texture" }, + { name: "strength", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + UpdateNoiseBlock: { + className: "UpdateNoiseBlock", + category: "Update", + description: "Updates a particle's position using a noise texture.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "noiseTexture", type: "Texture" }, + { name: "strength", type: "Vector3", isOptional: true }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + UpdateAttractorBlock: { + className: "UpdateAttractorBlock", + category: "Update", + description: "Applies an attractor force to the particle, pulling it toward a point.", + inputs: [ + { name: "particle", type: "Particle" }, + { name: "attractor", type: "Vector3", isOptional: true }, + { name: "strength", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + AlignAngleBlock: { + className: "AlignAngleBlock", + category: "Update", + description: "Aligns the angle of a particle to its direction vector.", + inputs: [{ name: "particle", type: "Particle" }], + outputs: [{ name: "output", type: "Particle" }], + properties: { + alignment: "number — alignment offset in radians. Default: PI/2", + }, + defaultSerializedProperties: { alignment: Math.PI / 2 }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Triggers + // ═══════════════════════════════════════════════════════════════════════ + ParticleTriggerBlock: { + className: "ParticleTriggerBlock", + category: "Trigger", + description: "Triggers sub-emitters or other systems based on particle events. " + "Connects particle flow to a system for spawning sub-particles.", + inputs: [ + { name: "input", type: "Particle" }, + { name: "condition", type: "Float", isOptional: true }, + { name: "system", type: "System" }, + ], + outputs: [{ name: "output", type: "Particle" }], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Math Standard + // ═══════════════════════════════════════════════════════════════════════ + ParticleMathBlock: { + className: "ParticleMathBlock", + category: "Math", + description: "Applies standard math operations (Add, Subtract, Multiply, Divide, Max, Min) to two inputs.", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + operation: "ParticleMathBlockOperations — Add (0), Subtract (1), Multiply (2), Divide (3), Max (4), Min (5). Default: Add", + }, + defaultSerializedProperties: { operation: 0 }, + }, + + ParticleNumberMathBlock: { + className: "ParticleNumberMathBlock", + category: "Math", + description: "Applies number-only math (Modulo, Pow) to two numeric inputs.", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + operation: "ParticleNumberMathBlockOperations — Modulo (0), Pow (1). Default: Modulo", + }, + defaultSerializedProperties: { operation: 0 }, + }, + + ParticleVectorMathBlock: { + className: "ParticleVectorMathBlock", + category: "Math", + description: "Applies vector-only math (Dot, Distance) to two Vector3 inputs.", + inputs: [ + { name: "left", type: "Vector3" }, + { name: "right", type: "Vector3" }, + ], + outputs: [{ name: "output", type: "Float" }], + properties: { + operation: "ParticleVectorMathBlockOperations — Dot (0), Distance (1). Default: Dot", + }, + defaultSerializedProperties: { operation: 0 }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Math Scientific + // ═══════════════════════════════════════════════════════════════════════ + ParticleTrigonometryBlock: { + className: "ParticleTrigonometryBlock", + category: "Math", + description: + "Applies trigonometric/scientific operations (Cos, Sin, Abs, Exp, Exp2, Round, Floor, Ceiling, " + + "Sqrt, Log, Tan, ArcTan, ArcCos, ArcSin, Sign, Negate, OneMinus, Reciprocal, ToDegrees, ToRadians, Fract).", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + operation: + "ParticleTrigonometryBlockOperations — Cos (0), Sin (1), Abs (2), Exp (3), Exp2 (4), Round (5), " + + "Floor (6), Ceiling (7), Sqrt (8), Log (9), Tan (10), ArcTan (11), ArcCos (12), ArcSin (13), " + + "Sign (14), Negate (15), OneMinus (16), Reciprocal (17), ToDegrees (18), ToRadians (19), Fract (20). Default: Cos", + }, + defaultSerializedProperties: { operation: 0 }, + }, + + ParticleVectorLengthBlock: { + className: "ParticleVectorLengthBlock", + category: "Math", + description: "Computes the length of a vector input.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "Float" }], + }, + + ParticleFloatToIntBlock: { + className: "ParticleFloatToIntBlock", + category: "Math", + description: "Converts a float to an int using Round, Ceil, Floor, or Truncate.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "Int" }], + properties: { + operation: "ParticleFloatToIntBlockOperations — Round (0), Ceil (1), Floor (2), Truncate (3). Default: Round", + }, + defaultSerializedProperties: { operation: 0 }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Conditions + // ═══════════════════════════════════════════════════════════════════════ + ParticleConditionBlock: { + className: "ParticleConditionBlock", + category: "Condition", + description: "Evaluates a condition (Equal, NotEqual, LessThan, GreaterThan, etc.) and returns ifTrue or ifFalse value.", + inputs: [ + { name: "left", type: "Float" }, + { name: "right", type: "Float", isOptional: true }, + { name: "ifTrue", type: "AutoDetect", isOptional: true }, + { name: "ifFalse", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + test: + "ParticleConditionBlockTests — Equal (0), NotEqual (1), LessThan (2), GreaterThan (3), " + + "LessOrEqual (4), GreaterOrEqual (5), Xor (6), Or (7), And (8). Default: Equal", + epsilon: "number — epsilon for comparison. Default: 0", + }, + defaultSerializedProperties: { test: 0, epsilon: 0 }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Interpolation + // ═══════════════════════════════════════════════════════════════════════ + ParticleLerpBlock: { + className: "ParticleLerpBlock", + category: "Interpolation", + description: "Linearly interpolates between two values based on a gradient.", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + { name: "gradient", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + ParticleNLerpBlock: { + className: "ParticleNLerpBlock", + category: "Interpolation", + description: "Normalised linear interpolation between two values.", + inputs: [ + { name: "left", type: "AutoDetect" }, + { name: "right", type: "AutoDetect" }, + { name: "gradient", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + ParticleSmoothStepBlock: { + className: "ParticleSmoothStepBlock", + category: "Interpolation", + description: "Smooth step interpolation between two edges.", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "edge0", type: "Float", isOptional: true }, + { name: "edge1", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + ParticleStepBlock: { + className: "ParticleStepBlock", + category: "Interpolation", + description: "Step function: returns 0 if value < edge, else 1.", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "edge", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + ParticleClampBlock: { + className: "ParticleClampBlock", + category: "Interpolation", + description: "Clamps a value between min and max.", + inputs: [ + { name: "value", type: "AutoDetect" }, + { name: "min", type: "Float", isOptional: true }, + { name: "max", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + ParticleGradientBlock: { + className: "ParticleGradientBlock", + category: "Interpolation", + description: "Defines a multi-stop gradient. Dynamically extends with more value inputs as needed.", + inputs: [ + { name: "gradient", type: "Float", isOptional: true }, + { name: "value0", type: "AutoDetect" }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + ParticleGradientValueBlock: { + className: "ParticleGradientValueBlock", + category: "Interpolation", + description: "Defines a single gradient entry with a reference position (0-1).", + inputs: [{ name: "value", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + reference: "number — gradient position 0–1. Default: 0", + }, + defaultSerializedProperties: { reference: 0 }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Random + // ═══════════════════════════════════════════════════════════════════════ + ParticleRandomBlock: { + className: "ParticleRandomBlock", + category: "Misc", + description: "Generates a random value between min and max.", + inputs: [ + { name: "min", type: "AutoDetect", isOptional: true }, + { name: "max", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + lockMode: "ParticleRandomBlockLocks — None (0), PerParticle (1), PerSystem (2), OncePerParticle (3). Default: PerParticle", + }, + defaultSerializedProperties: { lockMode: 1 }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Converter + // ═══════════════════════════════════════════════════════════════════════ + ParticleConverterBlock: { + className: "ParticleConverterBlock", + category: "Converter", + description: + "Converts between Color4/Vector3/Vector2/Float components. " + + "NOTE: Input port names have a TRAILING SPACE to disambiguate from outputs. " + + "When connecting to inputs, use 'color ', 'xyz ', 'xy ', 'zw ', 'x ', 'y ', 'z ', 'w ' (with trailing space).", + inputs: [ + { name: "color ", type: "Color4", isOptional: true }, + { name: "xyz ", type: "Vector3", isOptional: true }, + { name: "xy ", type: "Vector2", isOptional: true }, + { name: "zw ", type: "Vector2", isOptional: true }, + { name: "x ", type: "Float", isOptional: true }, + { name: "y ", type: "Float", isOptional: true }, + { name: "z ", type: "Float", isOptional: true }, + { name: "w ", type: "Float", isOptional: true }, + ], + outputs: [ + { name: "color", type: "Color4" }, + { name: "xyz", type: "Vector3" }, + { name: "xy", type: "Vector2" }, + { name: "zw", type: "Vector2" }, + { name: "x", type: "Float" }, + { name: "y", type: "Float" }, + { name: "z", type: "Float" }, + { name: "w", type: "Float" }, + ], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Utility + // ═══════════════════════════════════════════════════════════════════════ + ParticleDebugBlock: { + className: "ParticleDebugBlock", + category: "Utility", + description: "Debug block that passes through its input and can log values.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + ParticleElbowBlock: { + className: "ParticleElbowBlock", + category: "Utility", + description: "Pass-through block used for visual routing in the editor.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + ParticleLocalVariableBlock: { + className: "ParticleLocalVariableBlock", + category: "Utility", + description: "Stores a local variable scoped to a particle or loop iteration.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + scope: "ParticleLocalVariableBlockScope — Particle (0), Loop (1). Default: Particle", + }, + defaultSerializedProperties: { scope: 0 }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Teleport + // ═══════════════════════════════════════════════════════════════════════ + ParticleTeleportInBlock: { + className: "ParticleTeleportInBlock", + category: "Teleport", + description: "Entry point for a teleport pair. Accepts any input and teleports it to a matching TeleportOut.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [], + }, + + ParticleTeleportOutBlock: { + className: "ParticleTeleportOutBlock", + category: "Teleport", + description: "Exit point for a teleport pair. Outputs the value received from a matching TeleportIn.", + inputs: [], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, +}; + +// ─── Utility functions ──────────────────────────────────────────────────── + +/** + * Get a summary of the block catalog grouped by category. + * @returns A markdown string summarizing the available block types and their descriptions. + */ +export function GetBlockCatalogSummary(): string { + const byCategory = new Map(); + for (const [key, info] of Object.entries(BlockRegistry)) { + if (!byCategory.has(info.category)) { + byCategory.set(info.category, []); + } + byCategory.get(info.category)!.push(` ${key}: ${info.description.split(".")[0]}`); + } + + const lines: string[] = []; + for (const [cat, entries] of byCategory) { + lines.push(`\n## ${cat}`); + lines.push(...entries); + } + return lines.join("\n"); +} + +/** + * Get detailed info about a specific block type. + * @param blockType The class name of the + * @returns The IBlockTypeInfo for the given block type, or undefined if not found. + */ +export function GetBlockTypeDetails(blockType: string): IBlockTypeInfo | undefined { + return BlockRegistry[blockType]; +} diff --git a/packages/tools/npe-mcp-server/src/index.ts b/packages/tools/npe-mcp-server/src/index.ts new file mode 100644 index 00000000000..3ec03b939ab --- /dev/null +++ b/packages/tools/npe-mcp-server/src/index.ts @@ -0,0 +1,1181 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-console */ +/** + * Node Particle MCP Server (babylonjs-node-particle) + * ────────────── + * A Model Context Protocol server that exposes tools for building Babylon.js + * Node Particle System Sets programmatically. An AI agent (or any MCP client) can: + * + * • Create / manage particle system graphs + * • Add blocks from the full NPE block catalog + * • Connect blocks together + * • Set block properties (contextual sources, system sources, operations, etc.) + * • Validate the graph + * • Export the final particle JSON (loadable by NPE / NodeParticleSystemSet.Parse) + * • Import existing NPE 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, + CreateJsonFileSchema, + CreateJsonImportResponse, + CreateOutputFileSchema, + CreateSnippetIdSchema, + CreateTypedSnippetImportResponse, + McpEditorSessionController, + ParseJsonText, + RunSnippetResponse, + WriteTextFileEnsuringDirectory, +} from "@tools/mcp-server-core"; + +import { BlockRegistry, GetBlockCatalogSummary, GetBlockTypeDetails } from "./blockRegistry.js"; +import { ParticleGraphManager } from "./particleGraph.js"; +import { LoadSnippet, SaveSnippet, type IDataSnippetResult } from "@tools/snippet-loader"; + +// ─── Singleton graph manager ────────────────────────────────────────────── +const manager = new ParticleGraphManager(); +const sessionController = new McpEditorSessionController( + { + serverName: "NPE MCP Session Server", + documentKind: "node-particle", + managerUnavailableMessage: "Particle graph manager is not available", + getDocument: (manager, session) => manager.exportJSON(session.name), + setDocument: (manager, session, document) => { + const result = manager.importJSON(session.name, document); + return result && result !== "OK" ? result : undefined; + }, + }, + { + defaultPort: 3001, + statusTitle: "NPE MCP Session Server", + } +); + +/** + * Notify SSE subscribers if a session exists for the given particle system set. + * @param particleSystemName - The particle system set name to check for active sessions. + */ +function _notifyIfSession(particleSystemName: string): void { + const sessionId = sessionController.getSessionIdForName(particleSystemName); + if (sessionId) { + sessionController.notifySessionUpdate(sessionId); + } +} + +/** + * Import particle system JSON and notify a matching live session on success. + * @param particleSystemName - The particle system set name to import into. + * @param jsonText - Serialized NPE JSON. + * @returns "OK" on success, or an error string. + */ +function _importParticleSystemJson(particleSystemName: string, jsonText: string): string { + const result = manager.importJSON(particleSystemName, jsonText); + if (result === "OK") { + _notifyIfSession(particleSystemName); + } + return result; +} + +// ─── MCP Server ─────────────────────────────────────────────────────────── +const server = new McpServer( + { + name: "babylonjs-node-particle", + version: "1.0.0", + }, + { + instructions: [ + "You build Babylon.js Node Particle System Sets via particle graphs. Workflow: create_particle_system → add blocks (ParticleInputBlock sources, shape blocks, CreateParticleBlock, update blocks, SystemBlock) → connect ports → validate_particle_system → export_particle_system_json.", + "Every particle graph needs at least one SystemBlock. Use get_block_type_info to discover ports before connecting.", + "Output JSON can be consumed by the Scene MCP to add particle effects.", + ].join(" "), + } +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Resources (read-only reference data) +// ═══════════════════════════════════════════════════════════════════════════ + +server.registerResource("block-catalog", "npe://block-catalog", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: `# NPE Block Catalog\n${GetBlockCatalogSummary()}`, + }, + ], +})); + +server.registerResource("enums", "npe://enums", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# NPE Enumerations Reference", + "", + "## NodeParticleBlockConnectionPointTypes", + "Int (0x01), Float (0x02), Vector2 (0x04), Vector3 (0x08), Matrix (0x10), " + + "Particle (0x20), Texture (0x40), Color4 (0x80), FloatGradient (0x100), " + + "Vector2Gradient (0x200), Vector3Gradient (0x400), Color4Gradient (0x800), " + + "System (0x1000), AutoDetect (0x2000), BasedOnInput (0x4000), Undefined (0x8000)", + "", + "## NodeParticleContextualSources (for ParticleInputBlock)", + "None (0x0000), Position (0x0001), Direction (0x0002), Age (0x0003), Lifetime (0x0004), " + + "Color (0x0005), ScaledDirection (0x0006), Scale (0x0007), AgeGradient (0x0008), " + + "Angle (0x0009), SpriteCellIndex (0x0010), SpriteCellStart (0x0011), " + + "SpriteCellEnd (0x0012), InitialColor (0x0013), ColorDead (0x0014), " + + "InitialDirection (0x0015), ColorStep (0x0016), ScaledColorStep (0x0017), " + + "LocalPositionUpdated (0x0018), Size (0x0019), DirectionScale (0x0020)", + "", + "## NodeParticleSystemSources (for ParticleInputBlock)", + "None (0), Time (1), Delta (2), Emitter (3), CameraPosition (4)", + "", + "## ParticleMathBlockOperations (for ParticleMathBlock)", + "Add (0), Subtract (1), Multiply (2), Divide (3), Max (4), Min (5)", + "", + "## ParticleTrigonometryBlockOperations (for ParticleTrigonometryBlock)", + "Cos (0), Sin (1), Abs (2), Exp (3), Exp2 (4), Round (5), Floor (6), Ceiling (7), " + + "Sqrt (8), Log (9), Tan (10), ArcTan (11), ArcCos (12), ArcSin (13), Sign (14), " + + "Negate (15), OneMinus (16), Reciprocal (17), ToDegrees (18), ToRadians (19), Fract (20)", + "", + "## ParticleConditionBlockTests (for ParticleConditionBlock)", + "Equal (0), NotEqual (1), LessThan (2), GreaterThan (3), LessOrEqual (4), " + "GreaterOrEqual (5), Xor (6), Or (7), And (8)", + "", + "## ParticleNumberMathBlockOperations (for ParticleNumberMathBlock)", + "Modulo (0), Pow (1)", + "", + "## ParticleVectorMathBlockOperations (for ParticleVectorMathBlock)", + "Dot (0), Distance (1)", + "", + "## ParticleFloatToIntBlockOperations (for ParticleFloatToIntBlock)", + "Round (0), Ceil (1), Floor (2), Truncate (3)", + "", + "## ParticleRandomBlockLocks (for ParticleRandomBlock)", + "None (0), PerParticle (1), PerSystem (2), OncePerParticle (3)", + "", + "## ParticleLocalVariableBlockScope (for ParticleLocalVariableBlock)", + "Particle (0), Loop (1)", + ].join("\n"), + }, + ], +})); + +server.registerResource("concepts", "npe://concepts", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# Node Particle System Concepts", + "", + "## What is a Node Particle System Set?", + "A Node Particle System Set is a visual, graph-based particle effect builder in Babylon.js.", + "Instead of configuring particle systems from code, you connect typed blocks that represent", + "particle operations. The graph evaluates at runtime to produce one or more ParticleSystems.", + "", + "## Graph Structure — SystemBlock (One or More)", + "Every Node Particle graph MUST have at least one SystemBlock.", + " • **SystemBlock** — receives the final System connection and produces a particle system", + "Unlike NGE (which has a single GeometryOutputBlock), NPE supports MULTIPLE SystemBlocks,", + "each producing a separate particle system in the set.", + "", + "## ParticleInputBlock — Contextual Sources, System Sources & Constants", + "ParticleInputBlock is the source of all external data entering the graph. It has three modes:", + "", + "### Mode 1: Contextual Source (per-particle data)", + "Reads per-particle attributes from the active particle system.", + " • Set `contextualValue` to one of: Position, Direction, Age, Lifetime, Color,", + " ScaledDirection, Scale, AgeGradient, Angle, SpriteCellIndex, SpriteCellStart,", + " SpriteCellEnd, InitialColor, ColorDead, InitialDirection, ColorStep,", + " ScaledColorStep, LocalPositionUpdated, Size, DirectionScale", + " • The `type` is automatically derived from the contextual source:", + " - Position/Direction/ScaledDirection/InitialDirection/LocalPositionUpdated → Vector3", + " - Scale → Vector2", + " - Color/InitialColor/ColorDead/ColorStep/ScaledColorStep → Color4", + " - Age/Lifetime/Angle/AgeGradient/Size/DirectionScale → Float", + " - SpriteCellIndex/SpriteCellStart/SpriteCellEnd → Int", + "", + "### Mode 2: System Source (per-system data)", + "Reads system-level attributes.", + " • Set `systemSource` to one of: Time, Delta, Emitter, CameraPosition", + " • The `type` is automatically derived from the system source:", + " - Time/Delta → Float", + " - Emitter/CameraPosition → Vector3", + "", + "### Mode 3: Constant Value", + "Provides a fixed value of a specific type.", + " • Set `type` to: Int, Float, Vector2, Vector3, Color4, Matrix", + " • Set `value` to the constant (number, or {x,y}, {x,y,z}, {r,g,b,a}, or flat array)", + "", + "## Particle Lifecycle", + "", + "### 1. Shape — Where particles spawn", + "Shape blocks define the emission volume: BoxShapeBlock, SphereShapeBlock, ConeShapeBlock,", + "CylinderShapeBlock, PointShapeBlock, CustomShapeBlock, MeshShapeBlock.", + "Connect CreateParticleBlock.particle output to a shape block's 'particle' input.", + "The shape block positions the particle and outputs it on 'output'.", + "", + "### 2. Create — Birth a particle", + "CreateParticleBlock receives optional initial parameters (emitPower, lifeTime, color, colorDead, scale, angle, size).", + "It outputs a newly born Particle that flows into a shape block, then update blocks.", + "", + "### 3. Update — Per-frame particle mutation", + "Update blocks modify particle properties each frame:", + " • UpdatePositionBlock, UpdateDirectionBlock, UpdateColorBlock, UpdateScaleBlock,", + " UpdateSizeBlock, UpdateAngleBlock, UpdateAgeBlock", + " • BasicPositionUpdateBlock, BasicColorUpdateBlock, BasicSpriteUpdateBlock", + " • UpdateFlowMapBlock, UpdateNoiseBlock, UpdateAttractorBlock, AlignAngleBlock", + "Each update block takes a Particle in and outputs a Particle out.", + "Chain them: Shape.output → UpdateAge.particle → UpdateAge.output → UpdatePosition → ... → SystemBlock.particle", + "", + "### 4. System — Output", + "SystemBlock receives the final Particle stream and produces a ParticleSystem.", + "", + "## The Simplest Particle Graph", + "```", + "ParticleInputBlock(type:'Float', value:2) \u2192 CreateParticleBlock.lifeTime", + "CreateParticleBlock.particle \u2192 BoxShapeBlock.particle", + "BoxShapeBlock.output \u2192 UpdateAgeBlock.particle", + "ParticleInputBlock(contextualValue:'Age') → UpdateAgeBlock.age", + "UpdateAgeBlock.output → SystemBlock.particle", + "ParticleTextureSourceBlock.texture → SystemBlock.texture", + "```", + "", + "## ParticleTextureSourceBlock — Must Set url Property", + "A ParticleTextureSourceBlock MUST have a `url` property set, otherwise the particle system renders empty.", + "Use add_block with properties: { url: 'https://assets.babylonjs.com/textures/flare.png' } (default particle sprite).", + "", + "## ParticleConverterBlock — Important: Trailing-Space Input Names", + "ParticleConverterBlock input port names have a TRAILING SPACE to disambiguate from outputs:", + " Inputs: 'color ' (Color4), 'xyz ' (Vector3), 'xy ' (Vector2),", + " 'x ' (Float), 'y ' (Float), 'z ' (Float), 'w ' (Float)", + " Outputs: 'color' (Color4), 'xyz' (Vector3), 'xy' (Vector2),", + " 'x' (Float), 'y' (Float), 'z' (Float), 'w' (Float)", + "When calling connect_blocks to a ParticleConverterBlock INPUT, you MUST include the trailing space.", + "", + "## Gravity / Deceleration Pattern", + "BasicPositionUpdateBlock does NOT apply gravity or drag — it simply adds scaledDirection to position.", + "To make particles decelerate or fall, wire a gravity vector into the direction update chain:", + "```", + "ParticleInputBlock(type:'Vector3', value:{x:0,y:-9.81,z:0}) [Gravity]", + "ParticleInputBlock(systemSource:'Delta') [DeltaTime]", + "ParticleMathBlock(operation:'Multiply') ← Gravity + DeltaTime → gravityDelta", + "ParticleInputBlock(contextualValue:'Direction') [CurrentDir]", + "ParticleMathBlock(operation:'Add') ← CurrentDir + gravityDelta → newDir", + "UpdateDirectionBlock ← particle + newDir", + "BasicPositionUpdateBlock ← UpdateDirectionBlock.output", + "```", + "This modifies the particle's direction each frame so it arcs downward (or in any direction).", + "For drag/damping, multiply direction by a factor < 1 each frame instead of adding gravity.", + "", + "## Runtime Usage — buildAsync & Manual Emit", + "After designing a graph with the NPE MCP and exporting JSON, load it at runtime like this:", + "```typescript", + "const set = NodeParticleSystemSet.Parse(jsonObject);", + "const result: ParticleSystemSet = await set.buildAsync(scene);", + "// result.systems is an IParticleSystem[] — each system is named from its SystemBlock", + "const sparks = result.systems.find(s => s.name === 'SparkSystem');", + "sparks.manualEmitCount = 10; // triggers a burst of 10 particles", + "```", + "- Setting `doNoStart: true` on a SystemBlock makes `system.canStart()` return false", + " (the system won't auto-start on build). Call `system.start()` manually when ready.", + "- `manualEmitCount` is a write-on-demand property: set it to N > 0, and the next update", + " frame emits exactly N particles, then resets to 0.", + "- To modify colors/values at runtime, access input blocks BEFORE building:", + " `set.getBlockByName('MyColor').value = new Color4(1,0,0,1);` then rebuild.", + "", + "## Common Mistakes", + "1. Forgetting SystemBlock → no particle system is produced", + "2. Creating ParticleInputBlock without contextualValue, systemSource, or value → no data", + "3. Not connecting CreateParticleBlock.particle to a shape block → particles have nowhere to spawn", + "4. Not chaining update blocks → particles don't age or move", + "5. Omitting trailing space on ParticleConverterBlock inputs", + "6. Not connecting a ParticleInputBlock(contextualValue:'Age') to UpdateAgeBlock.age → REQUIRED input", + "7. Not connecting a ParticleTextureSourceBlock to SystemBlock.texture → REQUIRED input", + "8. UpdateAgeBlock.age and SystemBlock.texture are REQUIRED — the graph will fail to build without them", + "9. Using BasicPositionUpdateBlock expecting gravity — it has none; use the gravity pattern above", + "10. Trying to change colors on built systems won't work — modify input blocks before buildAsync()", + ].join("\n"), + }, + ], +})); + +// ═══════════════════════════════════════════════════════════════════════════ +// Prompts (reusable prompt templates) +// ═══════════════════════════════════════════════════════════════════════════ + +server.registerPrompt("create-basic-particle-system", { description: "Step-by-step instructions for building a simple box-shaped particle system" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a simple particle system that emits from a box shape. Steps:", + "1. create_particle_system with name 'BasicParticles'", + "2. Add CreateParticleBlock named 'create'", + "3. Add BoxShapeBlock named 'shape'", + "4. Add ParticleInputBlock named 'lifetime' with type 'Float', value 2", + "5. Connect lifetime.output → create.lifeTime", + "6. Connect create.particle → shape.particle", + "7. Add UpdateAgeBlock named 'updateAge'", + "8. Connect shape.output → updateAge.particle", + "9. Add ParticleInputBlock named 'ageInput' with contextualValue 'Age'", + "10. Connect ageInput.output → updateAge.age", + "11. Add BasicPositionUpdateBlock named 'updatePos'", + "12. Connect updateAge.output → updatePos.particle", + "13. Add ParticleTextureSourceBlock named 'texture' with url 'https://assets.babylonjs.com/textures/flare.png'", + "14. Add SystemBlock named 'system'", + "15. Connect texture.texture → system.texture", + "16. Connect updatePos.output → system.particle", + "17. validate_particle_system, then export_particle_system_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-colored-particle-system", { description: "Step-by-step instructions for creating a particle system with color fading" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a particle system with color that fades from white to transparent. Steps:", + "1. create_particle_system with name 'ColoredParticles'", + "2. Add CreateParticleBlock named 'create'", + "3. Add SphereShapeBlock named 'shape'", + "4. Add ParticleInputBlock named 'lifetime' with type 'Float', value 3", + "5. Connect lifetime.output → create.lifeTime", + "6. Add ParticleInputBlock named 'startColor' with type 'Color4', value {r:1,g:1,b:1,a:1}", + "7. Connect startColor.output → create.color", + "8. Connect create.particle → shape.particle", + "9. Add UpdateAgeBlock named 'updateAge'", + "10. Connect shape.output → updateAge.particle", + "11. Add ParticleInputBlock named 'ageInput' with contextualValue 'Age'", + "12. Connect ageInput.output → updateAge.age", + "13. Add BasicPositionUpdateBlock named 'updatePos'", + "14. Connect updateAge.output → updatePos.particle", + "15. Add UpdateColorBlock named 'updateColor'", + "16. Connect updatePos.output → updateColor.particle", + "17. Add ParticleInputBlock named 'endColor' with type 'Color4', value {r:1,g:1,b:1,a:0}", + "18. Connect endColor.output → updateColor.color", + "19. Add ParticleTextureSourceBlock named 'texture' with url 'https://assets.babylonjs.com/textures/flare.png'", + "20. Add SystemBlock named 'system'", + "21. Connect texture.texture → system.texture", + "22. Connect updateColor.output → system.particle", + "23. validate_particle_system, then export_particle_system_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-cone-fountain", { description: "Step-by-step instructions for creating a fountain effect with a cone emitter" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a fountain-like particle effect using a cone emitter. Steps:", + "1. create_particle_system with name 'Fountain'", + "2. Add CreateParticleBlock named 'create'", + "3. Add ConeShapeBlock named 'cone'", + "4. Add ParticleInputBlock named 'lifetime' with type 'Float', value 4", + "5. Connect lifetime.output → create.lifeTime", + "6. Add ParticleInputBlock named 'scale' with type 'Vector2', value {x:0.1,y:0.1}", + "7. Connect scale.output → create.scale", + "8. Connect create.particle → cone.particle", + "9. Add UpdateAgeBlock named 'updateAge'", + "10. Connect cone.output → updateAge.particle", + "11. Add ParticleInputBlock named 'ageInput' with contextualValue 'Age'", + "12. Connect ageInput.output → updateAge.age", + "13. Add BasicPositionUpdateBlock named 'updatePos'", + "14. Connect updateAge.output → updatePos.particle", + "15. Add ParticleTextureSourceBlock named 'texture' with url 'https://assets.babylonjs.com/textures/flare.png'", + "16. Add SystemBlock named 'system'", + "17. Connect texture.texture → system.texture", + "18. Connect updatePos.output → system.particle", + "19. validate_particle_system, then export_particle_system_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-gravity-fountain", { description: "Step-by-step instructions for creating a fountain with gravity (particles shoot up and fall back down)" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a fountain that shoots particles upward and lets gravity pull them down. Steps:", + "1. create_particle_system with name 'GravityFountain'", + "2. Add CreateParticleBlock named 'create'", + "3. Add ParticleInputBlock named 'lifetime' with type 'Float', value 3", + "4. Connect lifetime.output → create.lifeTime", + "5. Add ParticleInputBlock named 'emitPower' with type 'Float', value 8", + "6. Connect emitPower.output → create.emitPower", + "7. Add ConeShapeBlock named 'cone'", + "8. Connect create.particle → cone.particle", + "9. Add UpdateAgeBlock named 'updateAge'", + "10. Connect cone.output → updateAge.particle", + "11. Add ParticleInputBlock named 'ageInput' with contextualValue 'Age'", + "12. Connect ageInput.output → updateAge.age", + "--- Gravity direction update ---", + "13. Add ParticleInputBlock named 'gravity' with type 'Vector3', value {x:0,y:-9.81,z:0}", + "14. Add ParticleInputBlock named 'deltaTime' with systemSource 'Delta'", + "15. Add ParticleMathBlock named 'gravityDelta' with operation 'Multiply'", + "16. Connect gravity.output → gravityDelta.left", + "17. Connect deltaTime.output → gravityDelta.right", + "18. Add ParticleInputBlock named 'currentDir' with contextualValue 'Direction'", + "19. Add ParticleMathBlock named 'addGravity' with operation 'Add'", + "20. Connect currentDir.output → addGravity.left", + "21. Connect gravityDelta.output → addGravity.right", + "22. Add UpdateDirectionBlock named 'updateDir'", + "23. Connect updateAge.output → updateDir.particle", + "24. Connect addGravity.output → updateDir.direction", + "--- Position update ---", + "25. Add BasicPositionUpdateBlock named 'updatePos'", + "26. Connect updateDir.output → updatePos.particle", + "--- System ---", + "27. Add ParticleTextureSourceBlock named 'texture' with url 'https://assets.babylonjs.com/textures/flare.png'", + "28. Add SystemBlock named 'system'", + "29. Connect texture.texture → system.texture", + "30. Connect updatePos.output → system.particle", + "31. validate_particle_system, then export_particle_system_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-burst-system", { description: "Step-by-step instructions for creating a burst particle system with doNoStart and manualEmitCount" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a burst particle system that does NOT auto-start and is triggered manually. Steps:", + "1. create_particle_system with name 'BurstEffect'", + "2. Add CreateParticleBlock named 'create'", + "3. Add ParticleInputBlock named 'lifetime' with type 'Float', value 1.5", + "4. Connect lifetime.output → create.lifeTime", + "5. Add ParticleInputBlock named 'emitPower' with type 'Float', value 5", + "6. Connect emitPower.output → create.emitPower", + "7. Add SphereShapeBlock named 'shape'", + "8. Connect create.particle → shape.particle", + "9. Add UpdateAgeBlock named 'updateAge'", + "10. Connect shape.output → updateAge.particle", + "11. Add ParticleInputBlock named 'ageInput' with contextualValue 'Age'", + "12. Connect ageInput.output → updateAge.age", + "13. Add BasicPositionUpdateBlock named 'updatePos'", + "14. Connect updateAge.output → updatePos.particle", + "15. Add ParticleTextureSourceBlock named 'texture' with url 'https://assets.babylonjs.com/textures/flare.png'", + "16. Add SystemBlock named 'BurstSystem' with properties: { doNoStart: true, manualEmitCount: -1, capacity: 500 }", + "17. Connect texture.texture → BurstSystem.texture", + "18. Connect updatePos.output → BurstSystem.particle", + "19. validate_particle_system, then export_particle_system_json", + "", + "Runtime usage:", + " const set = NodeParticleSystemSet.Parse(json);", + " const result = await set.buildAsync(scene);", + " const burst = result.systems.find(s => s.name === 'BurstSystem');", + " // Trigger a burst of 20 particles on demand:", + " burst.start(); // needed once since doNoStart prevents auto-start", + " burst.manualEmitCount = 20;", + ].join("\n"), + }, + }, + ], +})); + +// ═══════════════════════════════════════════════════════════════════════════ +// Tools +// ═══════════════════════════════════════════════════════════════════════════ + +// ── Particle system lifecycle ────────────────────────────────────────── + +server.registerTool( + "create_particle_system", + { + description: "Create a new empty Node Particle System Set graph in memory. This is always the first step.", + inputSchema: { + name: z.string().describe("Unique name for the particle system set (e.g. 'FireEffect', 'SnowStorm')"), + comment: z.string().optional().describe("An optional description of what this particle system does"), + }, + }, + async ({ name, comment }) => { + manager.createParticleSet(name, comment); + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(name); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return { + content: [ + { + type: "text", + text: `Created particle system set "${name}". Now add blocks with add_block, connect them with connect_blocks, then export with export_particle_system_json.\n\nMCP Session URL: ${sessionUrl}`, + }, + ], + }; + } +); + +server.registerTool( + "get_session_url", + { + description: "Get or create a live-session URL for a particle system set. The URL can be pasted into a compatible Node Particle Editor MCP session panel.", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system set"), + }, + }, + async ({ particleSystemName }) => { + const particleSystems = manager.listParticleSets(); + if (!particleSystems.includes(particleSystemName)) { + return { content: [{ type: "text", text: `Particle system set "${particleSystemName}" not found.` }], isError: true }; + } + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(particleSystemName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return { content: [{ type: "text", text: `MCP Session URL: ${sessionUrl}` }] }; + } +); + +server.registerTool( + "start_session", + { + description: "Start a live session for an existing particle system set. If a session already exists for this particle system set, returns the existing URL.", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system set"), + }, + }, + async ({ particleSystemName }) => { + const particleSystems = manager.listParticleSets(); + if (!particleSystems.includes(particleSystemName)) { + return { content: [{ type: "text", text: `Particle system set "${particleSystemName}" not found.` }], isError: true }; + } + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(particleSystemName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return { content: [{ type: "text", text: `MCP Session URL: ${sessionUrl}` }] }; + } +); + +server.registerTool( + "close_session", + { + description: + "Close a live session for a particle system set. Disconnects all SSE subscribers in the editor and removes the session. The particle system set itself is NOT deleted.", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system set whose session to close"), + }, + }, + async ({ particleSystemName }) => { + const closed = sessionController.closeSessionForName(particleSystemName); + if (!closed) { + return { content: [{ type: "text", text: `No active session for "${particleSystemName}".` }] }; + } + return { content: [{ type: "text", text: `Session for "${particleSystemName}" closed. The editor will disconnect.` }] }; + } +); + +server.registerTool( + "stop_session_server", + { + description: "Stop the live MCP editor session server started by this MCP process. This closes all active sessions, disconnects editors, and releases the port.", + }, + async () => { + await sessionController.stopAsync(); + return { content: [{ type: "text", text: "MCP session server stopped. Any connected editors have been disconnected." }] }; + } +); + +server.registerTool( + "delete_particle_system", + { + description: "Delete a particle system set graph from memory.", + inputSchema: { + name: z.string().describe("Name of the particle system set to delete"), + }, + }, + async ({ name }) => { + sessionController.closeSessionForName(name); + const ok = manager.deleteParticleSet(name); + return { + content: [{ type: "text", text: ok ? `Deleted "${name}".` : `Particle system set "${name}" not found.` }], + }; + } +); + +server.registerTool("clear_all", { description: "Remove all particle system sets from memory, resetting the server to a clean state." }, async () => { + const names = manager.listParticleSets(); + for (const name of names) { + sessionController.closeSessionForName(name); + } + manager.clearAll(); + return { + content: [ + { + type: "text", + text: names.length > 0 ? `Cleared ${names.length} particle system set(s): ${names.join(", ")}` : "Nothing to clear — memory was already empty.", + }, + ], + }; +}); + +server.registerTool("list_particle_systems", { description: "List all particle system set graphs currently in memory." }, async () => { + const names = manager.listParticleSets(); + return { + content: [ + { + type: "text", + text: names.length > 0 ? `Particle system sets in memory:\n${names.map((n) => ` • ${n}`).join("\n")}` : "No particle system sets in memory.", + }, + ], + }; +}); + +// ── Block operations ──────────────────────────────────────────────────── + +server.registerTool( + "add_block", + { + description: "Add a new block to a particle system graph. Returns the block's id for use in connect_blocks.", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system set to add the block to"), + blockType: z + .string() + .describe( + "The block type from the registry (e.g. 'SystemBlock', 'ParticleInputBlock', 'CreateParticleBlock', " + + "'BoxShapeBlock', 'UpdateAgeBlock', etc.). Use list_block_types to see all." + ), + name: z.string().optional().describe("Human-friendly name for this block instance (e.g. 'mySystem', 'ageInput')"), + properties: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Key-value properties to set on the block. For ParticleInputBlock: type (Int/Float/Vector2/Vector3/Color4/Matrix), " + + "contextualValue (None/Position/Direction/Age/Lifetime/Color/etc.), systemSource (None/Time/Delta/Emitter/CameraPosition), " + + "value (the constant value), min/max (number). " + + "For ParticleMathBlock: operation (Add/Subtract/Multiply/Divide/Max/Min). " + + "For ParticleTrigonometryBlock: operation (Cos/Sin/Abs/Exp/.../Fract). " + + "For ParticleConditionBlock: test (Equal/NotEqual/LessThan/.../And). " + + "For ParticleRandomBlock: lockMode (None/PerParticle/PerSystem/OncePerParticle)." + ), + }, + }, + async ({ particleSystemName, blockType, name, properties }) => { + const result = manager.addBlock(particleSystemName, blockType, name, properties as Record); + if (typeof result === "string") { + return { content: [{ type: "text", text: `Error: ${result}` }], isError: true }; + } + _notifyIfSession(particleSystemName); + const lines = [`Added block [${result.block.id}] "${result.block.name}" (${blockType}). Use this id (${result.block.id}) to connect it.`]; + if (result.warnings) { + lines.push("", "Warnings:", ...result.warnings); + } + return { + content: [ + { + type: "text", + text: lines.join("\n"), + }, + ], + }; + } +); + +server.registerTool( + "add_blocks_batch", + { + description: "Add multiple blocks at once. More efficient than calling add_block repeatedly. Returns all created block ids.", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system set"), + blocks: z + .array( + z.object({ + blockType: z.string().describe("Block type name"), + blockName: z.string().optional().describe("Instance name for the block"), + name: z.string().optional().describe("Instance name (alias for blockName)"), + properties: z.record(z.string(), z.unknown()).optional().describe("Block properties"), + }) + ) + .describe("Array of blocks to add"), + }, + }, + async ({ particleSystemName, blocks }) => { + const results: string[] = []; + let hasSuccess = false; + for (const blockDef of blocks) { + const bName = blockDef.blockName ?? blockDef.name; + const result = manager.addBlock(particleSystemName, blockDef.blockType, bName, blockDef.properties as Record); + if (typeof result === "string") { + results.push(`Error adding ${blockDef.blockType}: ${result}`); + } else { + hasSuccess = true; + let line = `[${result.block.id}] ${result.block.name} (${blockDef.blockType})`; + if (result.warnings) { + line += `\n ⚠ ${result.warnings.join("\n ⚠ ")}`; + } + results.push(line); + } + } + if (hasSuccess) { + _notifyIfSession(particleSystemName); + } + return { content: [{ type: "text", text: `Added blocks:\n${results.join("\n")}` }] }; + } +); + +server.registerTool( + "remove_block", + { + description: "Remove a block from a particle system graph. Also removes any connections to/from it.", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system set"), + blockId: z.number().describe("The block id to remove"), + }, + }, + async ({ particleSystemName, blockId }) => { + const result = manager.removeBlock(particleSystemName, blockId); + if (result === "OK") { + _notifyIfSession(particleSystemName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Removed block ${blockId}.` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "set_block_properties", + { + description: "Set or update properties on an existing block (e.g. change a ParticleInputBlock value, set a ParticleMathBlock operation).", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system set"), + blockId: z.number().describe("The block id to modify"), + properties: z.record(z.string(), z.unknown()).describe("Key-value properties to set. Same keys as add_block's properties parameter."), + }, + }, + async ({ particleSystemName, blockId, properties }) => { + const result = manager.setBlockProperties(particleSystemName, blockId, properties as Record); + if (result === "OK") { + _notifyIfSession(particleSystemName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Updated block ${blockId}.` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +// ── Connections ────────────────────────────────────────────────────────── + +server.registerTool( + "connect_blocks", + { + description: "Connect an output of one block to an input of another block. Data flows from source output → target input.", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system set"), + sourceBlockId: z.number().describe("Block id to connect FROM (the one with the output)"), + outputName: z.string().describe("Name of the output on the source block (e.g. 'output', 'shape', 'particle')"), + targetBlockId: z.number().describe("Block id to connect TO (the one with the input)"), + inputName: z.string().describe("Name of the input on the target block (e.g. 'particle', 'system', 'shape')"), + }, + }, + async ({ particleSystemName, sourceBlockId, outputName, targetBlockId, inputName }) => { + const result = manager.connectBlocks(particleSystemName, sourceBlockId, outputName, targetBlockId, inputName); + if (result === "OK") { + _notifyIfSession(particleSystemName); + } + return { + content: [ + { + type: "text", + text: result === "OK" ? `Connected [${sourceBlockId}].${outputName} → [${targetBlockId}].${inputName}` : `Error: ${result}`, + }, + ], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "connect_blocks_batch", + { + description: "Connect multiple block pairs at once. More efficient than calling connect_blocks repeatedly.", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system set"), + connections: z + .array( + z.object({ + sourceBlockId: z.number(), + outputName: z.string(), + targetBlockId: z.number(), + inputName: z.string(), + }) + ) + .describe("Array of connections to make"), + }, + }, + async ({ particleSystemName, connections }) => { + const results: string[] = []; + let hasSuccess = false; + for (const conn of connections) { + const result = manager.connectBlocks(particleSystemName, conn.sourceBlockId, conn.outputName, conn.targetBlockId, conn.inputName); + if (result === "OK") { + hasSuccess = true; + results.push(`[${conn.sourceBlockId}].${conn.outputName} → [${conn.targetBlockId}].${conn.inputName}`); + } else { + results.push(`Error: ${result}`); + } + } + if (hasSuccess) { + _notifyIfSession(particleSystemName); + } + return { content: [{ type: "text", text: `Connections:\n${results.join("\n")}` }] }; + } +); + +server.registerTool( + "disconnect_input", + { + description: "Disconnect an input on a block (remove an existing connection).", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system set"), + blockId: z.number().describe("The block id whose input to disconnect"), + inputName: z.string().describe("Name of the input to disconnect"), + }, + }, + async ({ particleSystemName, blockId, inputName }) => { + const result = manager.disconnectInput(particleSystemName, blockId, inputName); + if (result === "OK") { + _notifyIfSession(particleSystemName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Disconnected [${blockId}].${inputName}` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +// ── Query tools ───────────────────────────────────────────────────────── + +server.registerTool( + "describe_particle_system", + { + description: "Get a human-readable description of the current state of a particle system graph, " + "including all blocks and their connections.", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system set to describe"), + }, + }, + async ({ particleSystemName }) => { + const desc = manager.describeParticleSet(particleSystemName); + return { content: [{ type: "text", text: desc }] }; + } +); + +server.registerTool( + "describe_block", + { + description: "Get detailed information about a specific block instance in a particle system, including its connections and properties.", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system set"), + blockId: z.number().describe("The block id to describe"), + }, + }, + async ({ particleSystemName, blockId }) => { + const desc = manager.describeBlock(particleSystemName, blockId); + return { content: [{ type: "text", text: desc }] }; + } +); + +server.registerTool( + "list_block_types", + { + description: "List all available NPE block types, grouped by category. Use this to discover which blocks you can add.", + inputSchema: { + category: z + .string() + .optional() + .describe( + "Optionally filter by category (Input, System, Texture, Shape, Setup, Update, Trigger, Math, Condition, " + "Interpolation, Misc, Converter, Utility, Teleport)" + ), + }, + }, + async ({ category }) => { + if (category) { + const matching = Object.entries(BlockRegistry) + .filter(([, info]) => info.category.toLowerCase() === category.toLowerCase()) + .map(([key, info]) => ` ${key}: ${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 inputs, outputs, properties, and description.", + inputSchema: { + blockType: z.string().describe("The block type name (e.g. 'SystemBlock', 'ParticleInputBlock', 'CreateParticleBlock')"), + }, + }, + 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}`); + lines.push(`Category: ${info.category}`); + lines.push(`Description: ${info.description}`); + + lines.push("\n### Inputs:"); + if (info.inputs.length === 0) { + lines.push(" (none)"); + } + for (const inp of info.inputs) { + const opt = inp.isOptional ? " (optional)" : " (required)"; + lines.push(` • ${inp.name}: ${inp.type}${opt}`); + } + + lines.push("\n### Outputs:"); + if (info.outputs.length === 0) { + lines.push(" (none)"); + } + for (const out of info.outputs) { + lines.push(` • ${out.name}: ${out.type}`); + } + + if (info.properties) { + lines.push("\n### Configurable Properties:"); + for (const [k, v] of Object.entries(info.properties)) { + lines.push(` • ${k}: ${v}`); + } + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } +); + +// ── Validation ────────────────────────────────────────────────────────── + +server.registerTool( + "validate_particle_system", + { + description: "Run validation checks on a particle system graph. Reports missing SystemBlock, unconnected required inputs, and broken references.", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system set to validate"), + }, + }, + async ({ particleSystemName }) => { + const issues = manager.validateParticleSet(particleSystemName); + return { + content: [{ type: "text", text: issues.join("\n") }], + isError: issues.some((i) => i.startsWith("ERROR")), + }; + } +); + +// ── Export / Import ───────────────────────────────────────────────────── + +server.registerTool( + "export_particle_system_json", + { + description: + "Export the particle system graph as NPE-compatible JSON. This JSON can be loaded in the Babylon.js Node Particle Editor " + + "or via NodeParticleSystemSet.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: { + particleSystemName: z.string().describe("Name of the particle system set to export"), + outputFile: CreateOutputFileSchema(z), + }, + }, + async ({ particleSystemName, outputFile }) => { + const json = manager.exportJSON(particleSystemName); + if (!json) { + return { content: [{ type: "text", text: `Particle system set "${particleSystemName}" not found.` }], isError: true }; + } + if (outputFile) { + try { + WriteTextFileEnsuringDirectory(outputFile, json); + return { content: [{ type: "text", text: `NPE JSON written to: ${outputFile}` }] }; + } catch (e) { + return { content: [{ type: "text", text: `Error writing file: ${(e as Error).message}` }], isError: true }; + } + } + return { content: [{ type: "text", text: json }] }; + } +); + +server.registerTool( + "import_particle_system_json", + { + description: + "Import an existing NPE JSON into memory for editing. You can then modify blocks, connections, etc. " + + "Provide either the inline json string OR a jsonFile path (not both).", + inputSchema: { + particleSystemName: z.string().describe("Name to give the imported particle system set"), + json: CreateInlineJsonSchema(z, "The NPE JSON string to import"), + jsonFile: CreateJsonFileSchema(z, "Absolute path to a file containing the NPE JSON to import (alternative to inline json)"), + }, + }, + async ({ particleSystemName, json, jsonFile }) => { + return CreateJsonImportResponse({ + json, + jsonFile, + fileDescription: "NPE JSON file", + importJson: (jsonText: string) => _importParticleSystemJson(particleSystemName, jsonText), + describeImported: () => manager.describeParticleSet(particleSystemName), + }); + } +); + +server.registerTool( + "import_from_snippet", + { + description: + "Import a Node Particle System from the Babylon.js Snippet Server by its snippet ID. " + + "The snippet is fetched, validated as a nodeParticle type, and loaded into memory for editing. " + + 'Snippet IDs look like "ABC123" or "ABC123#2" (with revision).', + inputSchema: { + particleSystemName: z.string().describe("Name to give the imported particle system set in memory"), + snippetId: CreateSnippetIdSchema(z), + }, + }, + async ({ particleSystemName, snippetId }) => { + return await RunSnippetResponse({ + snippetId, + loadSnippet: async (requestedSnippetId: string) => (await LoadSnippet(requestedSnippetId)) as IDataSnippetResult, + createResponse: (snippetResult: IDataSnippetResult) => + CreateTypedSnippetImportResponse({ + snippetId, + snippetResult, + expectedType: "nodeParticle", + importJson: (jsonText: string) => _importParticleSystemJson(particleSystemName, jsonText), + describeImported: () => manager.describeParticleSet(particleSystemName), + successMessage: `Imported snippet "${snippetId}" as "${particleSystemName}" successfully.`, + }), + }); + } +); + +// ── Snippet / URL helpers ─────────────────────────────────────────────── + +server.registerTool( + "get_snippet_url", + { + description: "Generate a URL that opens the particle system in the online Babylon.js Node Particle Editor. " + "The JSON is encoded in the URL fragment.", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system set"), + }, + }, + async ({ particleSystemName }) => { + const json = manager.exportJSON(particleSystemName); + if (!json) { + return { content: [{ type: "text", text: `Particle system set "${particleSystemName}" not found.` }], isError: true }; + } + const encoded = Buffer.from(json).toString("base64"); + const url = `https://npe.babylonjs.com/#${encoded}`; + return { + content: [ + { + type: "text", + text: `Open this particle system in the NPE editor:\n${url}\n\nNote: For very large graphs, use the snippet server instead.`, + }, + ], + }; + } +); + +// ── Snippet server ────────────────────────────────────────────────────── + +server.registerTool( + "save_snippet", + { + description: + "Save the particle system to the Babylon.js Snippet Server and return the snippet ID and version. " + + "The snippet can later be loaded in the Node Particle Editor via its snippet ID, or fetched with import_from_snippet. " + + "To create a new revision of an existing snippet, pass the previous snippetId.", + inputSchema: { + particleSystemName: z.string().describe("Name of the particle system to save"), + snippetId: z.string().optional().describe('Optional existing snippet ID to create a new revision of (e.g. "ABC123" or "ABC123#1")'), + name: z.string().optional().describe("Optional human-readable title for the snippet"), + description: z.string().optional().describe("Optional description"), + tags: z.string().optional().describe("Optional comma-separated tags"), + }, + }, + async ({ particleSystemName, snippetId, name, description, tags }) => { + const json = manager.exportJSON(particleSystemName); + if (!json) { + return { content: [{ type: "text", text: `Particle system "${particleSystemName}" not found.` }], isError: true }; + } + try { + const result = await SaveSnippet( + { type: "nodeParticle", data: ParseJsonText({ jsonText: json, jsonLabel: "NPE JSON" }) }, + { snippetId, metadata: { name, description, tags } } + ); + return { + content: [ + { + type: "text", + text: `Saved particle system "${particleSystemName}" to snippet server.\n\nSnippet ID: ${result.id}\nVersion: ${result.version}\nFull ID: ${result.snippetId}\n\nLoad in NPE editor: https://npe.babylonjs.com/#${result.snippetId}`, + }, + ], + }; + } catch (e) { + return { content: [{ type: "text", text: `Error saving snippet: ${(e as Error).message}` }], isError: true }; + } + } +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Start the server +// ═══════════════════════════════════════════════════════════════════════════ + +async function Main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Babylon.js Node Particle Editor MCP Server running on stdio"); +} + +try { + await Main(); +} catch (err) { + console.error("Fatal error:", err); + process.exit(1); +} + +const _shutdown = () => { + void sessionController.stopAsync(); + process.exit(0); +}; +process.on("SIGINT", _shutdown); +process.on("SIGTERM", _shutdown); diff --git a/packages/tools/npe-mcp-server/src/particleGraph.ts b/packages/tools/npe-mcp-server/src/particleGraph.ts new file mode 100644 index 00000000000..2d10a1fee56 --- /dev/null +++ b/packages/tools/npe-mcp-server/src/particleGraph.ts @@ -0,0 +1,1137 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * ParticleGraphManager – holds an in-memory representation of a Node Particle + * System Set that the MCP tools build up incrementally. When the user is + * satisfied, the graph can be serialised to the NPE JSON format that Babylon.js + * understands. + * + * Design goals + * ──────────── + * 1. **No Babylon.js runtime dependency** – the MCP server must remain a light, + * standalone process. We therefore work purely with a JSON data model that + * mirrors the serialisation format NodeParticleSystemSet.serialize() produces. + * 2. **Idempotent & stateful** – the manager stores the current graph in memory + * so an AI agent can add blocks, connect them, tweak properties, and finally + * export. Multiple particle system sets can coexist (keyed by name). + */ + +import { BlockRegistry, type IBlockTypeInfo } from "./blockRegistry.js"; + +// ─── Types ──────────────────────────────────────────────────────────────── + +/** + * Serialized form of a single connection point (input or output) on a block. + */ +export interface ISerializedConnectionPoint { + /** Name of the connection point */ + name: string; + /** Display name shown in the NPE editor */ + displayName?: string; + /** The input-side name this feeds into on the target block */ + inputName?: string; + /** ID of the block this connection links to */ + targetBlockId?: number; + /** Name of the output on the linked block */ + targetConnectionName?: string; + /** Whether exposed on the NPE editor frame */ + isExposedOnFrame?: boolean; + /** Position when exposed on frame */ + exposedPortPosition?: number; +} + +/** + * Serialized form of a single Node Particle block. + */ +export interface ISerializedBlock { + /** The Babylon.js class identifier (e.g. "BABYLON.SystemBlock") */ + customType: string; + /** Unique block identifier within the particle system */ + id: number; + /** Human-friendly block name */ + name: string; + /** Input connection points */ + inputs: ISerializedConnectionPoint[]; + /** Output connection points */ + outputs: ISerializedConnectionPoint[]; + /** Block-specific values (e.g. operation, type, value …) */ + [key: string]: unknown; +} + +/** + * Serialized form of a complete Node Particle System Set. + */ +export interface ISerializedParticleSystemSet { + /** Mark the format */ + customType: string; + /** All blocks in the particle graph */ + blocks: ISerializedBlock[]; + /** NPE editor layout data */ + editorData?: { + /** Block positions in the editor */ + locations: Array<{ /** Block ID */ blockId: number; /** X coordinate */ x: number; /** Y coordinate */ y: number }>; + }; + /** Optional comment / description */ + comment?: string; + /** Name of the set */ + name?: string; +} + +// ─── Enum mappings ──────────────────────────────────────────────────────── + +/** Mapping from human-readable type names to NodeParticleBlockConnectionPointTypes enum values */ +export const ConnectionPointTypes: Record = { + Int: 0x0001, + Float: 0x0002, + Vector2: 0x0004, + Vector3: 0x0008, + Matrix: 0x0010, + Particle: 0x0020, + Texture: 0x0040, + Color4: 0x0080, + FloatGradient: 0x0100, + Vector2Gradient: 0x0200, + Vector3Gradient: 0x0400, + Color4Gradient: 0x0800, + System: 0x1000, + AutoDetect: 0x2000, + BasedOnInput: 0x4000, + Undefined: 0x8000, +}; + +/** Mapping from human-readable contextual source names to NodeParticleContextualSources enum values */ +export const ContextualSources: Record = { + None: 0x0000, + Position: 0x0001, + Direction: 0x0002, + Age: 0x0003, + Lifetime: 0x0004, + Color: 0x0005, + ScaledDirection: 0x0006, + Scale: 0x0007, + AgeGradient: 0x0008, + Angle: 0x0009, + SpriteCellIndex: 0x0010, + SpriteCellStart: 0x0011, + SpriteCellEnd: 0x0012, + InitialColor: 0x0013, + ColorDead: 0x0014, + InitialDirection: 0x0015, + ColorStep: 0x0016, + ScaledColorStep: 0x0017, + LocalPositionUpdated: 0x0018, + Size: 0x0019, + DirectionScale: 0x0020, +}; + +/** Mapping from human-readable system source names to NodeParticleSystemSources enum values */ +export const SystemSources: Record = { + None: 0, + Time: 1, + Delta: 2, + Emitter: 3, + CameraPosition: 4, +}; + +/** + * Auto-derive the connection point type from a contextual source. + */ +const ContextualSourceToType: Record = { + [ContextualSources.None]: ConnectionPointTypes.AutoDetect, + [ContextualSources.Position]: ConnectionPointTypes.Vector3, + [ContextualSources.Direction]: ConnectionPointTypes.Vector3, + [ContextualSources.ScaledDirection]: ConnectionPointTypes.Vector3, + [ContextualSources.InitialDirection]: ConnectionPointTypes.Vector3, + [ContextualSources.LocalPositionUpdated]: ConnectionPointTypes.Vector3, + [ContextualSources.Color]: ConnectionPointTypes.Color4, + [ContextualSources.InitialColor]: ConnectionPointTypes.Color4, + [ContextualSources.ColorDead]: ConnectionPointTypes.Color4, + [ContextualSources.ColorStep]: ConnectionPointTypes.Color4, + [ContextualSources.ScaledColorStep]: ConnectionPointTypes.Color4, + [ContextualSources.Scale]: ConnectionPointTypes.Vector2, + [ContextualSources.Age]: ConnectionPointTypes.Float, + [ContextualSources.Lifetime]: ConnectionPointTypes.Float, + [ContextualSources.Angle]: ConnectionPointTypes.Float, + [ContextualSources.AgeGradient]: ConnectionPointTypes.Float, + [ContextualSources.Size]: ConnectionPointTypes.Float, + [ContextualSources.DirectionScale]: ConnectionPointTypes.Float, + [ContextualSources.SpriteCellIndex]: ConnectionPointTypes.Int, + [ContextualSources.SpriteCellStart]: ConnectionPointTypes.Int, + [ContextualSources.SpriteCellEnd]: ConnectionPointTypes.Int, +}; + +/** + * Auto-derive the connection point type from a system source. + */ +const SystemSourceToType: Record = { + [SystemSources.None]: ConnectionPointTypes.AutoDetect, + [SystemSources.Time]: ConnectionPointTypes.Float, + [SystemSources.Delta]: ConnectionPointTypes.Float, + [SystemSources.Emitter]: ConnectionPointTypes.Vector3, + [SystemSources.CameraPosition]: ConnectionPointTypes.Vector3, +}; + +// ─── Block Enum Properties ──────────────────────────────────────────────── + +/** ParticleMathBlockOperations */ +const MathBlockOperations: Record = { + Add: 0, + Subtract: 1, + Multiply: 2, + Divide: 3, + Max: 4, + Min: 5, +}; + +/** ParticleTrigonometryBlockOperations */ +const TrigonometryOperations: Record = { + Cos: 0, + Sin: 1, + Abs: 2, + Exp: 3, + Exp2: 4, + Round: 5, + Floor: 6, + Ceiling: 7, + Sqrt: 8, + Log: 9, + Tan: 10, + ArcTan: 11, + ArcCos: 12, + ArcSin: 13, + Sign: 14, + Negate: 15, + OneMinus: 16, + Reciprocal: 17, + ToDegrees: 18, + ToRadians: 19, + Fract: 20, +}; + +/** ParticleConditionBlockTests */ +const ConditionBlockTests: Record = { + Equal: 0, + NotEqual: 1, + LessThan: 2, + GreaterThan: 3, + LessOrEqual: 4, + GreaterOrEqual: 5, + Xor: 6, + Or: 7, + And: 8, +}; + +/** ParticleNumberMathBlockOperations */ +const NumberMathOperations: Record = { + Modulo: 0, + Pow: 1, +}; + +/** ParticleVectorMathBlockOperations */ +const VectorMathOperations: Record = { + Dot: 0, + Distance: 1, +}; + +/** ParticleFloatToIntBlockOperations */ +const FloatToIntOperations: Record = { + Round: 0, + Ceil: 1, + Floor: 2, + Truncate: 3, +}; + +/** ParticleRandomBlockLocks */ +const RandomBlockLocks: Record = { + None: 0, + PerParticle: 1, + PerSystem: 2, + OncePerParticle: 3, +}; + +/** ParticleLocalVariableBlockScope */ +const LocalVariableScope: Record = { + Particle: 0, + Loop: 1, +}; + +/** + * Maps block type names to their property→enum-map pairs. + * When a property value is a string, we look up the numeric equivalent here. + */ +const BlockEnumProperties: Record>> = { + ParticleMathBlock: { operation: MathBlockOperations }, + ParticleTrigonometryBlock: { operation: TrigonometryOperations }, + ParticleConditionBlock: { test: ConditionBlockTests }, + ParticleNumberMathBlock: { operation: NumberMathOperations }, + ParticleVectorMathBlock: { operation: VectorMathOperations }, + ParticleFloatToIntBlock: { operation: FloatToIntOperations }, + ParticleRandomBlock: { lockMode: RandomBlockLocks }, + ParticleLocalVariableBlock: { scope: LocalVariableScope }, +}; + +// ─── Manager ────────────────────────────────────────────────────────────── + +/** + * Holds in-memory representations of Node Particle System Sets that MCP tools build up incrementally. + */ +export class ParticleGraphManager { + /** All managed particle system sets, keyed by name. */ + private _particleSets = new Map(); + /** Auto-increment block id counter per set */ + private _nextId = new Map(); + /** Layout tracking for aesthetic NPE positioning */ + private _nextX = new Map(); + + // ── Lifecycle ────────────────────────────────────────────────────── + + /** + * Create a new empty particle system set. + * @param name - Unique name for the particle set. + * @param comment - Optional comment/description. + * @returns The newly created serialized particle set. + */ + createParticleSet(name: string, comment?: string): ISerializedParticleSystemSet { + const pset: ISerializedParticleSystemSet = { + customType: "BABYLON.NodeParticleSystemSet", + blocks: [], + comment, + name, + }; + this._particleSets.set(name, pset); + this._nextId.set(name, 1); + this._nextX.set(name, 0); + return pset; + } + + /** + * Retrieve a particle set by name. + * @param name - The name of the particle set. + * @returns The particle set, or undefined if not found. + */ + getParticleSet(name: string): ISerializedParticleSystemSet | undefined { + return this._particleSets.get(name); + } + + /** + * List the names of all managed particle sets. + * @returns Array of particle set names. + */ + listParticleSets(): string[] { + return Array.from(this._particleSets.keys()); + } + + /** + * Delete a particle set by name. + * @param name - The name of the particle set to delete. + * @returns True if the set was deleted, false if it was not found. + */ + deleteParticleSet(name: string): boolean { + this._nextId.delete(name); + this._nextX.delete(name); + return this._particleSets.delete(name); + } + + /** + * Remove all particle sets from memory. + */ + clearAll(): void { + this._particleSets.clear(); + this._nextId.clear(); + this._nextX.clear(); + } + + // ── Block CRUD ───────────────────────────────────────────────────── + + /** + * Add a block to the particle graph. + * @param setName - The name of the particle set. + * @param blockType - The type of block to add. + * @param blockName - Optional custom name for the block. + * @param properties - Optional initial properties to set on the block. + * @returns The created block with optional warnings, or an error string. + */ + addBlock(setName: string, blockType: string, blockName?: string, properties?: Record): { block: ISerializedBlock; warnings?: string[] } | string { + const pset = this._particleSets.get(setName); + if (!pset) { + return `Particle set "${setName}" not found. Create it first.`; + } + + const info: IBlockTypeInfo | undefined = BlockRegistry[blockType]; + if (!info) { + return `Unknown block type "${blockType}". Use list_block_types to see available blocks.`; + } + + const warnings: string[] = []; + + const id = this._nextId.get(setName)!; + this._nextId.set(setName, id + 1); + + const name = blockName ?? `${blockType}_${id}`; + + const block: ISerializedBlock = { + customType: `BABYLON.${info.className}`, + id, + name, + inputs: info.inputs.map((inp) => ({ + name: inp.name, + displayName: inp.name, + })), + outputs: info.outputs.map((out) => ({ + name: out.name, + displayName: out.name, + })), + }; + + // Set default ParticleInputBlock fields + if (blockType === "ParticleInputBlock") { + block["type"] = ConnectionPointTypes.Float; // Default; overridden below + block["contextualValue"] = ContextualSources.None; + block["systemSource"] = SystemSources.None; + block["min"] = 0; + block["max"] = 0; + block["groupInInspector"] = ""; + block["displayInInspector"] = true; + } + + // Apply registry-defined default properties + if (info.defaultSerializedProperties) { + for (const [key, value] of Object.entries(info.defaultSerializedProperties)) { + block[key] = value; + } + } + + // Apply user-supplied properties + if (properties) { + for (const [key, value] of Object.entries(properties)) { + if (blockType === "ParticleInputBlock" && key === "type" && typeof value === "string") { + block["type"] = ConnectionPointTypes[value] ?? value; + } else if (blockType === "ParticleInputBlock" && key === "contextualValue" && typeof value === "string") { + const cv = ContextualSources[value] ?? value; + block["contextualValue"] = cv; + block["systemSource"] = SystemSources.None; + // Auto-derive type from contextual source + if (typeof cv === "number" && cv !== ContextualSources.None && ContextualSourceToType[cv] !== undefined) { + block["type"] = ContextualSourceToType[cv]; + } + } else if (blockType === "ParticleInputBlock" && key === "systemSource" && typeof value === "string") { + const ss = SystemSources[value] ?? value; + block["systemSource"] = ss; + block["contextualValue"] = ContextualSources.None; + // Auto-derive type from system source + if (typeof ss === "number" && ss !== SystemSources.None && SystemSourceToType[ss] !== undefined) { + block["type"] = SystemSourceToType[ss]; + } + } else if (typeof value === "string" && BlockEnumProperties[blockType]?.[key]) { + block[key] = BlockEnumProperties[blockType][key][value] ?? value; + } else { + block[key] = value; + } + } + } + + // For ParticleInputBlock: check for missing data BEFORE filling defaults + if (blockType === "ParticleInputBlock") { + const cv = block["contextualValue"] as number; + const ss = block["systemSource"] as number; + const hasUserValue = block["value"] !== undefined && block["value"] !== null; + if (cv === ContextualSources.None && ss === SystemSources.None && !hasUserValue) { + warnings.push( + `⚠ ParticleInputBlock "${block.name}" has contextualValue=None, systemSource=None, and no value. ` + + `Set a contextualValue (e.g. 'Position', 'Age'), a systemSource (e.g. 'Time', 'Delta'), ` + + `or provide a constant value.` + ); + } + this._normaliseInputBlockValue(block); + this._ensureDefaultValue(block); + } + + // Track editor location for nice layout + const x = this._nextX.get(setName)!; + this._nextX.set(setName, x + 280); + if (!pset.editorData) { + pset.editorData = { locations: [] }; + } + pset.editorData.locations.push({ blockId: id, x, y: 0 }); + + pset.blocks.push(block); + return { block, warnings: warnings.length > 0 ? warnings : undefined }; + } + + /** + * Normalise a ParticleInputBlock's `value` to the format the NPE parser expects. + * @param block - The block to normalise. + */ + private _normaliseInputBlockValue(block: ISerializedBlock): void { + const val = block["value"]; + if (val === undefined || val === null) { + return; + } + + const type = block["type"] as number | undefined; + + // Scalar values + if (typeof val === "number") { + if (type === ConnectionPointTypes.Matrix) { + block["value"] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + block["valueType"] = "BABYLON.Matrix"; + return; + } + block["valueType"] = "number"; + return; + } + + // Already a flat array + if (Array.isArray(val)) { + if (!block["valueType"]) { + block["valueType"] = this._inferValueType(type, val.length); + } + return; + } + + // Object with named components → convert to flat array + if (typeof val === "object") { + const obj = val as Record; + if ("r" in obj) { + // Color4 {r, g, b, a} + block["value"] = [obj.r, obj.g, obj.b, obj.a ?? 1]; + block["valueType"] = "BABYLON.Color4"; + } else if ("x" in obj) { + if ("w" in obj) { + block["value"] = [obj.x, obj.y, obj.z, obj.w]; + block["valueType"] = "BABYLON.Color4"; // Color4 uses x,y,z,w sometimes + } else if ("z" in obj) { + block["value"] = [obj.x, obj.y, obj.z]; + block["valueType"] = "BABYLON.Vector3"; + } else { + block["value"] = [obj.x, obj.y]; + block["valueType"] = "BABYLON.Vector2"; + } + } + } + } + + /** + * Infer `valueType` from ConnectionPointTypes enum value and array length. + * @param type - The ConnectionPointTypes enum value. + * @param length - The array length of the value. + * @returns The inferred valueType string. + */ + private _inferValueType(type: number | undefined, length: number): string { + const typeMap: Record = { + [ConnectionPointTypes.Vector2]: "BABYLON.Vector2", + [ConnectionPointTypes.Vector3]: "BABYLON.Vector3", + [ConnectionPointTypes.Color4]: "BABYLON.Color4", + [ConnectionPointTypes.Matrix]: "BABYLON.Matrix", + }; + if (type !== undefined && typeMap[type]) { + return typeMap[type]; + } + + const lengthMap: Record = { + 2: "BABYLON.Vector2", + 3: "BABYLON.Vector3", + 4: "BABYLON.Color4", + 16: "BABYLON.Matrix", + }; + return lengthMap[length] ?? "BABYLON.Vector3"; + } + + /** + * Ensure that ParticleInputBlocks always have a default value. + * @param block - The block to ensure a default value for. + */ + private _ensureDefaultValue(block: ISerializedBlock): void { + if (block["value"] !== undefined && block["value"] !== null) { + return; + } + + // Contextual/system values don't need a stored value + const cv = block["contextualValue"] as number | undefined; + if (cv !== undefined && cv !== ContextualSources.None) { + return; + } + const ss = block["systemSource"] as number | undefined; + if (ss !== undefined && ss !== SystemSources.None) { + return; + } + + const type = block["type"] as number | undefined; + if (type === undefined) { + return; + } + + const defaults: Record = { + [ConnectionPointTypes.Float]: { value: 0, valueType: "number" }, + [ConnectionPointTypes.Int]: { value: 0, valueType: "number" }, + [ConnectionPointTypes.Vector2]: { value: [0, 0], valueType: "BABYLON.Vector2" }, + [ConnectionPointTypes.Vector3]: { value: [0, 0, 0], valueType: "BABYLON.Vector3" }, + [ConnectionPointTypes.Color4]: { value: [1, 1, 1, 1], valueType: "BABYLON.Color4" }, + [ConnectionPointTypes.Matrix]: { + value: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + valueType: "BABYLON.Matrix", + }, + }; + + const def = defaults[type]; + if (def) { + block["value"] = def.value; + block["valueType"] = def.valueType; + } + } + + /** + * Remove a block from a particle set by its id. + * @param setName - The name of the particle set. + * @param blockId - The id of the block to remove. + * @returns "OK" on success, or an error string. + */ + removeBlock(setName: string, blockId: number): string { + const pset = this._particleSets.get(setName); + if (!pset) { + return `Particle set "${setName}" not found.`; + } + + const idx = pset.blocks.findIndex((b) => b.id === blockId); + if (idx === -1) { + return `Block ${blockId} not found.`; + } + + // Remove any connections pointing to this block + for (const block of pset.blocks) { + for (const inp of block.inputs) { + if (inp.targetBlockId === blockId) { + delete inp.targetBlockId; + delete inp.targetConnectionName; + } + } + } + + pset.blocks.splice(idx, 1); + + if (pset.editorData) { + pset.editorData.locations = pset.editorData.locations.filter((l) => l.blockId !== blockId); + } + + return "OK"; + } + + // ── Connections ──────────────────────────────────────────────────── + + /** + * Connect an output of one block to an input of another block. + * @param setName - The name of the particle set. + * @param sourceBlockId - The id of the source block. + * @param outputName - The name of the output port on the source block. + * @param targetBlockId - The id of the target block. + * @param inputName - The name of the input port on the target block. + * @returns "OK" on success, or an error string. + */ + connectBlocks(setName: string, sourceBlockId: number, outputName: string, targetBlockId: number, inputName: string): string { + const pset = this._particleSets.get(setName); + if (!pset) { + return `Particle set "${setName}" not found.`; + } + + const sourceBlock = pset.blocks.find((b) => b.id === sourceBlockId); + if (!sourceBlock) { + return `Source block ${sourceBlockId} not found.`; + } + + const targetBlock = pset.blocks.find((b) => b.id === targetBlockId); + if (!targetBlock) { + return `Target block ${targetBlockId} not found.`; + } + + const output = sourceBlock.outputs.find((o) => o.name === outputName); + if (!output) { + const available = sourceBlock.outputs.map((o) => o.name).join(", "); + return `Output "${outputName}" not found on block ${sourceBlockId} ("${sourceBlock.name}"). Available: ${available}`; + } + + const input = targetBlock.inputs.find((i) => i.name === inputName); + if (!input) { + const available = targetBlock.inputs.map((i) => i.name).join(", "); + return `Input "${inputName}" not found on block ${targetBlockId} ("${targetBlock.name}"). Available: ${available}`; + } + + // An input can only have one connection — overwrite any existing one + input.inputName = input.name; + input.targetBlockId = sourceBlockId; + input.targetConnectionName = outputName; + + return "OK"; + } + + /** + * Disconnect an input on a block. + * @param setName - The name of the particle set. + * @param blockId - The id of the block. + * @param inputName - The name of the input port to disconnect. + * @returns "OK" on success, or an error string. + */ + disconnectInput(setName: string, blockId: number, inputName: string): string { + const pset = this._particleSets.get(setName); + if (!pset) { + return `Particle set "${setName}" not found.`; + } + + const block = pset.blocks.find((b) => b.id === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + const input = block.inputs.find((i) => i.name === inputName); + if (!input) { + return `Input "${inputName}" not found.`; + } + delete input.inputName; + delete input.targetBlockId; + delete input.targetConnectionName; + return "OK"; + } + + // ── Queries ──────────────────────────────────────────────────────── + + /** + * Get the current state of a particle set as a formatted description. + * @param setName - The name of the particle set. + * @returns A formatted string describing the particle set. + */ + describeParticleSet(setName: string): string { + const pset = this._particleSets.get(setName); + if (!pset) { + return `Particle set "${setName}" not found.`; + } + + const lines: string[] = []; + lines.push(`Particle Set: ${setName}`); + lines.push(`Blocks (${pset.blocks.length}):`); + + for (const block of pset.blocks) { + const typeName = block.customType.replace("BABYLON.", ""); + lines.push(` [${block.id}] ${block.name} (${typeName})`); + + if (block.inputs.length > 0) { + for (const inp of block.inputs) { + if (inp.targetBlockId !== undefined) { + const srcBlock = pset.blocks.find((b) => b.id === inp.targetBlockId); + lines.push(` ← ${inp.name} ← [${inp.targetBlockId}] ${srcBlock?.name ?? "?"}.${inp.targetConnectionName}`); + } + } + } + } + + const systemBlocks = pset.blocks.filter((b) => b.customType === "BABYLON.SystemBlock"); + lines.push(`System blocks: ${systemBlocks.length > 0 ? systemBlocks.map((b) => `[${b.id}]`).join(", ") : "(none)"}`); + if (pset.comment) { + lines.push(`Comment: ${pset.comment}`); + } + return lines.join("\n"); + } + + /** + * Describe a single block in detail. + * @param setName - The name of the particle set. + * @param blockId - The id of the block to describe. + * @returns A formatted string describing the block. + */ + describeBlock(setName: string, blockId: number): string { + const pset = this._particleSets.get(setName); + if (!pset) { + return `Particle set "${setName}" not found.`; + } + + const block = pset.blocks.find((b) => b.id === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + const typeName = block.customType.replace("BABYLON.", ""); + const lines: string[] = []; + lines.push(`Block [${block.id}]: "${block.name}" — type ${typeName}`); + + lines.push("\nInputs:"); + for (const inp of block.inputs) { + const conn = inp.targetBlockId !== undefined ? ` ← connected to [${inp.targetBlockId}].${inp.targetConnectionName}` : " (unconnected)"; + lines.push(` • ${inp.name}${conn}`); + } + + lines.push("\nOutputs:"); + for (const out of block.outputs) { + const consumers: string[] = []; + for (const b of pset.blocks) { + for (const i of b.inputs) { + if (i.targetBlockId === blockId && i.targetConnectionName === out.name) { + consumers.push(`[${b.id}] ${b.name}.${i.name}`); + } + } + } + const conn = consumers.length > 0 ? ` → ${consumers.join(", ")}` : " (unconnected)"; + lines.push(` • ${out.name}${conn}`); + } + + // Show any extra properties + const ignoredKeys = new Set(["customType", "id", "name", "inputs", "outputs"]); + const extraProps = Object.entries(block).filter(([k]) => !ignoredKeys.has(k)); + if (extraProps.length > 0) { + lines.push("\nProperties:"); + for (const [k, v] of extraProps) { + lines.push(` ${k}: ${JSON.stringify(v)}`); + } + } + + return lines.join("\n"); + } + + // ── Serialisation ───────────────────────────────────────────────── + + /** + * Export to the NPE JSON format that Babylon.js can load. + * @param setName - The name of the particle set. + * @returns The JSON string, or undefined if the set is not found. + */ + exportJSON(setName: string): string | undefined { + const pset = this._particleSets.get(setName); + if (!pset) { + return undefined; + } + + // Final pass: ensure every block has required properties for safe deserialization + for (const block of pset.blocks) { + if (block.customType === "BABYLON.ParticleInputBlock") { + this._normaliseInputBlockValue(block); + this._ensureDefaultValue(block); + } + + // Apply mandatory defaults from the registry for any block type + const typeName = block.customType.replace("BABYLON.", ""); + const info = BlockRegistry[typeName]; + if (info?.defaultSerializedProperties) { + for (const [key, value] of Object.entries(info.defaultSerializedProperties)) { + if (block[key] === undefined) { + block[key] = value; + } + } + } + + // Convert any remaining string enum values to numbers + const enumProps = BlockEnumProperties[typeName]; + if (enumProps) { + for (const [key, enumMap] of Object.entries(enumProps)) { + if (typeof block[key] === "string") { + block[key] = enumMap[block[key] as string] ?? block[key]; + } + } + } + } + + // Compute a proper layered graph layout for the editor + this._layoutGraph(pset); + + return JSON.stringify(pset, null, 2); + } + + // ── Graph Layout ─────────────────────────────────────────────────── + + /** Horizontal spacing between columns in the editor (px). */ + private static readonly COL_WIDTH = 340; + /** Vertical spacing between blocks within a column (px). */ + private static readonly ROW_HEIGHT = 180; + + /** + * Compute a layered graph layout and write it into `editorData.locations`. + * @param pset - The particle set to lay out. + */ + private _layoutGraph(pset: ISerializedParticleSystemSet): void { + const blocks = pset.blocks; + if (blocks.length === 0) { + return; + } + + const blockById = new Map(); + for (const b of blocks) { + blockById.set(b.id, b); + } + + // Build adjacency + const predecessors = new Map>(); + const successors = new Map>(); + for (const b of blocks) { + predecessors.set(b.id, new Set()); + successors.set(b.id, new Set()); + } + for (const b of blocks) { + for (const inp of b.inputs) { + if (inp.targetBlockId !== undefined) { + predecessors.get(b.id)!.add(inp.targetBlockId); + successors.get(inp.targetBlockId)?.add(b.id); + } + } + } + + // Longest-path depth from system blocks (output nodes) + const depth = new Map(); + const queue: number[] = []; + + // Use SystemBlocks as roots + for (const b of blocks) { + if (b.customType === "BABYLON.SystemBlock") { + depth.set(b.id, 0); + queue.push(b.id); + } + } + + // If no SystemBlocks, use the last block + if (queue.length === 0 && blocks.length > 0) { + depth.set(blocks[blocks.length - 1].id, 0); + queue.push(blocks[blocks.length - 1].id); + } + + let head = 0; + while (head < queue.length) { + const id = queue[head++]; + const d = depth.get(id)!; + for (const predId of predecessors.get(id) ?? []) { + const existing = depth.get(predId); + if (existing === undefined || d + 1 > existing) { + depth.set(predId, d + 1); + queue.push(predId); + } + } + } + + // Disconnected blocks get max depth + 1 + const maxDepth = Math.max(0, ...depth.values()); + for (const b of blocks) { + if (!depth.has(b.id)) { + depth.set(b.id, maxDepth + 1); + } + } + + // Reverse so inputs are on the left + const totalMaxDepth = Math.max(0, ...depth.values()); + const column = new Map(); + for (const [id, d] of depth) { + column.set(id, totalMaxDepth - d); + } + + // Group blocks by column and sort + const columns = new Map(); + for (const b of blocks) { + const col = column.get(b.id)!; + if (!columns.has(col)) { + columns.set(col, []); + } + columns.get(col)!.push(b.id); + } + + const sortedCols = [...columns.keys()].sort((a, b) => b - a); + const yPosition = new Map(); + + for (const col of sortedCols) { + const colBlocks = columns.get(col)!; + + if (col === sortedCols[0]) { + colBlocks.forEach((id, i) => yPosition.set(id, i)); + } else { + const barycenters = new Map(); + for (const id of colBlocks) { + const succs = successors.get(id)!; + const succYs: number[] = []; + for (const sid of succs) { + const sy = yPosition.get(sid); + if (sy !== undefined) { + succYs.push(sy); + } + } + if (succYs.length > 0) { + barycenters.set(id, succYs.reduce((a, b) => a + b, 0) / succYs.length); + } else { + barycenters.set(id, 9999); + } + } + colBlocks.sort((a, b) => barycenters.get(a)! - barycenters.get(b)!); + colBlocks.forEach((id, i) => yPosition.set(id, i)); + } + } + + // Write locations + const locations: Array<{ blockId: number; x: number; y: number }> = []; + for (const b of blocks) { + const col = column.get(b.id)!; + const row = yPosition.get(b.id)!; + locations.push({ + blockId: b.id, + x: col * ParticleGraphManager.COL_WIDTH, + y: row * ParticleGraphManager.ROW_HEIGHT, + }); + } + + if (!pset.editorData) { + pset.editorData = { locations }; + } else { + pset.editorData.locations = locations; + } + } + + /** + * Import an NPE JSON string. + * @param setName - The name to assign to the imported particle set. + * @param json - The JSON string to import. + * @returns "OK" on success, or an error string. + */ + importJSON(setName: string, json: string): string { + try { + const parsed = JSON.parse(json) as ISerializedParticleSystemSet; + this._particleSets.set(setName, parsed); + + const maxId = parsed.blocks.reduce((max, b) => Math.max(max, b.id), 0); + this._nextId.set(setName, maxId + 1); + this._nextX.set(setName, parsed.blocks.length * 280); + + return "OK"; + } catch (e) { + return `Failed to parse JSON: ${(e as Error).message}`; + } + } + + // ── Block Property Mutation ──────────────────────────────────────── + + /** + * Set one or more properties on a block. + * @param setName - The name of the particle set. + * @param blockId - The id of the block to update. + * @param properties - Key-value pairs of properties to set. + * @returns "OK" on success, or an error string. + */ + setBlockProperties(setName: string, blockId: number, properties: Record): string { + const pset = this._particleSets.get(setName); + if (!pset) { + return `Particle set "${setName}" not found.`; + } + + const block = pset.blocks.find((b) => b.id === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + const typeName = block.customType.replace("BABYLON.", ""); + + for (const [key, value] of Object.entries(properties)) { + if (typeName === "ParticleInputBlock" && key === "type" && typeof value === "string") { + block["type"] = ConnectionPointTypes[value] ?? value; + } else if (typeName === "ParticleInputBlock" && key === "contextualValue" && typeof value === "string") { + const cv = ContextualSources[value] ?? value; + block["contextualValue"] = cv; + block["systemSource"] = SystemSources.None; + if (typeof cv === "number" && cv !== ContextualSources.None && ContextualSourceToType[cv] !== undefined) { + block["type"] = ContextualSourceToType[cv]; + } + } else if (typeName === "ParticleInputBlock" && key === "systemSource" && typeof value === "string") { + const ss = SystemSources[value] ?? value; + block["systemSource"] = ss; + block["contextualValue"] = ContextualSources.None; + if (typeof ss === "number" && ss !== SystemSources.None && SystemSourceToType[ss] !== undefined) { + block["type"] = SystemSourceToType[ss]; + } + } else if (typeof value === "string" && BlockEnumProperties[typeName]?.[key]) { + block[key] = BlockEnumProperties[typeName][key][value] ?? value; + } else { + block[key] = value; + } + } + + // Re-normalise ParticleInputBlock value after property changes + if (typeName === "ParticleInputBlock") { + this._normaliseInputBlockValue(block); + this._ensureDefaultValue(block); + } + + return "OK"; + } + + // ── Validation ──────────────────────────────────────────────────── + + /** + * Run basic validation on the graph and return any warnings/errors. + * @param setName - The name of the particle set to validate. + * @returns An array of warning/error strings (empty if valid). + */ + validateParticleSet(setName: string): string[] { + const pset = this._particleSets.get(setName); + if (!pset) { + return [`Particle set "${setName}" not found.`]; + } + + const issues: string[] = []; + + // Check for at least one SystemBlock + const hasSystemBlock = pset.blocks.some((b) => b.customType === "BABYLON.SystemBlock"); + if (!hasSystemBlock) { + issues.push("ERROR: Missing SystemBlock — every particle graph needs at least one SystemBlock to produce a particle system."); + } + + // Check for unconnected required inputs + for (const block of pset.blocks) { + const typeName = block.customType.replace("BABYLON.", ""); + const info = Object.values(BlockRegistry).find((r) => r.className === typeName); + if (!info) { + continue; + } + + for (const inp of block.inputs) { + const inputInfo = info.inputs.find((i) => i.name === inp.name); + if (inp.targetBlockId === undefined && inputInfo && !inputInfo.isOptional) { + issues.push(`ERROR: Block [${block.id}] "${block.name}" has required input "${inp.name}" that is not connected.`); + } + } + } + + // Check for dangling connection references + for (const block of pset.blocks) { + for (const inp of block.inputs) { + if (inp.targetBlockId !== undefined) { + const src = pset.blocks.find((b) => b.id === inp.targetBlockId); + if (!src) { + issues.push(`ERROR: Block [${block.id}] "${block.name}" input "${inp.name}" references non-existent block ${inp.targetBlockId}.`); + } else if (!src.outputs.find((o) => o.name === inp.targetConnectionName)) { + issues.push( + `WARNING: Block [${block.id}] "${block.name}" input "${inp.name}" references output "${inp.targetConnectionName}" which doesn't exist on block [${src.id}].` + ); + } + } + } + } + + // Check ParticleInputBlock-specific issues + for (const block of pset.blocks) { + if (block.customType !== "BABYLON.ParticleInputBlock") { + continue; + } + const cv = block["contextualValue"] as number | undefined; + const ss = block["systemSource"] as number | undefined; + const hasValue = block["value"] !== undefined; + if ((cv === undefined || cv === ContextualSources.None) && (ss === undefined || ss === SystemSources.None) && !hasValue) { + issues.push(`WARNING: ParticleInputBlock [${block.id}] "${block.name}" has no contextual value, no system source, and no constant value — it provides no data.`); + } + } + + // Check for orphan blocks + for (const block of pset.blocks) { + if (block.customType === "BABYLON.SystemBlock" || block.customType === "BABYLON.ParticleInputBlock") { + continue; + } + const hasIncomingConnection = block.inputs.some((inp) => inp.targetBlockId !== undefined); + const hasOutgoingConnection = pset.blocks.some((other) => other.inputs.some((inp) => inp.targetBlockId === block.id)); + if (!hasIncomingConnection && !hasOutgoingConnection) { + issues.push(`WARNING: Block [${block.id}] "${block.name}" (${block.customType.replace("BABYLON.", "")}) has no connections — it is an orphan and does nothing.`); + } + } + + if (issues.length === 0) { + issues.push("No issues found — graph looks valid."); + } + + return issues; + } +} diff --git a/packages/tools/npe-mcp-server/test/unit/generateExamples.test.ts b/packages/tools/npe-mcp-server/test/unit/generateExamples.test.ts new file mode 100644 index 00000000000..7a1726fcbf5 --- /dev/null +++ b/packages/tools/npe-mcp-server/test/unit/generateExamples.test.ts @@ -0,0 +1,479 @@ +/** + * Node Particle MCP Server – Example Particle System Generator + * + * Builds several reference Node Particle System Set graphs via the + * ParticleGraphManager API, validates them, and writes them to the examples/ + * directory. + * + * Run: npx ts-node --esm test/unit/generateExamples.ts + * Or simply include as a test file – Jest will run it and the examples are + * written to disk as a side effect. + */ + +import * as fs from "fs"; +import * as path from "path"; +import { ParticleGraphManager } from "../../src/particleGraph"; + +const EXAMPLES_DIR = path.resolve(__dirname, "../../examples"); + +function writeExample(name: string, json: string): void { + fs.mkdirSync(EXAMPLES_DIR, { recursive: true }); + const filePath = path.join(EXAMPLES_DIR, `${name}.json`); + fs.writeFileSync(filePath, json, "utf-8"); +} + +function id(result: ReturnType): number { + if (typeof result === "string") { + throw new Error(result); + } + return result.block.id; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 1 – Basic Particles +// A minimal particle system: BoxShape → CreateParticle → UpdateAge → System +// ═══════════════════════════════════════════════════════════════════════════ + +function buildBasicParticles(): string { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("BasicParticles", "Minimal box-emitting particle system."); + + const shapeId = id(mgr.addBlock("BasicParticles", "BoxShapeBlock", "shape")); + const createId = id(mgr.addBlock("BasicParticles", "CreateParticleBlock", "create")); + + const lifetimeId = id( + mgr.addBlock("BasicParticles", "ParticleInputBlock", "lifetime", { + type: "Float", + value: 2, + }) + ); + mgr.connectBlocks("BasicParticles", lifetimeId, "output", createId, "lifeTime"); + + mgr.connectBlocks("BasicParticles", createId, "particle", shapeId, "particle"); + + const updateAgeId = id(mgr.addBlock("BasicParticles", "UpdateAgeBlock", "updateAge")); + mgr.connectBlocks("BasicParticles", shapeId, "output", updateAgeId, "particle"); + + const ageInputId = id( + mgr.addBlock("BasicParticles", "ParticleInputBlock", "ageInput", { + contextualValue: "Age", + }) + ); + mgr.connectBlocks("BasicParticles", ageInputId, "output", updateAgeId, "age"); + + const updatePosId = id(mgr.addBlock("BasicParticles", "BasicPositionUpdateBlock", "updatePos")); + mgr.connectBlocks("BasicParticles", updateAgeId, "output", updatePosId, "particle"); + + const textureId = id(mgr.addBlock("BasicParticles", "ParticleTextureSourceBlock", "texture", { url: "https://assets.babylonjs.com/textures/flare.png" })); + const systemId = id(mgr.addBlock("BasicParticles", "SystemBlock", "system")); + mgr.connectBlocks("BasicParticles", textureId, "texture", systemId, "texture"); + mgr.connectBlocks("BasicParticles", updatePosId, "output", systemId, "particle"); + + const issues = mgr.validateParticleSet("BasicParticles"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + return mgr.exportJSON("BasicParticles")!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 2 – Colored Particles +// Particles with initial color and color update via gradient. +// ═══════════════════════════════════════════════════════════════════════════ + +function buildColoredParticles(): string { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("ColoredParticles", "Sphere-emitting particles with color fade."); + + const shapeId = id(mgr.addBlock("ColoredParticles", "SphereShapeBlock", "shape")); + const createId = id(mgr.addBlock("ColoredParticles", "CreateParticleBlock", "create")); + + const lifetimeId = id( + mgr.addBlock("ColoredParticles", "ParticleInputBlock", "lifetime", { + type: "Float", + value: 3, + }) + ); + mgr.connectBlocks("ColoredParticles", lifetimeId, "output", createId, "lifeTime"); + + const startColorId = id( + mgr.addBlock("ColoredParticles", "ParticleInputBlock", "startColor", { + type: "Color4", + value: { r: 1, g: 0.8, b: 0, a: 1 }, + }) + ); + mgr.connectBlocks("ColoredParticles", startColorId, "output", createId, "color"); + + mgr.connectBlocks("ColoredParticles", createId, "particle", shapeId, "particle"); + + const updateAgeId = id(mgr.addBlock("ColoredParticles", "UpdateAgeBlock", "updateAge")); + mgr.connectBlocks("ColoredParticles", shapeId, "output", updateAgeId, "particle"); + + const ageInputId = id( + mgr.addBlock("ColoredParticles", "ParticleInputBlock", "ageInput", { + contextualValue: "Age", + }) + ); + mgr.connectBlocks("ColoredParticles", ageInputId, "output", updateAgeId, "age"); + + const updatePosId = id(mgr.addBlock("ColoredParticles", "BasicPositionUpdateBlock", "updatePos")); + mgr.connectBlocks("ColoredParticles", updateAgeId, "output", updatePosId, "particle"); + + const updateColorId = id(mgr.addBlock("ColoredParticles", "UpdateColorBlock", "updateColor")); + mgr.connectBlocks("ColoredParticles", updatePosId, "output", updateColorId, "particle"); + + const endColorId = id( + mgr.addBlock("ColoredParticles", "ParticleInputBlock", "endColor", { + type: "Color4", + value: { r: 1, g: 0, b: 0, a: 0 }, + }) + ); + mgr.connectBlocks("ColoredParticles", endColorId, "output", updateColorId, "color"); + + const textureId = id(mgr.addBlock("ColoredParticles", "ParticleTextureSourceBlock", "texture", { url: "https://assets.babylonjs.com/textures/flare.png" })); + const systemId = id(mgr.addBlock("ColoredParticles", "SystemBlock", "system")); + mgr.connectBlocks("ColoredParticles", textureId, "texture", systemId, "texture"); + mgr.connectBlocks("ColoredParticles", updateColorId, "output", systemId, "particle"); + + const issues = mgr.validateParticleSet("ColoredParticles"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + return mgr.exportJSON("ColoredParticles")!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 3 – Cone Fountain +// Fountain-like effect with a cone emitter and direction update. +// ═══════════════════════════════════════════════════════════════════════════ + +function buildConeFountain(): string { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("ConeFountain", "Fountain effect using a cone shape."); + + const shapeId = id(mgr.addBlock("ConeFountain", "ConeShapeBlock", "cone")); + const createId = id(mgr.addBlock("ConeFountain", "CreateParticleBlock", "create")); + + const lifetimeId = id( + mgr.addBlock("ConeFountain", "ParticleInputBlock", "lifetime", { + type: "Float", + value: 4, + }) + ); + mgr.connectBlocks("ConeFountain", lifetimeId, "output", createId, "lifeTime"); + + const scaleId = id( + mgr.addBlock("ConeFountain", "ParticleInputBlock", "scale", { + type: "Vector2", + value: { x: 0.1, y: 0.1 }, + }) + ); + mgr.connectBlocks("ConeFountain", scaleId, "output", createId, "scale"); + + mgr.connectBlocks("ConeFountain", createId, "particle", shapeId, "particle"); + + const updateAgeId = id(mgr.addBlock("ConeFountain", "UpdateAgeBlock", "updateAge")); + mgr.connectBlocks("ConeFountain", shapeId, "output", updateAgeId, "particle"); + + const ageInputId = id( + mgr.addBlock("ConeFountain", "ParticleInputBlock", "ageInput", { + contextualValue: "Age", + }) + ); + mgr.connectBlocks("ConeFountain", ageInputId, "output", updateAgeId, "age"); + + const updateDirId = id(mgr.addBlock("ConeFountain", "UpdateDirectionBlock", "updateDir")); + mgr.connectBlocks("ConeFountain", updateAgeId, "output", updateDirId, "particle"); + + const dirInputId = id( + mgr.addBlock("ConeFountain", "ParticleInputBlock", "dirInput", { + contextualValue: "Direction", + }) + ); + mgr.connectBlocks("ConeFountain", dirInputId, "output", updateDirId, "direction"); + + const updatePosId = id(mgr.addBlock("ConeFountain", "BasicPositionUpdateBlock", "updatePos")); + mgr.connectBlocks("ConeFountain", updateDirId, "output", updatePosId, "particle"); + + const textureId = id(mgr.addBlock("ConeFountain", "ParticleTextureSourceBlock", "texture", { url: "https://assets.babylonjs.com/textures/flare.png" })); + const systemId = id(mgr.addBlock("ConeFountain", "SystemBlock", "system")); + mgr.connectBlocks("ConeFountain", textureId, "texture", systemId, "texture"); + mgr.connectBlocks("ConeFountain", updatePosId, "output", systemId, "particle"); + + const issues = mgr.validateParticleSet("ConeFountain"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + return mgr.exportJSON("ConeFountain")!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 4 – Math Particles +// Demonstrate math operations: multiply particle direction by time. +// ═══════════════════════════════════════════════════════════════════════════ + +function buildMathParticles(): string { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("MathParticles", "Particles with math-modified direction."); + + const shapeId = id(mgr.addBlock("MathParticles", "BoxShapeBlock", "shape")); + const createId = id(mgr.addBlock("MathParticles", "CreateParticleBlock", "create")); + + const lifetimeId = id( + mgr.addBlock("MathParticles", "ParticleInputBlock", "lifetime", { + type: "Float", + value: 3, + }) + ); + mgr.connectBlocks("MathParticles", lifetimeId, "output", createId, "lifeTime"); + + mgr.connectBlocks("MathParticles", createId, "particle", shapeId, "particle"); + + const updateAgeId = id(mgr.addBlock("MathParticles", "UpdateAgeBlock", "updateAge")); + mgr.connectBlocks("MathParticles", shapeId, "output", updateAgeId, "particle"); + + const ageInputId = id( + mgr.addBlock("MathParticles", "ParticleInputBlock", "ageInput", { + contextualValue: "Age", + }) + ); + mgr.connectBlocks("MathParticles", ageInputId, "output", updateAgeId, "age"); + + // Read direction and multiply by a factor using math block + const dirId = id( + mgr.addBlock("MathParticles", "ParticleInputBlock", "direction", { + contextualValue: "Direction", + }) + ); + const factorId = id( + mgr.addBlock("MathParticles", "ParticleInputBlock", "factor", { + type: "Float", + value: 2.5, + }) + ); + const mathId = id(mgr.addBlock("MathParticles", "ParticleMathBlock", "mulDir", { operation: "Multiply" })); + mgr.connectBlocks("MathParticles", dirId, "output", mathId, "left"); + mgr.connectBlocks("MathParticles", factorId, "output", mathId, "right"); + + const updateDirId = id(mgr.addBlock("MathParticles", "UpdateDirectionBlock", "updateDir")); + mgr.connectBlocks("MathParticles", updateAgeId, "output", updateDirId, "particle"); + mgr.connectBlocks("MathParticles", mathId, "output", updateDirId, "direction"); + + const updatePosId = id(mgr.addBlock("MathParticles", "BasicPositionUpdateBlock", "updatePos")); + mgr.connectBlocks("MathParticles", updateDirId, "output", updatePosId, "particle"); + + const textureId = id(mgr.addBlock("MathParticles", "ParticleTextureSourceBlock", "texture", { url: "https://assets.babylonjs.com/textures/flare.png" })); + const systemId = id(mgr.addBlock("MathParticles", "SystemBlock", "system")); + mgr.connectBlocks("MathParticles", textureId, "texture", systemId, "texture"); + mgr.connectBlocks("MathParticles", updatePosId, "output", systemId, "particle"); + + const issues = mgr.validateParticleSet("MathParticles"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + return mgr.exportJSON("MathParticles")!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 5 – Multi-System Particles +// Demonstrate multiple SystemBlocks in a single set. +// ═══════════════════════════════════════════════════════════════════════════ + +function buildMultiSystemParticles(): string { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("MultiSystemParticles", "Two independent particle systems in a single set."); + + // System 1: Box emitter + const shape1Id = id(mgr.addBlock("MultiSystemParticles", "BoxShapeBlock", "shape1")); + const create1Id = id(mgr.addBlock("MultiSystemParticles", "CreateParticleBlock", "create1")); + + const lifetime1Id = id( + mgr.addBlock("MultiSystemParticles", "ParticleInputBlock", "lifetime1", { + type: "Float", + value: 2, + }) + ); + mgr.connectBlocks("MultiSystemParticles", lifetime1Id, "output", create1Id, "lifeTime"); + + mgr.connectBlocks("MultiSystemParticles", create1Id, "particle", shape1Id, "particle"); + + const updateAge1Id = id(mgr.addBlock("MultiSystemParticles", "UpdateAgeBlock", "updateAge1")); + mgr.connectBlocks("MultiSystemParticles", shape1Id, "output", updateAge1Id, "particle"); + + const ageInput1Id = id( + mgr.addBlock("MultiSystemParticles", "ParticleInputBlock", "ageInput1", { + contextualValue: "Age", + }) + ); + mgr.connectBlocks("MultiSystemParticles", ageInput1Id, "output", updateAge1Id, "age"); + + const texture1Id = id(mgr.addBlock("MultiSystemParticles", "ParticleTextureSourceBlock", "texture1", { url: "https://assets.babylonjs.com/textures/flare.png" })); + const system1Id = id(mgr.addBlock("MultiSystemParticles", "SystemBlock", "system1")); + mgr.connectBlocks("MultiSystemParticles", texture1Id, "texture", system1Id, "texture"); + mgr.connectBlocks("MultiSystemParticles", updateAge1Id, "output", system1Id, "particle"); + + // System 2: Sphere emitter + const shape2Id = id(mgr.addBlock("MultiSystemParticles", "SphereShapeBlock", "shape2")); + const create2Id = id(mgr.addBlock("MultiSystemParticles", "CreateParticleBlock", "create2")); + + const lifetime2Id = id( + mgr.addBlock("MultiSystemParticles", "ParticleInputBlock", "lifetime2", { + type: "Float", + value: 5, + }) + ); + mgr.connectBlocks("MultiSystemParticles", lifetime2Id, "output", create2Id, "lifeTime"); + + mgr.connectBlocks("MultiSystemParticles", create2Id, "particle", shape2Id, "particle"); + + const updateAge2Id = id(mgr.addBlock("MultiSystemParticles", "UpdateAgeBlock", "updateAge2")); + mgr.connectBlocks("MultiSystemParticles", shape2Id, "output", updateAge2Id, "particle"); + + const ageInput2Id = id( + mgr.addBlock("MultiSystemParticles", "ParticleInputBlock", "ageInput2", { + contextualValue: "Age", + }) + ); + mgr.connectBlocks("MultiSystemParticles", ageInput2Id, "output", updateAge2Id, "age"); + + const texture2Id = id(mgr.addBlock("MultiSystemParticles", "ParticleTextureSourceBlock", "texture2", { url: "https://assets.babylonjs.com/textures/flare.png" })); + const system2Id = id(mgr.addBlock("MultiSystemParticles", "SystemBlock", "system2")); + mgr.connectBlocks("MultiSystemParticles", texture2Id, "texture", system2Id, "texture"); + mgr.connectBlocks("MultiSystemParticles", updateAge2Id, "output", system2Id, "particle"); + + const issues = mgr.validateParticleSet("MultiSystemParticles"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + return mgr.exportJSON("MultiSystemParticles")!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 6 – Gravity Fountain +// Particles shoot up from a cone and fall back down via gravity applied +// to the direction each frame. +// ═══════════════════════════════════════════════════════════════════════════ + +function buildGravityFountain(): string { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("GravityFountain", "Cone fountain with gravity pulling particles down."); + + const createId = id(mgr.addBlock("GravityFountain", "CreateParticleBlock", "create")); + const lifetimeId = id( + mgr.addBlock("GravityFountain", "ParticleInputBlock", "lifetime", { + type: "Float", + value: 3, + }) + ); + mgr.connectBlocks("GravityFountain", lifetimeId, "output", createId, "lifeTime"); + + const emitPowerId = id( + mgr.addBlock("GravityFountain", "ParticleInputBlock", "emitPower", { + type: "Float", + value: 8, + }) + ); + mgr.connectBlocks("GravityFountain", emitPowerId, "output", createId, "emitPower"); + + const shapeId = id(mgr.addBlock("GravityFountain", "ConeShapeBlock", "cone")); + mgr.connectBlocks("GravityFountain", createId, "particle", shapeId, "particle"); + + const updateAgeId = id(mgr.addBlock("GravityFountain", "UpdateAgeBlock", "updateAge")); + mgr.connectBlocks("GravityFountain", shapeId, "output", updateAgeId, "particle"); + + const ageInputId = id( + mgr.addBlock("GravityFountain", "ParticleInputBlock", "ageInput", { + contextualValue: "Age", + }) + ); + mgr.connectBlocks("GravityFountain", ageInputId, "output", updateAgeId, "age"); + + // Gravity direction update: direction += gravity * deltaTime + const gravityId = id( + mgr.addBlock("GravityFountain", "ParticleInputBlock", "gravity", { + type: "Vector3", + value: { x: 0, y: -9.81, z: 0 }, + }) + ); + const deltaTimeId = id( + mgr.addBlock("GravityFountain", "ParticleInputBlock", "deltaTime", { + systemSource: "Delta", + }) + ); + const gravityDeltaId = id(mgr.addBlock("GravityFountain", "ParticleMathBlock", "gravityDelta", { operation: "Multiply" })); + mgr.connectBlocks("GravityFountain", gravityId, "output", gravityDeltaId, "left"); + mgr.connectBlocks("GravityFountain", deltaTimeId, "output", gravityDeltaId, "right"); + + const currentDirId = id( + mgr.addBlock("GravityFountain", "ParticleInputBlock", "currentDir", { + contextualValue: "Direction", + }) + ); + const addGravityId = id(mgr.addBlock("GravityFountain", "ParticleMathBlock", "addGravity", { operation: "Add" })); + mgr.connectBlocks("GravityFountain", currentDirId, "output", addGravityId, "left"); + mgr.connectBlocks("GravityFountain", gravityDeltaId, "output", addGravityId, "right"); + + const updateDirId = id(mgr.addBlock("GravityFountain", "UpdateDirectionBlock", "updateDir")); + mgr.connectBlocks("GravityFountain", updateAgeId, "output", updateDirId, "particle"); + mgr.connectBlocks("GravityFountain", addGravityId, "output", updateDirId, "direction"); + + // Position update + const updatePosId = id(mgr.addBlock("GravityFountain", "BasicPositionUpdateBlock", "updatePos")); + mgr.connectBlocks("GravityFountain", updateDirId, "output", updatePosId, "particle"); + + // System + const textureId = id(mgr.addBlock("GravityFountain", "ParticleTextureSourceBlock", "texture", { url: "https://assets.babylonjs.com/textures/flare.png" })); + const systemId = id(mgr.addBlock("GravityFountain", "SystemBlock", "system")); + mgr.connectBlocks("GravityFountain", textureId, "texture", systemId, "texture"); + mgr.connectBlocks("GravityFountain", updatePosId, "output", systemId, "particle"); + + const issues = mgr.validateParticleSet("GravityFountain"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + return mgr.exportJSON("GravityFountain")!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Jest Test Wrapper +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Node Particle MCP Server – Example Generation", () => { + it("generates BasicParticles example", () => { + const json = buildBasicParticles(); + const parsed = JSON.parse(json); + expect(parsed.blocks.length).toBeGreaterThanOrEqual(7); + writeExample("BasicParticles", json); + }); + + it("generates ColoredParticles example", () => { + const json = buildColoredParticles(); + const parsed = JSON.parse(json); + expect(parsed.blocks.length).toBeGreaterThanOrEqual(10); + writeExample("ColoredParticles", json); + }); + + it("generates ConeFountain example", () => { + const json = buildConeFountain(); + const parsed = JSON.parse(json); + expect(parsed.blocks.length).toBeGreaterThanOrEqual(8); + writeExample("ConeFountain", json); + }); + + it("generates MathParticles example", () => { + const json = buildMathParticles(); + const parsed = JSON.parse(json); + expect(parsed.blocks.length).toBeGreaterThanOrEqual(10); + writeExample("MathParticles", json); + }); + + it("generates MultiSystemParticles example", () => { + const json = buildMultiSystemParticles(); + const parsed = JSON.parse(json); + const systemBlocks = parsed.blocks.filter((b: any) => b.customType === "BABYLON.SystemBlock"); + expect(systemBlocks.length).toBe(2); + writeExample("MultiSystemParticles", json); + }); + + it("generates GravityFountain example", () => { + const json = buildGravityFountain(); + const parsed = JSON.parse(json); + // Should have gravity, deltaTime, two math blocks, updateDir, updatePos, etc. + expect(parsed.blocks.length).toBeGreaterThanOrEqual(13); + writeExample("GravityFountain", json); + }); +}); diff --git a/packages/tools/npe-mcp-server/test/unit/graphManager.test.ts b/packages/tools/npe-mcp-server/test/unit/graphManager.test.ts new file mode 100644 index 00000000000..e002ccaeed8 --- /dev/null +++ b/packages/tools/npe-mcp-server/test/unit/graphManager.test.ts @@ -0,0 +1,593 @@ +/** + * Node Particle MCP Server – Graph Manager Validation Tests + * + * Creates particle system graphs via ParticleGraphManager, exports them to JSON, + * validates the JSON structure, and exercises core operations. + */ + +import { ParticleGraphManager } from "../../src/particleGraph"; +import { BlockRegistry } from "../../src/blockRegistry"; + +// ─── Test Helpers ───────────────────────────────────────────────────────── + +function validateParticleJSON(json: string, label: string): any { + let parsed: any; + try { + parsed = JSON.parse(json); + } catch { + throw new Error(`${label}: invalid JSON`); + } + + expect(parsed.customType).toBe("BABYLON.NodeParticleSystemSet"); + expect(Array.isArray(parsed.blocks)).toBe(true); + + const allIds = new Set(parsed.blocks.map((b: any) => b.id)); + for (const block of parsed.blocks) { + expect(typeof block.customType).toBe("string"); + expect(block.customType.startsWith("BABYLON.")).toBe(true); + expect(typeof block.id).toBe("number"); + expect(typeof block.name).toBe("string"); + expect(Array.isArray(block.inputs)).toBe(true); + expect(Array.isArray(block.outputs)).toBe(true); + + // Validate connections reference existing block IDs + for (const inp of block.inputs) { + if (inp.targetBlockId !== undefined) { + expect(allIds.has(inp.targetBlockId)).toBe(true); + expect(typeof inp.targetConnectionName).toBe("string"); + } + } + } + + return parsed; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Node Particle MCP Server – Graph Manager Validation", () => { + // ── Test 1: Simple particle system ────────────────────────────────── + + it("creates and exports a simple particle system with valid JSON", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("simple"); + + const shape = mgr.addBlock("simple", "BoxShapeBlock", "shape"); + expect(typeof shape).not.toBe("string"); + const shapeBlock = (shape as any).block; + + const create = mgr.addBlock("simple", "CreateParticleBlock", "create"); + expect(typeof create).not.toBe("string"); + const createBlock = (create as any).block; + + const updateAge = mgr.addBlock("simple", "UpdateAgeBlock", "updateAge"); + expect(typeof updateAge).not.toBe("string"); + const updateAgeBlock = (updateAge as any).block; + + const ageInput = mgr.addBlock("simple", "ParticleInputBlock", "ageInput", { + contextualValue: "Age", + }); + expect(typeof ageInput).not.toBe("string"); + const ageInputBlock = (ageInput as any).block; + + const texture = mgr.addBlock("simple", "ParticleTextureSourceBlock", "texture", { url: "https://assets.babylonjs.com/textures/flare.png" }); + expect(typeof texture).not.toBe("string"); + const textureBlock = (texture as any).block; + + const system = mgr.addBlock("simple", "SystemBlock", "system"); + expect(typeof system).not.toBe("string"); + const systemBlock = (system as any).block; + + // Flow: Create.particle → Shape.particle → Shape.output → UpdateAge.particle → UpdateAge.output → System.particle + const connCreateToShape = mgr.connectBlocks("simple", createBlock.id, "particle", shapeBlock.id, "particle"); + expect(connCreateToShape).toBe("OK"); + + const connShapeToUpdate = mgr.connectBlocks("simple", shapeBlock.id, "output", updateAgeBlock.id, "particle"); + expect(connShapeToUpdate).toBe("OK"); + + const connAgeInput = mgr.connectBlocks("simple", ageInputBlock.id, "output", updateAgeBlock.id, "age"); + expect(connAgeInput).toBe("OK"); + + const connTexture = mgr.connectBlocks("simple", textureBlock.id, "texture", systemBlock.id, "texture"); + expect(connTexture).toBe("OK"); + + const connUpdateToSystem = mgr.connectBlocks("simple", updateAgeBlock.id, "output", systemBlock.id, "particle"); + expect(connUpdateToSystem).toBe("OK"); + + const json = mgr.exportJSON("simple"); + expect(json).toBeDefined(); + const parsed = validateParticleJSON(json!, "simple"); + expect(parsed.blocks.length).toBe(6); + + // Should have a SystemBlock + const systemBlocks = parsed.blocks.filter((b: any) => b.customType === "BABYLON.SystemBlock"); + expect(systemBlocks.length).toBe(1); + }); + + // ── Test 2: Lifecycle operations ──────────────────────────────────── + + it("supports create, list, delete lifecycle", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("a"); + mgr.createParticleSet("b"); + + const list = mgr.listParticleSets(); + expect(list).toContain("a"); + expect(list).toContain("b"); + + expect(mgr.deleteParticleSet("a")).toBe(true); + expect(mgr.listParticleSets()).not.toContain("a"); + expect(mgr.deleteParticleSet("nonexistent")).toBe(false); + }); + + // ── Test 3: ParticleInputBlock with contextual source ─────────────── + + it("correctly sets contextual source and auto-derives type", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("ctx"); + + const result = mgr.addBlock("ctx", "ParticleInputBlock", "positions", { + contextualValue: "Position", + }); + expect(typeof result).not.toBe("string"); + const block = (result as any).block; + + // contextualValue should be numeric 0x0001 (Position) + expect(block.contextualValue).toBe(0x0001); + // type should be auto-derived to Vector3 (0x0008) + expect(block.type).toBe(0x0008); + // systemSource should be None + expect(block.systemSource).toBe(0); + }); + + // ── Test 4: ParticleInputBlock with system source ─────────────────── + + it("correctly sets system source and auto-derives type", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("sys"); + + const result = mgr.addBlock("sys", "ParticleInputBlock", "time", { + systemSource: "Time", + }); + expect(typeof result).not.toBe("string"); + const block = (result as any).block; + + // systemSource should be numeric 1 (Time) + expect(block.systemSource).toBe(1); + // type should be auto-derived to Float (0x0002) + expect(block.type).toBe(0x0002); + // contextualValue should be None + expect(block.contextualValue).toBe(0); + }); + + // ── Test 5: ParticleInputBlock with constant value ────────────────── + + it("correctly normalises constant input values", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("const"); + + // Float constant + const floatRes = mgr.addBlock("const", "ParticleInputBlock", "myFloat", { + type: "Float", + value: 3.14, + }); + expect(typeof floatRes).not.toBe("string"); + const floatBlock = (floatRes as any).block; + expect(floatBlock.type).toBe(0x0002); // Float + expect(floatBlock.value).toBe(3.14); + expect(floatBlock.valueType).toBe("number"); + + // Vector3 constant via object + const vec3Res = mgr.addBlock("const", "ParticleInputBlock", "myVec3", { + type: "Vector3", + value: { x: 1, y: 2, z: 3 }, + }); + expect(typeof vec3Res).not.toBe("string"); + const vec3Block = (vec3Res as any).block; + expect(vec3Block.type).toBe(0x0008); // Vector3 + expect(vec3Block.value).toEqual([1, 2, 3]); + expect(vec3Block.valueType).toBe("BABYLON.Vector3"); + + // Color4 constant via object {r,g,b,a} + const colorRes = mgr.addBlock("const", "ParticleInputBlock", "myColor", { + type: "Color4", + value: { r: 1, g: 0.5, b: 0.3, a: 1 }, + }); + expect(typeof colorRes).not.toBe("string"); + const colorBlock = (colorRes as any).block; + expect(colorBlock.type).toBe(0x0080); // Color4 + expect(colorBlock.value).toEqual([1, 0.5, 0.3, 1]); + expect(colorBlock.valueType).toBe("BABYLON.Color4"); + }); + + // ── Test 6: Enum conversion for block properties ──────────────────── + + it("converts string enum values to numbers for all block types", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("enums"); + + // ParticleMathBlock with string operation + const math = mgr.addBlock("enums", "ParticleMathBlock", "add", { operation: "Multiply" }); + expect(typeof math).not.toBe("string"); + expect((math as any).block.operation).toBe(2); // Multiply = 2 + + // ParticleTrigonometryBlock + const trig = mgr.addBlock("enums", "ParticleTrigonometryBlock", "sin", { operation: "Sin" }); + expect(typeof trig).not.toBe("string"); + expect((trig as any).block.operation).toBe(1); // Sin = 1 + + // ParticleConditionBlock + const cond = mgr.addBlock("enums", "ParticleConditionBlock", "cmp", { test: "GreaterThan" }); + expect(typeof cond).not.toBe("string"); + expect((cond as any).block.test).toBe(3); // GreaterThan = 3 + + // ParticleNumberMathBlock + const numMath = mgr.addBlock("enums", "ParticleNumberMathBlock", "pow", { operation: "Pow" }); + expect(typeof numMath).not.toBe("string"); + expect((numMath as any).block.operation).toBe(1); // Pow = 1 + + // ParticleVectorMathBlock + const vecMath = mgr.addBlock("enums", "ParticleVectorMathBlock", "dot", { operation: "Dot" }); + expect(typeof vecMath).not.toBe("string"); + expect((vecMath as any).block.operation).toBe(0); // Dot = 0 + + // ParticleFloatToIntBlock + const f2i = mgr.addBlock("enums", "ParticleFloatToIntBlock", "round", { operation: "Ceil" }); + expect(typeof f2i).not.toBe("string"); + expect((f2i as any).block.operation).toBe(1); // Ceil = 1 + + // ParticleRandomBlock + const rand = mgr.addBlock("enums", "ParticleRandomBlock", "rand", { lockMode: "PerParticle" }); + expect(typeof rand).not.toBe("string"); + expect((rand as any).block.lockMode).toBe(1); // PerParticle = 1 + + // ParticleLocalVariableBlock + const local = mgr.addBlock("enums", "ParticleLocalVariableBlock", "var", { scope: "Loop" }); + expect(typeof local).not.toBe("string"); + expect((local as any).block.scope).toBe(1); // Loop = 1 + }); + + // ── Test 7: setBlockProperties also converts enums ────────────────── + + it("converts string enums via setBlockProperties", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("setProp"); + + const math = mgr.addBlock("setProp", "ParticleMathBlock", "m"); + expect(typeof math).not.toBe("string"); + const block = (math as any).block; + expect(block.operation).toBe(0); // Default: Add + + const result = mgr.setBlockProperties("setProp", block.id, { operation: "Divide" }); + expect(result).toBe("OK"); + expect(block.operation).toBe(3); // Divide = 3 + }); + + // ── Test 8: exportJSON safety net converts remaining string enums ─── + + it("exportJSON converts any remaining string enum values", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("export"); + + const math = mgr.addBlock("export", "ParticleMathBlock", "m"); + const block = (math as any).block; + // Force a string value past the normal conversion (simulating edge case) + block.operation = "Max"; + + mgr.addBlock("export", "SystemBlock", "out"); + const json = mgr.exportJSON("export"); + expect(json).toBeDefined(); + const parsed = JSON.parse(json!); + const mathBlock = parsed.blocks.find((b: any) => b.name === "m"); + expect(mathBlock.operation).toBe(4); // Max = 4 + }); + + // ── Test 9: Connection validation ─────────────────────────────────── + + it("rejects invalid connections", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("conn"); + + const box = mgr.addBlock("conn", "BoxShapeBlock", "box"); + const boxId = (box as any).block.id; + + // Wrong output name + expect(mgr.connectBlocks("conn", boxId, "nonexistent", boxId, "shape")).toContain("not found"); + + // Non-existent block + expect(mgr.connectBlocks("conn", 999, "shape", boxId, "shape")).toContain("not found"); + + // Non-existent particle set + expect(mgr.connectBlocks("nope", boxId, "shape", boxId, "shape")).toContain("not found"); + }); + + // ── Test 10: Disconnect input ─────────────────────────────────────── + + it("disconnects inputs correctly", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("disc"); + + const shape = mgr.addBlock("disc", "BoxShapeBlock", "shape"); + const create = mgr.addBlock("disc", "CreateParticleBlock", "create"); + const shapeId = (shape as any).block.id; + const createId = (create as any).block.id; + + mgr.connectBlocks("disc", createId, "particle", shapeId, "particle"); + expect(mgr.disconnectInput("disc", shapeId, "particle")).toBe("OK"); + + const desc = mgr.describeParticleSet("disc"); + expect(desc).not.toContain("connected to"); + }); + + // ── Test 11: Remove block cleans up connections ───────────────────── + + it("removeBlock cleans up dangling connections", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("rm"); + + const input = mgr.addBlock("rm", "ParticleInputBlock", "lifetime", { + type: "Float", + value: 5, + }); + const create = mgr.addBlock("rm", "CreateParticleBlock", "create"); + const system = mgr.addBlock("rm", "SystemBlock", "system"); + + const inputId = (input as any).block.id; + const createId = (create as any).block.id; + const systemId = (system as any).block.id; + + mgr.connectBlocks("rm", inputId, "output", createId, "lifeTime"); + mgr.connectBlocks("rm", createId, "particle", systemId, "particle"); + + // Remove input block + expect(mgr.removeBlock("rm", inputId)).toBe("OK"); + + // Create's lifetime input should be disconnected + const issues = mgr.validateParticleSet("rm"); + expect(issues.every((i) => !i.includes(`block ${inputId}`))).toBe(true); + }); + + // ── Test 12: Validation catches issues ────────────────────────────── + + it("validation detects missing SystemBlock and orphans", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("val"); + + mgr.addBlock("val", "BoxShapeBlock", "orphanShape"); + // No SystemBlock + + const issues = mgr.validateParticleSet("val"); + expect(issues.some((i) => i.includes("Missing SystemBlock"))).toBe(true); + expect(issues.some((i) => i.includes("orphan"))).toBe(true); + }); + + // ── Test 13: Registry completeness ────────────────────────────────── + + it("block registry has all expected block types", () => { + const expectedBlocks = [ + "ParticleInputBlock", + "SystemBlock", + "ParticleTextureSourceBlock", + "BoxShapeBlock", + "SphereShapeBlock", + "ConeShapeBlock", + "CylinderShapeBlock", + "PointShapeBlock", + "CustomShapeBlock", + "MeshShapeBlock", + "CreateParticleBlock", + "SetupSpriteSheetBlock", + "UpdatePositionBlock", + "UpdateDirectionBlock", + "UpdateColorBlock", + "UpdateScaleBlock", + "UpdateSizeBlock", + "UpdateAngleBlock", + "UpdateAgeBlock", + "BasicPositionUpdateBlock", + "BasicColorUpdateBlock", + "BasicSpriteUpdateBlock", + "UpdateSpriteCellIndexBlock", + "UpdateFlowMapBlock", + "UpdateNoiseBlock", + "UpdateAttractorBlock", + "AlignAngleBlock", + "ParticleTriggerBlock", + "ParticleMathBlock", + "ParticleNumberMathBlock", + "ParticleVectorMathBlock", + "ParticleTrigonometryBlock", + "ParticleVectorLengthBlock", + "ParticleFloatToIntBlock", + "ParticleConditionBlock", + "ParticleLerpBlock", + "ParticleNLerpBlock", + "ParticleSmoothStepBlock", + "ParticleStepBlock", + "ParticleClampBlock", + "ParticleGradientBlock", + "ParticleGradientValueBlock", + "ParticleRandomBlock", + "ParticleConverterBlock", + "ParticleDebugBlock", + "ParticleElbowBlock", + "ParticleLocalVariableBlock", + "ParticleTeleportInBlock", + "ParticleTeleportOutBlock", + ]; + + for (const blockType of expectedBlocks) { + expect(BlockRegistry[blockType]).toBeDefined(); + expect(BlockRegistry[blockType].className).toBe(blockType); + } + }); + + // ── Test 14: Import/export round-trip ─────────────────────────────── + + it("round-trips through import and export", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("original"); + + mgr.addBlock("original", "BoxShapeBlock", "shape"); + mgr.addBlock("original", "CreateParticleBlock", "create"); + mgr.addBlock("original", "SystemBlock", "system"); + mgr.connectBlocks("original", 2, "particle", 1, "particle"); + mgr.connectBlocks("original", 1, "output", 3, "particle"); + + const json1 = mgr.exportJSON("original")!; + const parsed1 = JSON.parse(json1); + + // Import into a new name + expect(mgr.importJSON("copy", json1)).toBe("OK"); + const json2 = mgr.exportJSON("copy")!; + const parsed2 = JSON.parse(json2); + + // Same block count and connections + expect(parsed2.blocks.length).toBe(parsed1.blocks.length); + }); + + // ── Test 15: Default serialized properties ────────────────────────── + + it("applies defaultSerializedProperties from registry", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("defaults"); + + const math = mgr.addBlock("defaults", "ParticleMathBlock", "m"); + expect((math as any).block.operation).toBe(0); + + const cond = mgr.addBlock("defaults", "ParticleConditionBlock", "c"); + expect((cond as any).block.test).toBe(0); + + const trig = mgr.addBlock("defaults", "ParticleTrigonometryBlock", "t"); + expect((trig as any).block.operation).toBe(0); + }); + + // ── Test 16: Editor layout is generated ───────────────────────────── + + it("generates editor layout data on export", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("layout"); + + mgr.addBlock("layout", "BoxShapeBlock", "shape"); + mgr.addBlock("layout", "CreateParticleBlock", "create"); + mgr.addBlock("layout", "SystemBlock", "system"); + mgr.connectBlocks("layout", 2, "particle", 1, "particle"); + mgr.connectBlocks("layout", 1, "output", 3, "particle"); + + const json = mgr.exportJSON("layout")!; + const parsed = JSON.parse(json); + + expect(parsed.editorData).toBeDefined(); + expect(Array.isArray(parsed.editorData.locations)).toBe(true); + expect(parsed.editorData.locations.length).toBe(3); + }); + + // ── clearAll ──────────────────────────────────────────────────────── + + it("clearAll removes all particle sets and resets state", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("a"); + mgr.createParticleSet("b"); + expect(mgr.listParticleSets().length).toBe(2); + + mgr.clearAll(); + expect(mgr.listParticleSets()).toEqual([]); + expect(mgr.getParticleSet("a")).toBeUndefined(); + expect(mgr.getParticleSet("b")).toBeUndefined(); + + // Can create new sets after clear + mgr.createParticleSet("c"); + expect(mgr.listParticleSets()).toEqual(["c"]); + }); + + it("clearAll on empty manager is a no-op", () => { + const mgr = new ParticleGraphManager(); + mgr.clearAll(); + expect(mgr.listParticleSets()).toEqual([]); + }); + + // ── Test: Multiple SystemBlocks ───────────────────────────────────── + + it("supports multiple SystemBlocks in a single set", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("multi"); + + const shape1 = mgr.addBlock("multi", "BoxShapeBlock", "shape1"); + const create1 = mgr.addBlock("multi", "CreateParticleBlock", "create1"); + const system1 = mgr.addBlock("multi", "SystemBlock", "system1"); + + const shape2 = mgr.addBlock("multi", "SphereShapeBlock", "shape2"); + const create2 = mgr.addBlock("multi", "CreateParticleBlock", "create2"); + const system2 = mgr.addBlock("multi", "SystemBlock", "system2"); + + mgr.connectBlocks("multi", (create1 as any).block.id, "particle", (shape1 as any).block.id, "particle"); + mgr.connectBlocks("multi", (shape1 as any).block.id, "output", (system1 as any).block.id, "particle"); + + mgr.connectBlocks("multi", (create2 as any).block.id, "particle", (shape2 as any).block.id, "particle"); + mgr.connectBlocks("multi", (shape2 as any).block.id, "output", (system2 as any).block.id, "particle"); + + const json = mgr.exportJSON("multi")!; + const parsed = JSON.parse(json); + + const systemBlocks = parsed.blocks.filter((b: any) => b.customType === "BABYLON.SystemBlock"); + expect(systemBlocks.length).toBe(2); + }); + + // ── Test: Describe block ──────────────────────────────────────────── + + it("describeBlock shows inputs, outputs, and properties", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("desc"); + + const math = mgr.addBlock("desc", "ParticleMathBlock", "adder", { operation: "Add" }); + const block = (math as any).block; + + const desc = mgr.describeBlock("desc", block.id); + expect(desc).toContain("adder"); + expect(desc).toContain("ParticleMathBlock"); + expect(desc).toContain("Inputs:"); + expect(desc).toContain("Outputs:"); + expect(desc).toContain("operation"); + }); + + // ── Test: contextualValue auto-derivation for various sources ─────── + + it("auto-derives type for all contextual source families", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("derive"); + + // Color → Color4 (0x0080) + const colorRes = mgr.addBlock("derive", "ParticleInputBlock", "color", { contextualValue: "Color" }); + expect((colorRes as any).block.type).toBe(0x0080); + + // Age → Float (0x0002) + const ageRes = mgr.addBlock("derive", "ParticleInputBlock", "age", { contextualValue: "Age" }); + expect((ageRes as any).block.type).toBe(0x0002); + + // Scale → Vector2 (0x0004) + const scaleRes = mgr.addBlock("derive", "ParticleInputBlock", "scale", { contextualValue: "Scale" }); + expect((scaleRes as any).block.type).toBe(0x0004); + + // SpriteCellIndex → Int (0x0001) + const spriteRes = mgr.addBlock("derive", "ParticleInputBlock", "sprite", { contextualValue: "SpriteCellIndex" }); + expect((spriteRes as any).block.type).toBe(0x0001); + + // system source: Emitter → Vector3 (0x0008) + const emitterRes = mgr.addBlock("derive", "ParticleInputBlock", "emitter", { systemSource: "Emitter" }); + expect((emitterRes as any).block.type).toBe(0x0008); + }); + + // ── Test: ParticleInputBlock warning when no data ─────────────────── + + it("warns when ParticleInputBlock has no contextualValue, systemSource, or value", () => { + const mgr = new ParticleGraphManager(); + mgr.createParticleSet("warn"); + + const result = mgr.addBlock("warn", "ParticleInputBlock", "empty"); + expect(typeof result).not.toBe("string"); + expect((result as any).warnings).toBeDefined(); + expect((result as any).warnings.length).toBeGreaterThan(0); + expect((result as any).warnings[0]).toContain("no value"); + }); +}); diff --git a/packages/tools/npe-mcp-server/test/unit/npeParse.test.ts b/packages/tools/npe-mcp-server/test/unit/npeParse.test.ts new file mode 100644 index 00000000000..f965a6187a2 --- /dev/null +++ b/packages/tools/npe-mcp-server/test/unit/npeParse.test.ts @@ -0,0 +1,55 @@ +/** + * Node Particle MCP Server – Babylon.js Parse Validation + * + * Tests that JSON produced by the Node Particle MCP server can be parsed by Babylon.js's + * NodeParticleSystemSet.parseSerializedObject() without errors. This proves the JSON + * structure is valid and all block types are recognized by Babylon.js. + * + * Note: We skip build() because it actually _evaluates_ the particle graph, + * which requires a running scene/engine. The MCP server's responsibility is + * producing structurally valid JSON — not runtime evaluation. + */ +import { NodeParticleSystemSet } from "core/Particles/Node/nodeParticleSystemSet"; + +// Side-effect imports: register ALL NPE block types via RegisterClass +import "core/Particles/Node/index"; + +import * as fs from "fs"; +import * as path from "path"; + +const NPE_SERVER_DIR = path.resolve(__dirname, "../.."); +const EXAMPLES_DIR = path.resolve(NPE_SERVER_DIR, "examples"); + +function readExampleJson(filename: string): string { + return fs.readFileSync(path.join(EXAMPLES_DIR, filename), "utf-8"); +} + +describe("Node Particle MCP Server – Babylon.js Parse", () => { + const exampleFiles = ["BasicParticles.json", "ColoredParticles.json", "ConeFountain.json", "MathParticles.json", "MultiSystemParticles.json"]; + + for (const file of exampleFiles) { + it(`should parse example particle system: ${file}`, () => { + let jsonStr: string; + try { + jsonStr = readExampleJson(file); + } catch { + console.warn(`Skipping ${file}: not found (run generateExamples.test.ts first)`); + return; + } + + const source = JSON.parse(jsonStr); + + // Use parseSerializedObject to validate structure + // without executing the particle graph + const nodeParticleSet = new NodeParticleSystemSet(file.replace(".json", "")); + nodeParticleSet.parseSerializedObject(source); + + expect(nodeParticleSet.attachedBlocks.length).toBeGreaterThan(0); + + // Verify block count matches + expect(nodeParticleSet.attachedBlocks.length).toBe(source.blocks.length); + + nodeParticleSet.dispose(); + }); + } +}); diff --git a/packages/tools/npe-mcp-server/tsconfig.json b/packages/tools/npe-mcp-server/tsconfig.json new file mode 100644 index 00000000000..2c6df59671a --- /dev/null +++ b/packages/tools/npe-mcp-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "types": ["node"], + "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/nrge-mcp-server/README.md b/packages/tools/nrge-mcp-server/README.md new file mode 100644 index 00000000000..c9a3bc0bde4 --- /dev/null +++ b/packages/tools/nrge-mcp-server/README.md @@ -0,0 +1,42 @@ +# @tools/nrge-mcp-server + +MCP server for AI-driven Babylon.js Node Render Graph authoring. + +## Provides + +- create and manage custom render graphs in memory +- add render-graph blocks, connect ports, and set properties +- inspect graph structure and validate output pipelines +- export and import NRGE-compatible JSON +- import from and save to Babylon.js snippets + +## Typical Workflow + +```text +create_render_graph -> add_block -> connect_blocks -> set_block_properties -> validate_graph -> export_graph_json +``` + +In practice, a usable render graph usually includes an input block, render or post-process blocks, and an output block. + +## Binary + +```bash +babylonjs-node-render-graph +``` + +## Build And Run + +```bash +npm run build -w @tools/nrge-mcp-server +npm run start -w @tools/nrge-mcp-server +``` + +## Integration + +Exported Node Render Graph JSON can be attached to the Scene MCP server through `attach_node_render_graph`, either inline or via `nrgJsonFile`. + +## Related Files + +- `src/index.ts`: MCP tool registration +- `src/renderGraph.ts`: graph manager and import/export logic +- `src/blockRegistry.ts`: Node Render Graph block catalog diff --git a/packages/tools/nrge-mcp-server/examples/BasicForward.json b/packages/tools/nrge-mcp-server/examples/BasicForward.json new file mode 100644 index 00000000000..a36dc839a7b --- /dev/null +++ b/packages/tools/nrge-mcp-server/examples/BasicForward.json @@ -0,0 +1,214 @@ +{ + "customType": "BABYLON.NodeRenderGraph", + "name": "BasicForward", + "comment": "Minimal forward rendering pipeline: clear, render, output.", + "blocks": [ + { + "customType": "BABYLON.NodeRenderGraphInputBlock", + "id": 1, + "name": "BackBuffer Color", + "inputs": [], + "outputs": [ + { + "name": "output" + } + ], + "additionalConstructionParameters": [ + 2 + ], + "isExternal": true + }, + { + "customType": "BABYLON.NodeRenderGraphInputBlock", + "id": 2, + "name": "BackBuffer Depth", + "inputs": [], + "outputs": [ + { + "name": "output" + } + ], + "additionalConstructionParameters": [ + 4 + ], + "isExternal": true + }, + { + "customType": "BABYLON.NodeRenderGraphInputBlock", + "id": 3, + "name": "Camera", + "inputs": [], + "outputs": [ + { + "name": "output" + } + ], + "additionalConstructionParameters": [ + 16777216 + ], + "isExternal": true + }, + { + "customType": "BABYLON.NodeRenderGraphInputBlock", + "id": 4, + "name": "Objects", + "inputs": [], + "outputs": [ + { + "name": "output" + } + ], + "additionalConstructionParameters": [ + 33554432 + ], + "isExternal": true + }, + { + "customType": "BABYLON.NodeRenderGraphClearBlock", + "id": 5, + "name": "Clear", + "inputs": [ + { + "name": "target", + "inputName": "target", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "depth", + "inputName": "depth", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "dependencies" + } + ], + "outputs": [ + { + "name": "output" + }, + { + "name": "outputDepth" + } + ], + "color": { + "r": 0.2, + "g": 0.2, + "b": 0.3, + "a": 1 + }, + "clearColor": true, + "clearDepth": true + }, + { + "customType": "BABYLON.NodeRenderGraphObjectRendererBlock", + "id": 6, + "name": "Renderer", + "inputs": [ + { + "name": "target", + "inputName": "target", + "targetBlockId": 5, + "targetConnectionName": "output" + }, + { + "name": "depth", + "inputName": "depth", + "targetBlockId": 5, + "targetConnectionName": "outputDepth" + }, + { + "name": "camera", + "inputName": "camera", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "objects", + "inputName": "objects", + "targetBlockId": 4, + "targetConnectionName": "output" + }, + { + "name": "dependencies" + }, + { + "name": "shadowGenerators" + } + ], + "outputs": [ + { + "name": "output" + }, + { + "name": "outputDepth" + }, + { + "name": "objectRenderer" + } + ], + "additionalConstructionParameters": [ + true, + true + ] + }, + { + "customType": "BABYLON.NodeRenderGraphOutputBlock", + "id": 7, + "name": "Output", + "inputs": [ + { + "name": "texture", + "inputName": "texture", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "dependencies" + } + ], + "outputs": [] + } + ], + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 0, + "y": 100 + }, + { + "blockId": 2, + "x": 220, + "y": 100 + }, + { + "blockId": 3, + "x": 440, + "y": 100 + }, + { + "blockId": 4, + "x": 660, + "y": 100 + }, + { + "blockId": 5, + "x": 880, + "y": 100 + }, + { + "blockId": 6, + "x": 1100, + "y": 100 + }, + { + "blockId": 7, + "x": 1320, + "y": 100 + } + ] + }, + "outputNodeId": 7 +} \ No newline at end of file diff --git a/packages/tools/nrge-mcp-server/examples/BloomPipeline.json b/packages/tools/nrge-mcp-server/examples/BloomPipeline.json new file mode 100644 index 00000000000..5fafeac7268 --- /dev/null +++ b/packages/tools/nrge-mcp-server/examples/BloomPipeline.json @@ -0,0 +1,254 @@ +{ + "customType": "BABYLON.NodeRenderGraph", + "name": "BloomPipeline", + "comment": "Forward rendering with bloom post-process.", + "blocks": [ + { + "customType": "BABYLON.NodeRenderGraphInputBlock", + "id": 1, + "name": "BackBuffer Color", + "inputs": [], + "outputs": [ + { + "name": "output" + } + ], + "additionalConstructionParameters": [ + 2 + ], + "isExternal": true + }, + { + "customType": "BABYLON.NodeRenderGraphInputBlock", + "id": 2, + "name": "BackBuffer Depth", + "inputs": [], + "outputs": [ + { + "name": "output" + } + ], + "additionalConstructionParameters": [ + 4 + ], + "isExternal": true + }, + { + "customType": "BABYLON.NodeRenderGraphInputBlock", + "id": 3, + "name": "Camera", + "inputs": [], + "outputs": [ + { + "name": "output" + } + ], + "additionalConstructionParameters": [ + 16777216 + ], + "isExternal": true + }, + { + "customType": "BABYLON.NodeRenderGraphInputBlock", + "id": 4, + "name": "Objects", + "inputs": [], + "outputs": [ + { + "name": "output" + } + ], + "additionalConstructionParameters": [ + 33554432 + ], + "isExternal": true + }, + { + "customType": "BABYLON.NodeRenderGraphClearBlock", + "id": 5, + "name": "Clear", + "inputs": [ + { + "name": "target", + "inputName": "target", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "depth", + "inputName": "depth", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "dependencies" + } + ], + "outputs": [ + { + "name": "output" + }, + { + "name": "outputDepth" + } + ], + "color": { + "r": 0.1, + "g": 0.1, + "b": 0.15, + "a": 1 + }, + "clearColor": true, + "clearDepth": true + }, + { + "customType": "BABYLON.NodeRenderGraphObjectRendererBlock", + "id": 6, + "name": "Renderer", + "inputs": [ + { + "name": "target", + "inputName": "target", + "targetBlockId": 5, + "targetConnectionName": "output" + }, + { + "name": "depth", + "inputName": "depth", + "targetBlockId": 5, + "targetConnectionName": "outputDepth" + }, + { + "name": "camera", + "inputName": "camera", + "targetBlockId": 3, + "targetConnectionName": "output" + }, + { + "name": "objects", + "inputName": "objects", + "targetBlockId": 4, + "targetConnectionName": "output" + }, + { + "name": "dependencies" + }, + { + "name": "shadowGenerators" + } + ], + "outputs": [ + { + "name": "output" + }, + { + "name": "outputDepth" + }, + { + "name": "objectRenderer" + } + ], + "additionalConstructionParameters": [ + true, + true + ] + }, + { + "customType": "BABYLON.NodeRenderGraphBloomPostProcessBlock", + "id": 7, + "name": "Bloom", + "inputs": [ + { + "name": "source", + "inputName": "source", + "targetBlockId": 6, + "targetConnectionName": "output" + }, + { + "name": "target", + "inputName": "target", + "targetBlockId": 6, + "targetConnectionName": "outputDepth" + }, + { + "name": "dependencies" + } + ], + "outputs": [ + { + "name": "output" + } + ], + "additionalConstructionParameters": [ + false, + 0.5 + ], + "threshold": 0.8, + "weight": 0.6, + "kernel": 64, + "scale": 0.5 + }, + { + "customType": "BABYLON.NodeRenderGraphOutputBlock", + "id": 8, + "name": "Output", + "inputs": [ + { + "name": "texture", + "inputName": "texture", + "targetBlockId": 7, + "targetConnectionName": "output" + }, + { + "name": "dependencies" + } + ], + "outputs": [] + } + ], + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 0, + "y": 100 + }, + { + "blockId": 2, + "x": 220, + "y": 100 + }, + { + "blockId": 3, + "x": 440, + "y": 100 + }, + { + "blockId": 4, + "x": 660, + "y": 100 + }, + { + "blockId": 5, + "x": 880, + "y": 100 + }, + { + "blockId": 6, + "x": 1100, + "y": 100 + }, + { + "blockId": 7, + "x": 1320, + "y": 100 + }, + { + "blockId": 8, + "x": 1540, + "y": 100 + } + ] + }, + "outputNodeId": 8 +} \ No newline at end of file diff --git a/packages/tools/nrge-mcp-server/examples/ClearOnly.json b/packages/tools/nrge-mcp-server/examples/ClearOnly.json new file mode 100644 index 00000000000..0047d47ee40 --- /dev/null +++ b/packages/tools/nrge-mcp-server/examples/ClearOnly.json @@ -0,0 +1,93 @@ +{ + "customType": "BABYLON.NodeRenderGraph", + "name": "ClearOnly", + "comment": "Minimal pipeline: clear a back-buffer and output.", + "blocks": [ + { + "customType": "BABYLON.NodeRenderGraphInputBlock", + "id": 1, + "name": "BackBuffer Color", + "inputs": [], + "outputs": [ + { + "name": "output" + } + ], + "additionalConstructionParameters": [ + 2 + ], + "isExternal": true + }, + { + "customType": "BABYLON.NodeRenderGraphClearBlock", + "id": 2, + "name": "Clear", + "inputs": [ + { + "name": "target", + "inputName": "target", + "targetBlockId": 1, + "targetConnectionName": "output" + }, + { + "name": "depth" + }, + { + "name": "dependencies" + } + ], + "outputs": [ + { + "name": "output" + }, + { + "name": "outputDepth" + } + ], + "color": { + "r": 0.4, + "g": 0.6, + "b": 0.9, + "a": 1 + }, + "clearColor": true + }, + { + "customType": "BABYLON.NodeRenderGraphOutputBlock", + "id": 3, + "name": "Output", + "inputs": [ + { + "name": "texture", + "inputName": "texture", + "targetBlockId": 2, + "targetConnectionName": "output" + }, + { + "name": "dependencies" + } + ], + "outputs": [] + } + ], + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 0, + "y": 100 + }, + { + "blockId": 2, + "x": 220, + "y": 100 + }, + { + "blockId": 3, + "x": 440, + "y": 100 + } + ] + }, + "outputNodeId": 3 +} \ No newline at end of file diff --git a/packages/tools/nrge-mcp-server/package.json b/packages/tools/nrge-mcp-server/package.json new file mode 100644 index 00000000000..659113ac51d --- /dev/null +++ b/packages/tools/nrge-mcp-server/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tools/nrge-mcp-server", + "version": "1.0.0", + "private": true, + "description": "MCP server for AI-driven Node Render Graph (NRGE) 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", + "@tools/snippet-loader": "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/nrge-mcp-server/rollup.config.mjs b/packages/tools/nrge-mcp-server/rollup.config.mjs new file mode 100644 index 00000000000..b6a4d32812a --- /dev/null +++ b/packages/tools/nrge-mcp-server/rollup.config.mjs @@ -0,0 +1,3 @@ +import { createConfig } from "../rollup.config.mcp.mjs"; + +export default createConfig(); diff --git a/packages/tools/nrge-mcp-server/src/blockRegistry.ts b/packages/tools/nrge-mcp-server/src/blockRegistry.ts new file mode 100644 index 00000000000..00137be5e8a --- /dev/null +++ b/packages/tools/nrge-mcp-server/src/blockRegistry.ts @@ -0,0 +1,1043 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * Complete registry of all Node Render Graph block types available in Babylon.js. + * + * Each entry describes the block's Babylon class name, category, purpose, + * its typed input/output ports, and optional customisable properties. + * + * ── Connection-point types quick reference ─────────────────────────────── + * + * Texture types (use these names exactly in the catalog & connect calls): + * Texture – generic render target / history texture + * TextureBackBuffer – the final back-buffer colour attachment + * TextureBackBufferDepthStencilAttachment– back-buffer depth/stencil attachment + * TextureDepthStencilAttachment – off-screen depth/stencil attachment + * TextureViewDepth – geometry depth in view-space + * TextureNormalizedViewDepth – geometry normalised depth in view-space + * TextureScreenDepth – geometry depth in screen-space + * TextureViewNormal – geometry normals in view-space + * TextureWorldNormal – geometry normals in world-space + * TextureAlbedo – geometry albedo (base-colour) buffer + * TextureReflectivity – geometry reflectivity buffer + * TextureLocalPosition – geometry positions in local-space + * TextureWorldPosition – geometry positions in world-space + * TextureVelocity – motion/velocity buffer + * TextureLinearVelocity – linear velocity buffer + * TextureIrradiance – irradiance buffer + * TextureAlbedoSqrt – sqrt-encoded albedo buffer + * + * Non-texture types: + * Camera – a Babylon.js Camera object (provided by an InputBlock) + * ObjectList – a set of meshes/particle-systems (provided by InputBlock or CullObjects) + * ShadowLight – a shadow-casting light (provided by an InputBlock) + * ShadowGenerator – output of a shadow-generator block + * ResourceContainer – groups multiple texture handles for dependency tracking + * Object – opaque custom object (e.g. objectRenderer handle for layer blocks) + * + * Special meta-types (cannot be used as concrete port types): + * AutoDetect – port accepts many types; the type is resolved at connection time + * BasedOnInput – output type mirrors a connected input port + * + * ── additionalConstructionParameters ───────────────────────────────────── + * Several blocks require extra constructor arguments beyond (name, frameGraph, scene). + * These map directly to the serialisation field `additionalConstructionParameters` + * and MUST be supplied when adding the block so the deserialiser can recreate it. + * The default values used when the parameter is omitted are listed in each block entry. + */ + +/** + * Describes a single typed port on a block. + */ +export interface IPortInfo { + /** Port name (pass exactly this string to connect_blocks) */ + name: string; + /** Human-readable type label (from the connection-point reference above) */ + type: string; + /** True when the port does not have to be connected for the block to work */ + isOptional?: boolean; +} + +/** + * Describes a block type in the NRGE catalog. + */ +export interface IBlockTypeInfo { + /** The Babylon.js class name (without BABYLON. prefix) */ + className: string; + /** Grouping category shown in the NRGE left panel */ + category: string; + /** Short description of the block's purpose */ + description: string; + /** Input ports */ + inputs: IPortInfo[]; + /** Output ports */ + outputs: IPortInfo[]; + /** + * Named properties whose values can be tweaked via set_block_properties. + * Each key is the property name; the value is a human-readable type/range hint. + */ + properties?: Record; + /** + * `additionalConstructionParameters` to embed in the serialised block. + * If a block requires these, they MUST be present – omitting them will cause a + * runtime deserialisation crash. The defaults shown here match the constructor + * default arguments in the Babylon.js source. + */ + defaultAdditionalConstructionParameters?: unknown[]; +} + +// ─── Catalog ────────────────────────────────────────────────────────────────── + +// Base blocks such as NodeRenderGraphBaseObjectRendererBlock, NodeRenderGraphBasePostProcessBlock, +// NodeRenderGraphBaseWithPropertiesPostProcessBlock, and NodeRenderGraphBaseShadowGeneratorBlock are not creatable catalog entries. +export const BlockRegistry: Record = { + // ═══════════════════════════════════════════════════════════════════════ + // Input / Output + // ═══════════════════════════════════════════════════════════════════════ + + NodeRenderGraphInputBlock: { + className: "NodeRenderGraphInputBlock", + category: "Input", + description: + "Exposes an external resource to the render graph. " + + "The type is determined by `additionalConstructionParameters[0]` (a NodeRenderGraphBlockConnectionPointTypes enum value). " + + "Common type values: Texture=1, TextureDepthStencilAttachment=8, Camera=0x01000000, ObjectList=0x02000000, ShadowLight=0x00400000. " + + "Set `isExternal=true` so Babylon auto-fills the value from the scene at build time. " + + "For texture inputs you must provide `creationOptions` with size/format/samples; " + + "use the `set_block_properties` tool to set these fields after adding the block.", + inputs: [], + outputs: [{ name: "output", type: "AutoDetect" }], + properties: { + isExternal: "boolean – when true Babylon.js fills the value automatically (cameras, object-lists, lights). Default: false", + creationOptions: "FrameGraphTextureCreationOptions JSON – for texture inputs; defines size, format, samples, etc.", + }, + // additionalConstructionParameters[0] must be set to a NodeRenderGraphBlockConnectionPointTypes value. + // Common values: Texture=1 (0x1), TextureDepthStencilAttachment=8 (0x8), + // Camera=16777216 (0x01000000), ObjectList=33554432 (0x02000000), ShadowLight=4194304 (0x00400000) + defaultAdditionalConstructionParameters: [1073741824], // Undefined – callers must override + }, + + NodeRenderGraphOutputBlock: { + className: "NodeRenderGraphOutputBlock", + category: "Output", + description: + "The final output block. Every render graph MUST have exactly one. " + + "Connect the last rendered texture to the `texture` port. " + + "The outputNodeId of the serialised graph must equal this block's id.", + inputs: [ + { name: "texture", type: "Texture", isOptional: false }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Texture utilities + // ═══════════════════════════════════════════════════════════════════════ + + NodeRenderGraphClearBlock: { + className: "NodeRenderGraphClearBlock", + category: "Textures", + description: + "Clears a colour texture and/or depth-stencil attachment to a configurable colour / depth value. " + + "Both `target` and `depth` are optional, but at least one should be connected. " + + "Outputs mirror the connected inputs so downstream blocks can use the cleared textures.", + inputs: [ + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "depth", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [ + { name: "output", type: "BasedOnInput" }, + { name: "outputDepth", type: "BasedOnInput" }, + ], + properties: { + color: "Color4 {r,g,b,a} – clear colour (default: {r:0,g:0,b:0,a:1})", + clearColor: "boolean – whether to clear the colour channel (default: true)", + clearDepth: "boolean – whether to clear the depth channel (default: false)", + clearStencil: "boolean – whether to clear the stencil channel (default: false)", + convertColorToLinearSpace: "boolean – convert sRGB clear colour to linear space (default: false)", + }, + }, + + NodeRenderGraphCopyTextureBlock: { + className: "NodeRenderGraphCopyTextureBlock", + category: "Textures", + description: "Copies (blits) one texture into another. Useful for preserving intermediate render results or preparing textures for later read-back.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + NodeRenderGraphGenerateMipmapsBlock: { + className: "NodeRenderGraphGenerateMipmapsBlock", + category: "Textures", + description: + "Generates all mip-map levels for a texture in-place after prior rendering has been done. " + + "Required for techniques that sample lower mip levels (e.g. bloom, reflections).", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Rendering + // ═══════════════════════════════════════════════════════════════════════ + + NodeRenderGraphObjectRendererBlock: { + className: "NodeRenderGraphObjectRendererBlock", + category: "Rendering", + description: + "Renders a list of scene objects (meshes, particles) to a colour target using a camera. " + + "This is the primary rasterisation block — almost every graph needs one. " + + "Connect a cleared colour texture to `target`, a depth attachment to `depth`, " + + "a Camera input to `camera`, and a (possibly culled) ObjectList to `objects`. " + + "Optional `shadowGenerators` port accepts a ShadowGenerator or ResourceContainer of shadow generators.", + inputs: [ + { name: "target", type: "AutoDetect" }, + { name: "depth", type: "AutoDetect", isOptional: true }, + { name: "camera", type: "Camera" }, + { name: "objects", type: "ObjectList" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + { name: "shadowGenerators", type: "AutoDetect", isOptional: true }, + ], + outputs: [ + { name: "output", type: "BasedOnInput" }, + { name: "outputDepth", type: "BasedOnInput" }, + { name: "objectRenderer", type: "Object" }, + ], + properties: { + doNotChangeAspectRatio: "boolean – do not change the aspect ratio of the scene when rendering to RTT (default: true). Set as additionalConstructionParameters[0]", + enableClusteredLights: "boolean – enable clustered light rendering (default: true). Set as additionalConstructionParameters[1]", + isMainObjectRenderer: + "boolean – marks this as the main object renderer; only one block should be main per graph. Babylon.js auto-assigns the first one if none is set.", + depthTest: "boolean – enable depth testing (default: true)", + depthWrite: "boolean – enable depth writing (default: true)", + disableShadows: "boolean – disable shadow sampling (default: false)", + renderParticles: "boolean – render particle systems (default: true)", + renderSprites: "boolean – render sprite managers (default: true)", + }, + defaultAdditionalConstructionParameters: [true, true], + }, + + NodeRenderGraphGeometryRendererBlock: { + className: "NodeRenderGraphGeometryRendererBlock", + category: "Rendering", + description: + "Renders scene geometry into a multi-render target (G-Buffer), producing typed geometry textures " + + "(view-depth, normals, albedo, reflectivity, positions, velocity, etc.). " + + "Use these outputs as inputs for deferred shading techniques such as SSR, SSAO, or custom deferred passes. " + + "The `target` port for the colour attachment is OPTIONAL for this block.", + inputs: [ + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "depth", type: "AutoDetect", isOptional: true }, + { name: "camera", type: "Camera" }, + { name: "objects", type: "ObjectList" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + { name: "shadowGenerators", type: "AutoDetect", isOptional: true }, + ], + outputs: [ + { name: "output", type: "BasedOnInput" }, + { name: "outputDepth", type: "BasedOnInput" }, + { name: "objectRenderer", type: "Object" }, + { name: "geomViewDepth", type: "TextureViewDepth" }, + { name: "geomNormViewDepth", type: "TextureNormalizedViewDepth" }, + { name: "geomScreenDepth", type: "TextureScreenDepth" }, + { name: "geomViewNormal", type: "TextureViewNormal" }, + { name: "geomWorldNormal", type: "TextureWorldNormal" }, + { name: "geomLocalPosition", type: "TextureLocalPosition" }, + { name: "geomWorldPosition", type: "TextureWorldPosition" }, + { name: "geomAlbedo", type: "TextureAlbedo" }, + { name: "geomReflectivity", type: "TextureReflectivity" }, + { name: "geomVelocity", type: "TextureVelocity" }, + { name: "geomLinearVelocity", type: "TextureLinearVelocity" }, + ], + properties: { + doNotChangeAspectRatio: "boolean – do not change aspect ratio (default: true) — additionalConstructionParameters[0]", + enableClusteredLights: "boolean – enable clustered lights (default: true) — additionalConstructionParameters[1]", + depthTest: "boolean", + depthWrite: "boolean", + width: "number – G-buffer width in pixels (or percentage when sizeInPercentage=true)", + height: "number – G-buffer height", + sizeInPercentage: "boolean – use width/height as screen percentage (default: true)", + samples: "number – MSAA sample count (default: 1)", + }, + defaultAdditionalConstructionParameters: [true, true], + }, + + NodeRenderGraphUtilityLayerRendererBlock: { + className: "NodeRenderGraphUtilityLayerRendererBlock", + category: "Rendering", + description: + "Renders the Babylon.js utility layer (gizmos, helpers) on top of the main colour attachment. Used when the inspector / gizmos should appear in the final output.", + inputs: [ + { name: "target", type: "AutoDetect" }, + { name: "depth", type: "AutoDetect", isOptional: true }, + { name: "camera", type: "Camera" }, + { name: "objects", type: "ObjectList" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + { name: "shadowGenerators", type: "AutoDetect", isOptional: true }, + ], + outputs: [ + { name: "output", type: "BasedOnInput" }, + { name: "outputDepth", type: "BasedOnInput" }, + { name: "objectRenderer", type: "Object" }, + ], + defaultAdditionalConstructionParameters: [true, true], + }, + + NodeRenderGraphShadowGeneratorBlock: { + className: "NodeRenderGraphShadowGeneratorBlock", + category: "Rendering", + description: + "Generates a shadow map for a directional, spot, or point light. " + + "Connect its `output` (ShadowGenerator) to the `shadowGenerators` port of an ObjectRendererBlock " + + "so rendered objects receive shadows from this light.", + inputs: [ + { name: "light", type: "ShadowLight" }, + { name: "objects", type: "ObjectList" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "ShadowGenerator" }], + properties: { + mapSize: "number – shadow map resolution (default: 1024)", + useExponentialShadowMap: "boolean", + usePoissonSampling: "boolean", + useBlurExponentialShadowMap: "boolean", + useCloseExponentialShadowMap: "boolean", + useBlurCloseExponentialShadowMap: "boolean", + usePCSShadowMap: "boolean", + useVSM: "boolean", + bias: "number", + normalBias: "number", + }, + }, + + NodeRenderGraphCascadedShadowGeneratorBlock: { + className: "NodeRenderGraphCascadedShadowGeneratorBlock", + category: "Rendering", + description: + "Generates a Cascaded Shadow Map (CSM) for a directional light. " + + "CSM provides better quality close-up shadows with a smooth fade to lower quality at distance. " + + "Connect its `output` (ShadowGenerator) to the `shadowGenerators` port of an ObjectRendererBlock.", + inputs: [ + { name: "light", type: "ShadowLight" }, + { name: "objects", type: "ObjectList" }, + { name: "camera", type: "Camera" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "ShadowGenerator" }], + properties: { + mapSize: "number – shadow map resolution per cascade (default: 1024)", + numCascades: "number – number of cascades (default: 4; must be 2–4)", + lambda: "number – 0 = uniform, 1 = logarithmic split (default: 0.5)", + shadowMaxZ: "number – maximum shadow distance", + }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Object culling + // ═══════════════════════════════════════════════════════════════════════ + + NodeRenderGraphCullObjectsBlock: { + className: "NodeRenderGraphCullObjectsBlock", + category: "Culling", + description: + "Culls an ObjectList using a camera frustum and returns a reduced ObjectList " + + "containing only the visible objects. " + + "Use this before passing objects to an ObjectRendererBlock for better performance.", + inputs: [ + { name: "camera", type: "Camera" }, + { name: "objects", type: "ObjectList" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "ObjectList" }], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Post Processes + // ═══════════════════════════════════════════════════════════════════════ + + NodeRenderGraphBloomPostProcessBlock: { + className: "NodeRenderGraphBloomPostProcessBlock", + category: "PostProcess", + description: + "Adds a bloom glow effect to bright areas of the source texture. " + + "Connect a rendered colour texture to `source`. " + + "Use additionalConstructionParameters=[hdr, bloomScale] to set HDR mode and scale at creation; " + + "threshold, weight, and kernel can be set via set_block_properties.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + hdr: "boolean – use HDR textures (default: false) — set via additionalConstructionParameters[0]", + bloomScale: "number – scale factor (default: 0.5) — set via additionalConstructionParameters[1]", + threshold: "number – brightness threshold to bloom (default: 0.9)", + weight: "number – bloom intensity (default: 0.15)", + kernel: "number – blur kernel size (default: 64)", + }, + defaultAdditionalConstructionParameters: [false, 0.5], + }, + + NodeRenderGraphBlurPostProcessBlock: { + className: "NodeRenderGraphBlurPostProcessBlock", + category: "PostProcess", + description: "Applies a Gaussian blur to a texture. additionalConstructionParameters=[direction {x,y}, blockSize] control the blur direction and radius.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + kernel: "number – blur kernel size / radius", + }, + defaultAdditionalConstructionParameters: [{ x: 1, y: 0 }, 1], + }, + + NodeRenderGraphFXAAPostProcessBlock: { + className: "NodeRenderGraphFXAAPostProcessBlock", + category: "PostProcess", + description: "Applies Fast Approximate Anti-Aliasing (FXAA) to reduce jagged edges on the texture.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + NodeRenderGraphSharpenPostProcessBlock: { + className: "NodeRenderGraphSharpenPostProcessBlock", + category: "PostProcess", + description: "Sharpens the image to enhance detail clarity.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + colorAmount: "number – amount of colour sharpening (0–1, default: 0.8)", + edgeAmount: "number – edge detection strength (0–2, default: 0.3)", + }, + }, + + NodeRenderGraphChromaticAberrationPostProcessBlock: { + className: "NodeRenderGraphChromaticAberrationPostProcessBlock", + category: "PostProcess", + description: "Simulates lens chromatic aberration by separating the colour channels slightly.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + aberrationAmount: "number – strength of aberration (default: 30)", + radialIntensity: "number – how the effect grows from centre outwards (default: 1)", + direction: "{x,y} – direction of channel shift (default: {x:0.707,y:0.707})", + centerPosition: "{x,y} – centre of effect (default: {x:0.5,y:0.5})", + }, + }, + + NodeRenderGraphGrainPostProcessBlock: { + className: "NodeRenderGraphGrainPostProcessBlock", + category: "PostProcess", + description: "Overlays animated film-grain noise on the image.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + intensity: "number – grain intensity (default: 30)", + animated: "boolean – animate noise each frame (default: true)", + }, + }, + + NodeRenderGraphBlackAndWhitePostProcessBlock: { + className: "NodeRenderGraphBlackAndWhitePostProcessBlock", + category: "PostProcess", + description: "Converts the image to greyscale.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + degree: "number – 0 = no change, 1 = fully greyscale (default: 1)", + }, + }, + + NodeRenderGraphTonemapPostProcessBlock: { + className: "NodeRenderGraphTonemapPostProcessBlock", + category: "PostProcess", + description: "Applies tone-mapping to convert HDR values to LDR for display.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + operator: "number – tone-mapping operator: Hable=0, Reinhard=1, HejiDawson=2, Photographic=3, DX11DSK=4, Linear=5, ACES=6 (default: Hable=0)", + exposureAdjustment: "number – exposure multiplier (default: 1)", + }, + }, + + NodeRenderGraphDepthOfFieldPostProcessBlock: { + className: "NodeRenderGraphDepthOfFieldPostProcessBlock", + category: "PostProcess", + description: + "Simulates camera depth-of-field blur. " + + "Requires a `geomViewDepth` texture (from a GeometryRenderer) and a Camera. " + + "additionalConstructionParameters=[blurLevel, hdr]: blurLevel values: Low=0, Medium=1, High=2.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "geomViewDepth", type: "TextureViewDepth" }, + { name: "camera", type: "Camera" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + blurLevel: "number – blur quality (0=Low, 1=Medium, 2=High) — set via additionalConstructionParameters[0]", + hdr: "boolean – use HDR — set via additionalConstructionParameters[1]", + focalLength: "number – focal length in mm (default: 150)", + fStop: "number – f-stop / aperture (default: 1.4)", + focusDistance: "number – focus distance in mm (default: 2000)", + lensSize: "number – lens size (default: 50)", + }, + defaultAdditionalConstructionParameters: [0, false], + }, + + NodeRenderGraphSSRPostProcessBlock: { + className: "NodeRenderGraphSSRPostProcessBlock", + category: "PostProcess", + description: + "Screen-Space Reflections (SSR). " + + "Requires geometry buffers from GeometryRendererBlock: geomDepth (ScreenDepth or ViewDepth), " + + "geomNormal (WorldNormal or ViewNormal), geomReflectivity, and a Camera. " + + "Optional geomBackDepth provides better occlusion accuracy.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "camera", type: "Camera" }, + { name: "geomDepth", type: "AutoDetect" }, + { name: "geomNormal", type: "AutoDetect" }, + { name: "geomReflectivity", type: "TextureReflectivity" }, + { name: "geomBackDepth", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + maxDistance: "number – max reflection ray distance (default: 1000)", + step: "number – ray marching step size (default: 1)", + thickness: "number – thickness tolerance (default: 0.5)", + strength: "number – reflection strength (default: 1)", + maxSteps: "number – maximum ray march iterations (default: 1000)", + roughnessFactor: "number", + selfCollisionNumSkip: "number – steps to skip for self-collision avoidance (default: 1)", + }, + }, + + NodeRenderGraphSSAO2PostProcessBlock: { + className: "NodeRenderGraphSSAO2PostProcessBlock", + category: "PostProcess", + description: + "Screen-Space Ambient Occlusion v2 (SSAO2). " + + "Requires `geomViewDepth` (TextureViewDepth) and optionally `geomViewNormal` (TextureViewNormal) " + + "from a GeometryRendererBlock. additionalConstructionParameters=[ratio (number)].", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "geomViewDepth", type: "TextureViewDepth" }, + { name: "geomViewNormal", type: "TextureViewNormal", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + ratio: "number – render ratio (default: 0.5) — set via additionalConstructionParameters[0]", + maxZ: "number – max distance for AO sampling (default: 100)", + minZAspect: "number", + radius: "number – sampling radius (default: 2)", + totalStrength: "number – AO intensity (default: 1)", + base: "number – ambient baseline (default: 0)", + samples: "number – sample count (default: 8)", + }, + defaultAdditionalConstructionParameters: [0.5], + }, + + NodeRenderGraphTAAPostProcessBlock: { + className: "NodeRenderGraphTAAPostProcessBlock", + category: "PostProcess", + description: + "Temporal Anti-Aliasing (TAA). Accumulates samples across frames for smooth anti-aliasing. " + + "Requires a Camera. additionalConstructionParameters=[samples (number), factor (number)].", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "camera", type: "Camera" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + samples: "number – number of accumulated samples (default: 8) — additionalConstructionParameters[0]", + factor: "number – blend factor between current & accumulated (default: 0.05) — additionalConstructionParameters[1]", + }, + defaultAdditionalConstructionParameters: [8, 0.05], + }, + + NodeRenderGraphMotionBlurPostProcessBlock: { + className: "NodeRenderGraphMotionBlurPostProcessBlock", + category: "PostProcess", + description: "Applies motion blur based on per-pixel motion vectors. Requires `geomViewDepth` and `geomViewNormal`. Camera is also required.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "camera", type: "Camera" }, + { name: "geomViewDepth", type: "TextureViewDepth" }, + { name: "geomViewNormal", type: "TextureViewNormal" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + motionBlurSamples: "number – number of samples (default: 32)", + motionStrength: "number – overall blur strength (default: 1)", + }, + }, + + NodeRenderGraphImageProcessingPostProcessBlock: { + className: "NodeRenderGraphImageProcessingPostProcessBlock", + category: "PostProcess", + description: + "Applies the scene's ImageProcessingConfiguration (tone-mapping, exposure, contrast, saturation, " + + "colour grading LUT, etc.). This is the standard post-process used by the default rendering pipeline.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + NodeRenderGraphColorCorrectionPostProcessBlock: { + className: "NodeRenderGraphColorCorrectionPostProcessBlock", + category: "PostProcess", + description: "Applies colour correction using a colour-lookup table (LUT) texture.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + colorTableUrl: "string – URL/path to the colour lookup table texture", + }, + }, + + NodeRenderGraphConvolutionPostProcessBlock: { + className: "NodeRenderGraphConvolutionPostProcessBlock", + category: "PostProcess", + description: "Applies a convolution kernel (e.g. edge detection, emboss, sharpen) to the image.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + kernel: "number[] – flat 3×3 convolution kernel values (9 numbers)", + }, + }, + + NodeRenderGraphFilterPostProcessBlock: { + className: "NodeRenderGraphFilterPostProcessBlock", + category: "PostProcess", + description: "Applies a colour matrix filter to transform colours. " + "Each row of the 4×4 matrix maps an output RGBA channel from input RGBA.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + kernelMatrix: "Matrix (16 numbers) – 4×4 colour transformation matrix", + }, + }, + + NodeRenderGraphAnaglyphPostProcessBlock: { + className: "NodeRenderGraphAnaglyphPostProcessBlock", + category: "PostProcess", + description: "Produces an anaglyph 3D effect (red-cyan glasses). Requires two texture views (left/right eye).", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + NodeRenderGraphScreenSpaceCurvaturePostProcessBlock: { + className: "NodeRenderGraphScreenSpaceCurvaturePostProcessBlock", + category: "PostProcess", + description: "Highlights surface curvature based on screen-space derivatives of normals.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + ridge: "number – ridge curvature strength (default: 1)", + valley: "number – valley curvature strength (default: 1)", + }, + }, + + NodeRenderGraphExtractHighlightsPostProcessBlock: { + className: "NodeRenderGraphExtractHighlightsPostProcessBlock", + category: "PostProcess", + description: "Extracts pixels above a luminance threshold. Used as an intermediate step in bloom pipelines.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + threshold: "number – luminance threshold (default: 0.9)", + }, + }, + + NodeRenderGraphPassPostProcessBlock: { + className: "NodeRenderGraphPassPostProcessBlock", + category: "PostProcess", + description: "A no-op pass-through post process. Useful for routing texture connections without modification.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + NodeRenderGraphPassCubePostProcessBlock: { + className: "NodeRenderGraphPassCubePostProcessBlock", + category: "PostProcess", + description: "A no-op pass-through post process for cube textures. Similar to PassPostProcessBlock but for cube map sources.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + NodeRenderGraphCircleOfConfusionPostProcessBlock: { + className: "NodeRenderGraphCircleOfConfusionPostProcessBlock", + category: "PostProcess", + description: "Computes per-pixel circle-of-confusion values from depth. Used as an input to more manual depth-of-field setups. Requires `geomViewDepth` and a Camera.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "geomViewDepth", type: "TextureViewDepth" }, + { name: "camera", type: "Camera" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + NodeRenderGraphVolumetricLightingBlock: { + className: "NodeRenderGraphVolumetricLightingBlock", + category: "PostProcess", + description: "Renders volumetric light shafts (god rays) emanating from a single light source.", + inputs: [ + { name: "source", type: "AutoDetect" }, + { name: "target", type: "AutoDetect", isOptional: true }, + { name: "geomViewDepth", type: "AutoDetect", isOptional: true }, + { name: "camera", type: "Camera" }, + { name: "objects", type: "ObjectList" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + density: "number – ray density (default: 0.7)", + weight: "number – light weight per step (default: 1)", + decay: "number – falloff per step (default: 0.99)", + exposure: "number – final exposure (default: 1)", + }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Layers + // ═══════════════════════════════════════════════════════════════════════ + + NodeRenderGraphGlowLayerBlock: { + className: "NodeRenderGraphGlowLayerBlock", + category: "Layers", + description: + "Adds a glow effect to meshes whose emissive colour is above zero. " + + "Connect the `objectRenderer` output of an ObjectRendererBlock to this block's `objectRenderer` input. " + + "additionalConstructionParameters=[ldrMerge, layerTextureRatio, layerTextureFixedSize|undefined, layerTextureType].", + inputs: [ + { name: "target", type: "AutoDetect" }, + { name: "layer", type: "AutoDetect", isOptional: true }, + { name: "objectRenderer", type: "Object" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + ldrMerge: "boolean – clamp values > 1 in merge step (default: false) — additionalConstructionParameters[0]", + layerTextureRatio: "number – glow texture size relative to main (default: 0.5) — additionalConstructionParameters[1]", + layerTextureFixedSize: "number|undefined – fixed pixel size override — additionalConstructionParameters[2]", + blurKernelSize: "number – blur spread (default: 32)", + intensity: "number – glow intensity (default: 1)", + }, + defaultAdditionalConstructionParameters: [false, 0.5, undefined, 0], + }, + + NodeRenderGraphHighlightLayerBlock: { + className: "NodeRenderGraphHighlightLayerBlock", + category: "Layers", + description: + "Draws a coloured outline / highlight on specific meshes. Connect the `objectRenderer` output of an ObjectRendererBlock to this block's `objectRenderer` input.", + inputs: [ + { name: "target", type: "AutoDetect" }, + { name: "layer", type: "AutoDetect", isOptional: true }, + { name: "objectRenderer", type: "Object" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + properties: { + blurHorizontalSize: "number – horizontal blur (default: 1)", + blurVerticalSize: "number – vertical blur (default: 1)", + isStroke: "boolean – render as stroke/outline instead of fill (default: false)", + }, + }, + + NodeRenderGraphSelectionOutlineLayerBlock: { + className: "NodeRenderGraphSelectionOutlineLayerBlock", + category: "Layers", + description: "Draws a selection outline around the highlighted mesh. Typically used by the Babylon.js Inspector / gizmo system.", + inputs: [ + { name: "target", type: "AutoDetect" }, + { name: "depth", type: "AutoDetect", isOptional: true }, + { name: "camera", type: "Camera" }, + { name: "objects", type: "ObjectList" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Compute + // ═══════════════════════════════════════════════════════════════════════ + + NodeRenderGraphComputeShaderBlock: { + className: "NodeRenderGraphComputeShaderBlock", + category: "Compute", + description: + "Executes a custom compute shader within the frame graph. " + + "Accepts camera, shadow light, and object list dependencies. " + + "Outputs a ResourceContainer for downstream ordering.", + inputs: [{ name: "dependencies", type: "AutoDetect", isOptional: true }], + outputs: [{ name: "output", type: "ResourceContainer" }], + properties: { + shaderPath: "string | IComputeShaderPath – path or inline WGSL source of the compute shader", + shaderOptions: "IComputeShaderOptions – binding mappings and compile options", + }, + defaultAdditionalConstructionParameters: ["@compute @workgroup_size(1, 1, 1)\nfn main() {}", { bindingsMapping: {} }], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // IBL Shadows + // ═══════════════════════════════════════════════════════════════════════ + + NodeRenderGraphIblShadowsRendererBlock: { + className: "NodeRenderGraphIblShadowsRendererBlock", + category: "Rendering", + description: + "Computes image-based lighting (IBL) shadows using voxel tracing. " + + "Requires depth, normal, position, and velocity geometry textures plus a camera and object list. " + + "Produces a shadow texture output.", + inputs: [ + { name: "depth", type: "TextureScreenDepth" }, + { name: "normal", type: "TextureWorldNormal" }, + { name: "position", type: "TextureWorldPosition" }, + { name: "velocity", type: "TextureLinearVelocity" }, + { name: "camera", type: "Camera" }, + { name: "objects", type: "ObjectList" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "Texture" }], + properties: { + sampleDirections: "number – tracing sample directions (1–16, default: varies)", + coloredShadows: "boolean – whether traced shadows preserve environment color", + voxelShadowOpacity: "number – opacity of voxel-traced shadows (0–1)", + ssShadowOpacity: "number – opacity of screen-space shadows (0–1)", + ssShadowSampleCount: "number – screen-space shadow samples (1–64)", + ssShadowStride: "number – screen-space shadow stride (1–32)", + ssShadowDistanceScale: "number – screen-space shadow distance scale", + ssShadowThicknessScale: "number – screen-space shadow thickness scale", + voxelNormalBias: "number – voxel tracing normal bias", + voxelDirectionBias: "number – voxel tracing direction bias", + envRotation: "number – environment rotation in radians", + shadowRemanence: "number – temporal shadow remanence (0–1)", + shadowOpacity: "number – final shadow opacity (0–1)", + resolutionExp: "number – voxelization resolution exponent (1–8, resolution = 2^value)", + refreshRate: "number – voxelization refresh rate (-1: manual, 0: every frame, N: skip N frames)", + triPlanarVoxelization: "boolean – whether to use tri-planar voxelization", + }, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Utilities / Routing + // ═══════════════════════════════════════════════════════════════════════ + + NodeRenderGraphResourceContainerBlock: { + className: "NodeRenderGraphResourceContainerBlock", + category: "Utility", + description: + "Groups multiple texture handles (or shadow generators) into one `ResourceContainer` output. " + + "Use its output as the `dependencies` input of other blocks to express execution ordering " + + "without a direct data-flow connection.", + inputs: [ + { name: "input0", type: "AutoDetect", isOptional: true }, + { name: "input1", type: "AutoDetect", isOptional: true }, + { name: "input2", type: "AutoDetect", isOptional: true }, + { name: "input3", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "ResourceContainer" }], + }, + + NodeRenderGraphElbowBlock: { + className: "NodeRenderGraphElbowBlock", + category: "Utility", + description: "A routing (elbow / pass-through) block that lets you organise connections visually. The output type mirrors whatever is connected to the input.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [{ name: "output", type: "BasedOnInput" }], + }, + + NodeRenderGraphTeleportInBlock: { + className: "NodeRenderGraphTeleportInBlock", + category: "Utility", + description: + "Teleport entry-point. Connects to one or more TeleportOutBlocks to route a signal across the graph " + + "without drawing long connection wires. Must be paired with at least one TeleportOutBlock of the same name.", + inputs: [{ name: "input", type: "AutoDetect" }], + outputs: [], + }, + + NodeRenderGraphTeleportOutBlock: { + className: "NodeRenderGraphTeleportOutBlock", + category: "Utility", + description: + "Teleport exit-point. Receives the value wired into its matching TeleportInBlock. " + + "Multiple TeleportOut blocks can share the same TeleportIn, distributing one signal to many consumers.", + inputs: [], + outputs: [{ name: "output", type: "AutoDetect" }], + }, + + NodeRenderGraphExecuteBlock: { + className: "NodeRenderGraphExecuteBlock", + category: "Utility", + description: "Runs a custom JavaScript callback within the frame-graph execution order. This block is for advanced use — the callback must be set at runtime in code.", + inputs: [{ name: "dependencies", type: "AutoDetect", isOptional: true }], + outputs: [{ name: "output", type: "AutoDetect" }], + }, + + NodeRenderGraphLightingVolumeBlock: { + className: "NodeRenderGraphLightingVolumeBlock", + category: "Utility", + description: "Computes a lighting volume used by the volumetric lighting block for light contribution slicing.", + inputs: [ + { name: "camera", type: "Camera" }, + { name: "objects", type: "ObjectList" }, + { name: "dependencies", type: "AutoDetect", isOptional: true }, + ], + outputs: [{ name: "output", type: "AutoDetect" }], + }, +}; + +// ─── Helper utilities ───────────────────────────────────────────────────────── + +/** + * Returns a compact markdown summary of all block types, grouped by category. + * Suitable for injecting into agent context windows. + * @returns Markdown string with one entry per block type, grouped by category. + */ +export function GetBlockCatalogSummary(): string { + const byCategory: Record = {}; + + for (const [key, info] of Object.entries(BlockRegistry)) { + const cat = info.category; + byCategory[cat] = byCategory[cat] || []; + byCategory[cat].push(`- **${key}**: ${info.description.split(".")[0]}`); + } + + const lines: string[] = []; + for (const [cat, entries] of Object.entries(byCategory)) { + lines.push(`\n### ${cat}`); + lines.push(...entries); + } + return lines.join("\n"); +} + +/** + * Returns detailed markdown documentation for a single block type. + * @param blockType - Block class name WITHOUT the "BABYLON." prefix. + * @returns Markdown string describing the block's ports, properties, and construction parameters. + */ +export function GetBlockTypeDetails(blockType: string): string { + const info = BlockRegistry[blockType]; + if (!info) { + return `Unknown block type "${blockType}". Use list_block_types to see available types.`; + } + + const lines: string[] = [`## ${blockType}`, `**Category**: ${info.category}`, `**Description**: ${info.description}`, "", "### Inputs"]; + + if (info.inputs.length === 0) { + lines.push("_(none)_"); + } else { + for (const p of info.inputs) { + lines.push(`- \`${p.name}\` — **${p.type}**${p.isOptional ? " _(optional)_" : ""}`); + } + } + + lines.push("", "### Outputs"); + if (info.outputs.length === 0) { + lines.push("_(none)_"); + } else { + for (const p of info.outputs) { + lines.push(`- \`${p.name}\` — **${p.type}**`); + } + } + + if (info.properties && Object.keys(info.properties).length > 0) { + lines.push("", "### Configurable Properties"); + for (const [name, desc] of Object.entries(info.properties)) { + lines.push(`- \`${name}\`: ${desc}`); + } + } + + if (info.defaultAdditionalConstructionParameters !== undefined) { + lines.push( + "", + "### additionalConstructionParameters", + `Default: \`${JSON.stringify(info.defaultAdditionalConstructionParameters)}\``, + "These are embedded in the serialised block. Override them when calling add_block if needed." + ); + } + + return lines.join("\n"); +} diff --git a/packages/tools/nrge-mcp-server/src/index.ts b/packages/tools/nrge-mcp-server/src/index.ts new file mode 100644 index 00000000000..5ecd8372078 --- /dev/null +++ b/packages/tools/nrge-mcp-server/src/index.ts @@ -0,0 +1,1093 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-console */ +/** + * Node Render Graph MCP Server (babylonjs-node-render-graph) + * ─────────────────────────────────────────────── + * A Model Context Protocol server that lets AI agents build Babylon.js + * Node Render Graph (NRG / NRGE) pipelines programmatically. + * + * An agent can: + * • Create and manage render-graph definitions in memory + * • Add any NRG block from the full catalog (renderers, post-processes, layers, etc.) + * • Wire blocks together (texture / camera / object-list / shadow flows) + * • Set block-specific properties (e.g. bloom intensity, clear color, DOF blur level) + * • Validate the graph (required inputs, OutputBlock present, etc.) + * • Export NRG-compatible JSON (consumed by NodeRenderGraph.ParseAsync() in Babylon.js) + * • Import existing NRGE JSON for further editing + * • Query the block catalog and type documentation + * + * Integration with Scene MCP + * ────────────────────────── + * This server generates JSON that can be attached to a Babylon.js scene via the + * Scene MCP server's `attach_node_render_graph` tool. Typical workflow: + * 1. Node Render Graph MCP → build & export graph JSON + * 2. Scene MCP → `attach_node_render_graph { sceneName, nrgJson }` + * + * Transport: stdio (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 { + CreateErrorResponse, + CreateInlineJsonSchema, + CreateJsonFileSchema, + CreateJsonImportSummaryResponse, + CreateOutputFileSchema, + CreateOverwriteSchema, + CreateSnippetIdSchema, + CreateTextResponse, + CreateTypedSnippetImportSummaryResponse, + McpEditorSessionController, + ParseJsonText, + RunSnippetResponse, + WriteTextFileEnsuringDirectory, +} from "@tools/mcp-server-core"; + +import { BlockRegistry, GetBlockCatalogSummary, GetBlockTypeDetails } from "./blockRegistry.js"; +import { RenderGraphManager } from "./renderGraph.js"; +import { LoadSnippet, SaveSnippet, type IDataSnippetResult } from "@tools/snippet-loader"; + +// ─── Singleton graph manager ───────────────────────────────────────────── +const manager = new RenderGraphManager(); +const sessionController = new McpEditorSessionController( + { + serverName: "NRGE MCP Session Server", + documentKind: "node-render-graph", + managerUnavailableMessage: "Render graph manager is not available", + getDocument: (manager, session) => manager.exportJson(session.name), + setDocument: (manager, session, document) => { + try { + manager.importJson(session.name, document, true); + return undefined; + } catch (e) { + return (e as Error).message; + } + }, + }, + { + defaultPort: 3001, + statusTitle: "NRGE MCP Session Server", + } +); + +/** + * Notify SSE subscribers if a session exists for the given render graph. + * @param graphName - The render graph name to check for active sessions. + */ +function _notifyIfSession(graphName: string): void { + const sessionId = sessionController.getSessionIdForName(graphName); + if (sessionId) { + sessionController.notifySessionUpdate(sessionId); + } +} + +/** + * Import render graph JSON and notify a matching live session on success. + * @param graphName - The render graph name to import into. + * @param jsonText - Serialized NRGE JSON. + * @param overwrite - Whether to overwrite an existing graph with the same name. + * @returns The imported graph. + */ +function _importGraphJson(graphName: string, jsonText: string, overwrite: boolean = false) { + const graph = manager.importJson(graphName, jsonText, overwrite); + _notifyIfSession(graphName); + return graph; +} + +// ─── MCP Server ────────────────────────────────────────────────────────── +const server = new McpServer( + { + name: "babylonjs-node-render-graph", + version: "1.0.0", + }, + { + instructions: [ + "You build Babylon.js Node Render Graphs (custom render pipelines). Workflow: create_render_graph → add blocks (NodeRenderGraphInputBlock, object/geometry renderers, post-process blocks, NodeRenderGraphOutputBlock) → connect ports → validate_graph → export_graph_json.", + "Every render graph needs an InputBlock (provides camera/scene) and an OutputBlock (final framebuffer). Use get_block_type_info to discover ports.", + "Output JSON can be consumed by the Scene MCP via attach_node_render_graph.", + ].join(" "), + } +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Resources (read-only reference data an agent can always consult) +// ═══════════════════════════════════════════════════════════════════════════ + +server.registerResource("block-catalog", "nrg://block-catalog", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: `# NRG Block Catalog\n\n${GetBlockCatalogSummary()}`, + }, + ], +})); + +server.registerResource("enums", "nrg://enums", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# NRG Enumerations Reference", + "", + "## NodeRenderGraphBlockConnectionPointTypes", + "These numeric values are used as `additionalConstructionParameters[0]` for **NodeRenderGraphInputBlock**.", + "", + "| Type name | Value (hex) | Value (decimal) | Description |", + "|-----------|------------|-----------------|-------------|", + "| Texture | 0x00000001 | 1 | Generic colour texture (RGBA) |", + "| TextureBackBuffer | 0x00000002 | 2 | The engine's back-buffer render target |", + "| TextureBackBufferDepthStencilAttachment | 0x00000004 | 4 | Depth/stencil attachment of the back buffer |", + "| TextureDepthStencilAttachment | 0x00000008 | 8 | Depth/stencil attachment for off-screen textures |", + "| TextureViewDepth | 0x00000010 | 16 | View-space depth (geometry renderer output) |", + "| TextureViewNormal | 0x00000020 | 32 | View-space normal (geometry renderer output) |", + "| TextureAlbedo | 0x00000040 | 64 | Albedo/diffuse G-buffer texture |", + "| TextureReflectivity | 0x00000080 | 128 | Reflectivity G-buffer texture |", + "| TextureWorldPosition | 0x00000100 | 256 | World-space position G-buffer texture |", + "| TextureVelocity | 0x00000200 | 512 | Screen-space velocity (motion vectors) |", + "| TextureIrradiance | 0x00000400 | 1024 | Irradiance G-buffer texture |", + "| TextureLinearVelocity | 0x00000800 | 2048 | Linear (camera-space) velocity |", + "| TextureLocalPosition | 0x00001000 | 4096 | Local/object-space position |", + "| TextureWorldNormal | 0x00002000 | 8192 | World-space normal |", + "| TextureAlbedoSqrt | 0x00004000 | 16384 | Sqrt-mapped albedo for IBL |", + "| ResourceContainer | 0x00100000 | 1048576 | Shared GPU resource container |", + "| ShadowGenerator | 0x00200000 | 2097152 | Shadow generator output |", + "| ShadowLight | 0x00400000 | 4194304 | Directional/spot/point light for shadow casting |", + "| Camera | 0x01000000 | 16777216 | Scene camera |", + "| ObjectList | 0x02000000 | 33554432 | List of renderable objects/meshes |", + "| Object | 0x80000000 | 2147483648 | Generic internal task object (e.g. ObjectRendererTask) |", + "| AutoDetect | 0x10000000 | 268435456 | Type is resolved automatically at build time |", + "| BasedOnInput | 0x20000000 | 536870912 | Output type mirrors a specific input |", + "", + "## Using InputBlock types", + "When adding a `NodeRenderGraphInputBlock` you must pass the connection-point type as", + "`additionalConstructionParameters[0]` (an integer from the table above).", + "", + "Common examples:", + " • Back-buffer colour: `additionalConstructionParameters: [2]` (TextureBackBuffer)", + " • Off-screen texture: `additionalConstructionParameters: [1]` (Texture)", + " • Depth/stencil: `additionalConstructionParameters: [8]` (TextureDepthStencilAttachment)", + " • Back-buffer depth: `additionalConstructionParameters: [4]` (TextureBackBufferDepthStencilAttachment)", + " • Camera: `additionalConstructionParameters: [16777216]` (Camera)", + " • Object list: `additionalConstructionParameters: [33554432]` (ObjectList)", + " • Shadow light: `additionalConstructionParameters: [4194304]` (ShadowLight)", + "", + "## Key additionalConstructionParameters for other blocks", + "", + "**NodeRenderGraphObjectRendererBlock**", + "`[doNotChangeAspectRatio (boolean), enableClusteredLights (boolean)]`", + "Default: `[true, false]`", + "", + "**NodeRenderGraphGeometryRendererBlock** (same as ObjectRenderer)", + "Default: `[true, false]`", + "", + "**NodeRenderGraphBloomPostProcessBlock**", + "`[hdr (boolean), bloomScale (number)]` — hdr enables HDR pipeline; bloomScale controls texture scale.", + "Default: `[false, 0.5]`", + "", + "**NodeRenderGraphDepthOfFieldPostProcessBlock**", + "`[blurLevel (0|1|2 = Low|Medium|High), hdr (boolean)]`", + "Default: `[0, false]`", + "", + "**NodeRenderGraphSSRPostProcessBlock**", + "`[textureType (number)]` — Engine.TEXTURETYPE_UNSIGNED_BYTE=0, TEXTURETYPE_HALF_FLOAT=2.", + "Default: `[0]`", + "", + "**NodeRenderGraphTAAPostProcessBlock**", + "`[samples (number), factor (number)]` — Defaults: 8 samples, factor 1.0.", + "Default: `[8, 1.0]`", + "", + "**NodeRenderGraphGlowLayerBlock**", + "`[ldrMerge (boolean), ratio (number), fixedSize (number|undefined), type (number|undefined)]`", + "Default: `[false, 0.5, undefined, undefined]`", + ].join("\n"), + }, + ], +})); + +server.registerResource("concepts", "nrg://concepts", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# Node Render Graph (NRG) Concepts", + "", + "## What is a Node Render Graph?", + "The Node Render Graph is Babylon.js's frame-graph-based render pipeline builder.", + "Instead of writing custom render loops, you connect typed blocks that represent render", + "operations — clearing textures, rendering objects, applying post-processes, adding layers —", + "and Babylon.js executes them in the correct order at runtime.", + "", + "## Graph Execution Model", + "NRG graphs are **directed acyclic graphs** of render tasks. Data flows", + "**left-to-right**: source blocks produce textures/objects/cameras that downstream", + "blocks consume. The graph evaluates every frame.", + "", + "## Required Output Block", + "Every render graph MUST contain exactly **one** `NodeRenderGraphOutputBlock`.", + "Its `texture` input must be connected with the final rendered colour texture.", + "The block's `id` is stored as `outputNodeId` in the serialised JSON.", + "", + "## Block Categories", + "", + "### Input (sources of external data)", + " • **NodeRenderGraphInputBlock** — exposes an external resource (texture, camera,", + " object list, shadow light) as a graph input. Set `additionalConstructionParameters[0]`", + " to the desired connection-point type (see nrg://enums).", + " • **NodeRenderGraphOutputBlock** — the required graph sink. Connect the final", + " colour texture to its `texture` input.", + "", + "### Textures (GPU texture operations)", + " • **NodeRenderGraphClearBlock** — clears a texture with a solid colour /", + " optional depth clear. Usually the first block that processes a texture.", + " • **NodeRenderGraphCopyTextureBlock** — copies one texture to another.", + " • **NodeRenderGraphGenerateMipmapsBlock** — generates mipmaps for sampled textures.", + "", + "### Rendering (draw calls)", + " • **NodeRenderGraphObjectRendererBlock** — the standard forward-rendering block.", + " Needs: objectList, camera, texture (colour RT), textureDepth (depth/stencil RT).", + " • **NodeRenderGraphGeometryRendererBlock** — deferred G-buffer renderer. Writes", + " multiple G-buffer textures (viewDepth, viewNormal, albedo, reflectivity, ...).", + " • **NodeRenderGraphShadowGeneratorBlock** — renders a shadow map for one light.", + " • **NodeRenderGraphCascadedShadowGeneratorBlock** — cascaded shadow-map generator.", + " • **NodeRenderGraphCullObjectsBlock** — culls objects to a frustum. Outputs", + " separate lists for opaque / alpha-test / transparent objects.", + "", + "### Post-Process (screen-space effects applied to a texture)", + "All post-process blocks accept `source` (the input texture) and output `output`", + "(the processed texture). They also require a `camera` input.", + "Notable blocks: Bloom, Blur, FXAA, Sharpen, ChromaticAberration, Grain,", + "BlackAndWhite, Tonemap, DepthOfField, SSR, SSAO2, TAA, MotionBlur,", + "ImageProcessing, ColorCorrection, Convolution, Filter, Pass, and more.", + "", + "### Layers (translucent overlay passes)", + " • **NodeRenderGraphGlowLayerBlock** — adds a halo-glow effect.", + " • **NodeRenderGraphHighlightLayerBlock** — adds coloured outlines/glow to", + " specific meshes.", + " • **NodeRenderGraphSelectionOutlineLayerBlock** — renders an outline around", + " selected objects.", + "", + "### Utility (graph helpers)", + " • **NodeRenderGraphResourceContainerBlock** — groups GPU resources for sharing.", + " • **NodeRenderGraphElbowBlock** — re-routes a connection wire (cosmetic).", + " • **NodeRenderGraphTeleportInBlock / TeleportOutBlock** — split long-distance", + " wires into a named teleport pair.", + " • **NodeRenderGraphExecuteBlock** — runs an arbitrary custom callback inside", + " the frame graph.", + "", + "## The Simplest Render Graph", + "```", + "InputBlock(BackBuffer) → ClearBlock.target → ObjectRendererBlock.texture", + "InputBlock(BackBufferDepth) → ObjectRendererBlock.textureDepth", + "InputBlock(Camera) → ObjectRendererBlock.camera", + "InputBlock(ObjectList) → ObjectRendererBlock.objects", + "ObjectRendererBlock.texture → OutputBlock.texture", + "```", + "", + "## Adding a Post-Process", + "Post-process blocks sit BETWEEN the renderer output and the OutputBlock:", + "```", + "...ObjectRendererBlock.texture → BloomBlock.source", + "InputBlock(Camera) → BloomBlock.camera", + "BloomBlock.output → OutputBlock.texture", + "```", + "", + "## Scene MCP Integration", + "After exporting NRG JSON with `export_graph_json`, pass it to the", + "Scene MCP server's `attach_node_render_graph` tool to apply it to a scene.", + "The scene MCP will store the JSON and emit the appropriate", + "`NodeRenderGraph.ParseAsync(...)` code in the generated scene script.", + "", + "## Common Mistakes", + "1. Forgetting NodeRenderGraphOutputBlock — graph cannot evaluate.", + "2. Not setting `additionalConstructionParameters` on InputBlock.", + "3. Not connecting a camera to post-process blocks.", + "4. Leaving ObjectRendererBlock.objects or .camera disconnected.", + "5. Using wrong texture types — colour targets need Texture (1) or", + " TextureBackBuffer (2); depth targets need the depth-stencil types (4 or 8).", + ].join("\n"), + }, + ], +})); + +// ═══════════════════════════════════════════════════════════════════════════ +// Prompts (reusable step-by-step templates) +// ═══════════════════════════════════════════════════════════════════════════ + +server.registerPrompt( + "basic-render-graph", + { + description: "Step-by-step instructions for building the simplest possible render graph " + "(clear + object renderer + output) for use with the Scene MCP.", + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Build a minimal render graph that renders a scene's objects to the back-buffer.", + "", + "Steps:", + "1. create_render_graph name='BasicPipeline'", + "2. add_block blockType='NodeRenderGraphInputBlock' blockName='BackBuffer'", + " additionalConstructionParameters=[2] ← TextureBackBuffer", + "3. add_block blockType='NodeRenderGraphInputBlock' blockName='DepthBuffer'", + " additionalConstructionParameters=[4] ← TextureBackBufferDepthStencilAttachment", + "4. add_block blockType='NodeRenderGraphInputBlock' blockName='MainCamera'", + " additionalConstructionParameters=[16777216] ← Camera", + "5. add_block blockType='NodeRenderGraphInputBlock' blockName='Objects'", + " additionalConstructionParameters=[33554432] ← ObjectList", + "6. add_block blockType='NodeRenderGraphClearBlock' blockName='Clear'", + "7. add_block blockType='NodeRenderGraphObjectRendererBlock' blockName='Renderer'", + "8. add_block blockType='NodeRenderGraphOutputBlock' blockName='Output'", + "", + "Connections:", + "9. connect BackBuffer.output → Clear.target", + "10. connect Clear.output → Renderer.texture", + "11. connect DepthBuffer.output → Renderer.textureDepth", + "12. connect MainCamera.output → Renderer.camera", + "13. connect Objects.output → Renderer.objects", + "14. connect Renderer.texture → Output.texture", + "", + "15. validate_graph", + "16. export_graph_json", + "17. Optionally: pass the JSON to the Scene MCP `attach_node_render_graph` tool.", + ].join("\n"), + }, + }, + ], + }) +); + +server.registerPrompt( + "post-process-pipeline", + { + description: "Step-by-step instructions for building a render graph with Bloom and FXAA post-processes.", + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Build a render graph: clear → render objects → Bloom → FXAA → output.", + "", + "1. create_render_graph name='BloomFxaaPipeline'", + "2-5. Add InputBlocks for BackBuffer, DepthBuffer, MainCamera, Objects (same types as basic-render-graph).", + "6. add_block NodeRenderGraphClearBlock 'Clear'", + "7. add_block NodeRenderGraphObjectRendererBlock 'Renderer'", + "8. add_block NodeRenderGraphBloomPostProcessBlock 'Bloom'", + " additionalConstructionParameters=[false, 0.5] ← hdr=false, bloomScale=0.5", + "9. add_block NodeRenderGraphFXAAPostProcessBlock 'FXAA'", + "10. add_block NodeRenderGraphOutputBlock 'Output'", + "", + "Connections:", + "11. BackBuffer.output → Clear.target", + "12. Clear.output → Renderer.texture", + "13. DepthBuffer.output → Renderer.textureDepth", + "14. MainCamera.output → Renderer.camera", + "15. Objects.output → Renderer.objects", + "16. Renderer.texture → Bloom.source", + "17. MainCamera.output → Bloom.camera", + "18. Bloom.output → FXAA.source", + "19. MainCamera.output → FXAA.camera", + "20. FXAA.output → Output.texture", + "", + "21. validate_graph", + "22. export_graph_json", + ].join("\n"), + }, + }, + ], + }) +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Tools +// ═══════════════════════════════════════════════════════════════════════════ + +// ── Graph lifecycle ────────────────────────────────────────────────────── + +server.registerTool( + "create_render_graph", + { + description: "Create a new empty Node Render Graph in memory. This is always the first step.", + inputSchema: { + name: z.string().describe("Unique name for the render graph (e.g. 'BloomPipeline', 'DeferredRenderer')"), + comment: z.string().optional().describe("Optional description of what this pipeline does"), + }, + }, + async ({ name, comment }) => { + try { + manager.create(name, comment); + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(name); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return { + content: [ + { + type: "text", + text: [ + `Created render graph "${name}".`, + "", + "Next steps:", + " 1. Add InputBlocks for textures, camera, object list (add_block)", + " 2. Add rendering/post-process/layer blocks (add_block)", + " 3. Add NodeRenderGraphOutputBlock (add_block)", + " 4. Wire them together (connect_blocks)", + " 5. Validate (validate_graph) and export (export_graph_json)", + "", + `MCP Session URL: ${sessionUrl}`, + "", + "Tip: read nrg://concepts for a full overview and nrg://block-catalog for all block types.", + ].join("\n"), + }, + ], + }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } +); + +server.registerTool( + "get_session_url", + { + description: "Get or create a live-session URL for a render graph. The URL can be pasted into the Node Render Graph Editor MCP session panel.", + inputSchema: { + graphName: z.string().describe("Name of the render graph"), + }, + }, + async ({ graphName }) => { + const renderGraphs = manager.list(); + if (!renderGraphs.includes(graphName)) { + return CreateErrorResponse(`Render graph "${graphName}" not found.`); + } + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(graphName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return CreateTextResponse(`MCP Session URL: ${sessionUrl}`); + } +); + +server.registerTool( + "start_session", + { + description: "Start a live session for an existing render graph. If a session already exists for this render graph, returns the existing URL.", + inputSchema: { + graphName: z.string().describe("Name of the render graph"), + }, + }, + async ({ graphName }) => { + const renderGraphs = manager.list(); + if (!renderGraphs.includes(graphName)) { + return CreateErrorResponse(`Render graph "${graphName}" not found.`); + } + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(graphName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return CreateTextResponse(`MCP Session URL: ${sessionUrl}`); + } +); + +server.registerTool( + "close_session", + { + description: "Close a live session for a render graph. Disconnects all SSE subscribers in the editor and removes the session. The render graph itself is NOT deleted.", + inputSchema: { + graphName: z.string().describe("Name of the render graph whose session to close"), + }, + }, + async ({ graphName }) => { + const closed = sessionController.closeSessionForName(graphName); + if (!closed) { + return CreateTextResponse(`No active session for "${graphName}".`); + } + return CreateTextResponse(`Session for "${graphName}" closed. The editor will disconnect.`); + } +); + +server.registerTool( + "stop_session_server", + { + description: "Stop the live MCP editor session server started by this MCP process. This closes all active sessions, disconnects editors, and releases the port.", + }, + async () => { + await sessionController.stopAsync(); + return CreateTextResponse("MCP session server stopped. Any connected editors have been disconnected."); + } +); + +server.registerTool( + "delete_render_graph", + { + description: "Delete a render graph from memory.", + inputSchema: { + name: z.string().describe("Name of the render graph to delete"), + }, + }, + async ({ name }) => { + try { + sessionController.closeSessionForName(name); + manager.delete(name); + return { content: [{ type: "text", text: `Deleted render graph "${name}".` }] }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } +); + +server.registerTool("clear_all", { description: "Remove all render graphs from memory, resetting the server to a clean state." }, async () => { + const names = manager.list(); + for (const name of names) { + sessionController.closeSessionForName(name); + } + manager.clearAll(); + return { + content: [{ type: "text", text: names.length > 0 ? `Cleared ${names.length} render graph(s): ${names.join(", ")}` : "Nothing to clear — memory was already empty." }], + }; +}); + +server.registerTool("list_render_graphs", { description: "List all render graphs currently in memory." }, async () => { + const names = manager.list(); + return { + content: [ + { + type: "text", + text: names.length > 0 ? `Render graphs in memory:\n${names.map((n) => ` • ${n}`).join("\n")}` : "No render graphs in memory.", + }, + ], + }; +}); + +// ── Block operations ───────────────────────────────────────────────────── + +server.registerTool( + "add_block", + { + description: [ + "Add a block to a render graph. Returns the block's unique id, which you pass to connect_blocks.", + "", + "IMPORTANT for NodeRenderGraphInputBlock:", + " You MUST supply additionalConstructionParameters=[] where is the", + " integer connection-point type from nrg://enums. For example:", + " • Back-buffer colour: [2] (TextureBackBuffer)", + " • Off-screen texture: [1] (Texture)", + " • Depth/stencil attachment: [8] (TextureDepthStencilAttachment)", + " • Back-buffer depth: [4] (TextureBackBufferDepthStencilAttachment)", + " • Camera: [16777216]", + " • Object list: [33554432]", + " • Shadow light: [4194304]", + ].join("\n"), + inputSchema: { + graphName: z.string().describe("Name of the render graph"), + blockType: z + .string() + .describe( + "Block class name WITHOUT the 'BABYLON.' prefix " + + "(e.g. 'NodeRenderGraphInputBlock', 'NodeRenderGraphClearBlock', " + + "'NodeRenderGraphObjectRendererBlock', 'NodeRenderGraphBloomPostProcessBlock', " + + "'NodeRenderGraphOutputBlock'). Use list_block_types for the full catalog." + ), + blockName: z.string().optional().describe("Human-friendly label for this block (defaults to blockType)"), + additionalConstructionParameters: z + .array(z.unknown()) + .optional() + .describe( + "Constructor arguments beyond (name, frameGraph, scene). Required for NodeRenderGraphInputBlock " + + "and several post-process blocks. See nrg://enums for values and details." + ), + }, + }, + async ({ graphName, blockType, blockName, additionalConstructionParameters }) => { + try { + const block = manager.addBlock(graphName, blockType, blockName, additionalConstructionParameters as unknown[] | undefined); + _notifyIfSession(graphName); + return { + content: [ + { + type: "text", + text: [ + `Added block id=${block.id} name="${block.name}" type=${blockType}`, + `Inputs: ${block.inputs.map((i) => i.name).join(", ") || "(none)"}`, + `Outputs: ${block.outputs.map((o) => o.name).join(", ") || "(none)"}`, + "", + `Use id ${block.id} in connect_blocks / disconnect_input / set_block_properties.`, + ].join("\n"), + }, + ], + }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } +); + +server.registerTool( + "add_blocks_batch", + { + description: "Add multiple blocks in one call. More efficient than repeated add_block calls. Returns all created block ids.", + inputSchema: { + graphName: z.string().describe("Name of the render graph"), + blocks: z + .array( + z.object({ + blockType: z.string().describe("Block class name (without 'BABYLON.' prefix)"), + blockName: z.string().optional().describe("Human-friendly label"), + additionalConstructionParameters: z.array(z.unknown()).optional().describe("Constructor args (required for InputBlock and some post-processes)"), + }) + ) + .describe("List of blocks to add"), + }, + }, + async ({ graphName, blocks }) => { + try { + const results = manager.addBlocksBatch(graphName, blocks as Array<{ blockType: string; blockName?: string; additionalConstructionParameters?: unknown[] }>); + _notifyIfSession(graphName); + const lines = results.map( + (b) => + ` id=${b.id} "${b.name}" (${b.customType.replace("BABYLON.", "")}) inputs=[${b.inputs.map((i) => i.name).join(", ")}] outputs=[${b.outputs.map((o) => o.name).join(", ")}]` + ); + return { + content: [ + { + type: "text", + text: `Added ${results.length} blocks:\n${lines.join("\n")}`, + }, + ], + }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } +); + +server.registerTool( + "remove_block", + { + description: "Remove a block and all its connections from a render graph.", + inputSchema: { + graphName: z.string().describe("Name of the render graph"), + blockId: z.number().describe("Block id (from add_block or describe_graph output)"), + }, + }, + async ({ graphName, blockId }) => { + try { + manager.removeBlock(graphName, blockId); + _notifyIfSession(graphName); + return { content: [{ type: "text", text: `Removed block id=${blockId} and all its connections.` }] }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } +); + +// ── Connection operations ───────────────────────────────────────────────── + +server.registerTool( + "connect_blocks", + { + description: [ + "Connect an output port of one block to an input port of another block.", + "", + "How to read port names:", + " • Call describe_block or describe_graph to see each block's exact input and output port names.", + " • Call get_block_type_info to see the canonical port names defined in the catalog.", + "", + "Common connections:", + " InputBlock(BackBuffer).output → ClearBlock.target", + " ClearBlock.output → ObjectRendererBlock.texture", + " ObjectRendererBlock.texture → BloomBlock.source", + " BloomBlock.output → OutputBlock.texture", + ].join("\n"), + inputSchema: { + graphName: z.string().describe("Name of the render graph"), + sourceBlockId: z.number().describe("Id of the block providing the output value"), + sourcePortName: z.string().describe("Output port name on the source block (e.g. 'output', 'texture')"), + targetBlockId: z.number().describe("Id of the block receiving the input"), + targetPortName: z.string().describe("Input port name on the target block (e.g. 'target', 'source', 'camera', 'objects')"), + }, + }, + async ({ graphName, sourceBlockId, sourcePortName, targetBlockId, targetPortName }) => { + try { + manager.connect(graphName, sourceBlockId, sourcePortName, targetBlockId, targetPortName); + _notifyIfSession(graphName); + return { + content: [ + { + type: "text", + text: `Connected: block[${sourceBlockId}].${sourcePortName} → block[${targetBlockId}].${targetPortName}`, + }, + ], + }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } +); + +server.registerTool( + "connect_blocks_batch", + { + description: "Connect multiple block pairs in one call. More efficient than repeated connect_blocks calls.", + inputSchema: { + graphName: z.string().describe("Name of the render graph"), + connections: z + .array( + z.object({ + sourceBlockId: z.number().describe("Id of the source block"), + sourcePortName: z.string().describe("Output port name on the source block"), + targetBlockId: z.number().describe("Id of the target block"), + targetPortName: z.string().describe("Input port name on the target block"), + }) + ) + .describe("List of connections to create"), + }, + }, + async ({ graphName, connections }) => { + try { + manager.connectBatch(graphName, connections); + _notifyIfSession(graphName); + const lines = connections.map((c) => ` block[${c.sourceBlockId}].${c.sourcePortName} → block[${c.targetBlockId}].${c.targetPortName}`); + return { + content: [{ type: "text", text: `Created ${connections.length} connections:\n${lines.join("\n")}` }], + }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } +); + +server.registerTool( + "disconnect_input", + { + description: "Remove a connection from an input port of a block.", + inputSchema: { + graphName: z.string().describe("Name of the render graph"), + blockId: z.number().describe("Id of the block whose input should be disconnected"), + inputPortName: z.string().describe("Name of the input port to disconnect"), + }, + }, + async ({ graphName, blockId, inputPortName }) => { + try { + manager.disconnectInput(graphName, blockId, inputPortName); + _notifyIfSession(graphName); + return { content: [{ type: "text", text: `Disconnected input "${inputPortName}" on block id=${blockId}.` }] }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } +); + +// ── Properties ──────────────────────────────────────────────────────────── + +server.registerTool( + "set_block_properties", + { + description: [ + "Set one or more properties on a block, such as custom colours, threshold values,", + "intensity multipliers, or constructor parameters.", + "", + "Properties become top-level keys in the block serialisation, which is how", + "Babylon.js reads them back at deserialization time.", + "", + "Special key:", + " `additionalConstructionParameters` (array) — replaces the constructor args.", + " Use this to change an InputBlock's texture type after it was added.", + "", + "Examples:", + " ClearBlock: { clearColor: {r:0,g:0,b:0,a:1}, clearDepth: true }", + " BloomBlock: { threshold: 0.8, weight: 0.7 }", + " TaaBlock: { additionalConstructionParameters: [16, 0.9] }", + " GlowLayerBlock: { intensity: 1.5, blurKernelSize: 64 }", + ].join("\n"), + inputSchema: { + graphName: z.string().describe("Name of the render graph"), + blockId: z.number().describe("Id of the block to update"), + properties: z.record(z.string(), z.unknown()).describe("Key-value properties to set on the block"), + }, + }, + async ({ graphName, blockId, properties }) => { + try { + manager.setBlockProperties(graphName, blockId, properties); + _notifyIfSession(graphName); + const keys = Object.keys(properties); + return { content: [{ type: "text", text: `Updated block id=${blockId}: ${keys.join(", ")}` }] }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } +); + +// ── Describe / inspect ──────────────────────────────────────────────────── + +server.registerTool( + "describe_block", + { + description: "Get a detailed description of a single block: its ports, connections, and properties.", + inputSchema: { + graphName: z.string().describe("Name of the render graph"), + blockId: z.number().describe("Block id to describe"), + }, + }, + async ({ graphName, blockId }) => { + try { + const text = manager.describeBlock(graphName, blockId); + return { content: [{ type: "text", text }] }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } +); + +server.registerTool( + "describe_graph", + { + description: "Get a full human-readable overview of a render graph: all blocks, connections, and properties.", + inputSchema: { + graphName: z.string().describe("Name of the render graph"), + }, + }, + async ({ graphName }) => { + try { + const text = manager.describeGraph(graphName); + return { content: [{ type: "text", text }] }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } +); + +// ── Validation ──────────────────────────────────────────────────────────── + +server.registerTool( + "validate_graph", + { + description: [ + "Validate a render graph, checking for common issues:", + " • Missing NodeRenderGraphOutputBlock", + " • OutputBlock.texture not connected", + " • Required inputs left unconnected", + " • Dangling references to deleted blocks", + " • InputBlocks missing additionalConstructionParameters", + "", + "Always call this before export_graph_json.", + ].join("\n"), + inputSchema: { + graphName: z.string().describe("Name of the render graph to validate"), + }, + }, + async ({ graphName }) => { + try { + const { valid, messages } = manager.validate(graphName); + const header = valid ? `Graph "${graphName}" is valid.` : `Graph "${graphName}" has issues:`; + const body = messages.length > 0 ? "\n\n" + messages.map((m) => ` • ${m}`).join("\n") : ""; + return { content: [{ type: "text", text: header + body }] }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } +); + +// ── Export / Import ─────────────────────────────────────────────────────── + +server.registerTool( + "export_graph_json", + { + description: [ + "Export a render graph as NRGE-compatible JSON string.", + "", + "The returned JSON can be:", + " • Loaded by Babylon.js via `NodeRenderGraph.ParseAsync(json, scene)`", + " • Passed to the Scene MCP server's `attach_node_render_graph` tool", + "", + "Always call validate_graph before exporting to catch issues early.", + "", + "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).", + ].join("\n"), + inputSchema: { + graphName: z.string().describe("Name of the render graph to export"), + outputFile: CreateOutputFileSchema(z), + }, + }, + async ({ graphName, outputFile }) => { + try { + const json = manager.exportJson(graphName); + if (outputFile) { + try { + WriteTextFileEnsuringDirectory(outputFile, json); + return { content: [{ type: "text", text: `NRG JSON written to: ${outputFile}` }] }; + } catch (e) { + return { content: [{ type: "text", text: `Error writing file: ${(e as Error).message}` }], isError: true }; + } + } + return { content: [{ type: "text", text: json }] }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } +); + +server.registerTool( + "import_graph_json", + { + description: [ + "Import a render graph from an existing NRGE-compatible JSON string.", + "Useful for editing a previously exported or hand-authored graph.", + "", + "The graph is stored under `graphName` (overrides the JSON's internal name).", + "Blocks from the imported graph can then be modified with set_block_properties,", + "connect_blocks, etc.", + "", + "Provide either the inline json string OR a jsonFile path (not both).", + ].join("\n"), + inputSchema: { + graphName: z.string().describe("Name to assign to the imported graph in memory"), + json: CreateInlineJsonSchema(z, "NRGE-compatible JSON string (output of export_graph_json or NodeRenderGraph.serialize())"), + jsonFile: CreateJsonFileSchema(z, "Absolute path to a file containing the NRGE JSON to import (alternative to inline json)"), + overwrite: CreateOverwriteSchema(z), + }, + }, + async ({ graphName, json, jsonFile, overwrite }) => { + return CreateJsonImportSummaryResponse({ + json, + jsonFile, + fileDescription: "NRGE JSON file", + importJson: (jsonText: string) => _importGraphJson(graphName, jsonText, overwrite ?? false), + createSuccessText: (graph: { blocks: unknown[]; outputNodeId?: number | null }) => + [ + `Imported render graph "${graphName}" with ${graph.blocks.length} blocks.`, + `outputNodeId: ${graph.outputNodeId ?? "(not set)"}`, + "", + "Call describe_graph to inspect the imported structure.", + ].join("\n"), + }); + } +); + +server.registerTool( + "import_from_snippet", + { + description: + "Import a Node Render Graph from the Babylon.js Snippet Server by its snippet ID. " + + "The snippet is fetched, validated as a nodeRenderGraph type, and loaded into memory for editing. " + + 'Snippet IDs look like "ABC123" or "ABC123#2" (with revision).', + inputSchema: { + graphName: z.string().describe("Name to assign to the imported graph in memory"), + snippetId: CreateSnippetIdSchema(z), + overwrite: CreateOverwriteSchema(z), + }, + }, + async ({ graphName, snippetId, overwrite }) => { + return await RunSnippetResponse({ + snippetId, + loadSnippet: async (requestedSnippetId: string) => (await LoadSnippet(requestedSnippetId)) as IDataSnippetResult, + createResponse: (snippetResult: IDataSnippetResult) => + CreateTypedSnippetImportSummaryResponse({ + snippetId, + snippetResult, + expectedType: "nodeRenderGraph", + importJson: (jsonText: string) => _importGraphJson(graphName, jsonText, overwrite ?? false), + createSuccessText: (graph: { blocks: unknown[]; outputNodeId?: number | null }) => + [ + `Imported snippet "${snippetId}" as render graph "${graphName}" with ${graph.blocks.length} blocks.`, + `outputNodeId: ${graph.outputNodeId ?? "(not set)"}`, + "", + "Call describe_graph to inspect the imported structure.", + ].join("\n"), + }), + }); + } +); + +// ── Block catalog queries ───────────────────────────────────────────────── + +server.registerTool("list_block_types", { description: "List all available NRG block types, grouped by category." }, async () => { + return { content: [{ type: "text", text: GetBlockCatalogSummary() }] }; +}); + +server.registerTool( + "get_block_type_info", + { + description: "Get detailed information about a specific NRG block type: its input/output ports, optional flags, and required additionalConstructionParameters.", + inputSchema: { + blockType: z.string().describe("Block class name WITHOUT the 'BABYLON.' prefix (e.g. 'NodeRenderGraphBloomPostProcessBlock')"), + }, + }, + async ({ blockType }) => { + const info = BlockRegistry[blockType]; + if (!info) { + const needle = blockType.toLowerCase().replace("noderendergraph", ""); + const similar = Object.keys(BlockRegistry) + .filter((k) => k.toLowerCase().includes(needle)) + .slice(0, 5); + const hint = similar.length > 0 ? `\n\nDid you mean one of: ${similar.join(", ")}?` : "\n\nUse list_block_types to browse the full catalog."; + return { content: [{ type: "text", text: `Block type "${blockType}" not found.${hint}` }], isError: true }; + } + return { content: [{ type: "text", text: GetBlockTypeDetails(blockType) }] }; + } +); + +// ── Snippet server ────────────────────────────────────────────────────── + +server.registerTool( + "save_snippet", + { + description: + "Save the render graph to the Babylon.js Snippet Server and return the snippet ID and version. " + + "The snippet can later be loaded in the Node Render Graph Editor via its snippet ID, or fetched with import_from_snippet. " + + "To create a new revision of an existing snippet, pass the previous snippetId.", + inputSchema: { + graphName: z.string().describe("Name of the render graph to save"), + snippetId: z.string().optional().describe('Optional existing snippet ID to create a new revision of (e.g. "ABC123" or "ABC123#1")'), + name: z.string().optional().describe("Optional human-readable title for the snippet"), + description: z.string().optional().describe("Optional description"), + tags: z.string().optional().describe("Optional comma-separated tags"), + }, + }, + async ({ graphName, snippetId, name, description, tags }) => { + const json = manager.exportJson(graphName); + if (!json) { + return { content: [{ type: "text", text: `Render graph "${graphName}" not found.` }], isError: true }; + } + try { + const result = await SaveSnippet( + { type: "nodeRenderGraph", data: ParseJsonText({ jsonText: json, jsonLabel: "NRG JSON" }) }, + { snippetId, metadata: { name, description, tags } } + ); + return { + content: [ + { + type: "text", + text: `Saved render graph "${graphName}" to snippet server.\n\nSnippet ID: ${result.id}\nVersion: ${result.version}\nFull ID: ${result.snippetId}\n\nLoad in NRGE editor: https://nrge.babylonjs.com/#${result.snippetId}`, + }, + ], + }; + } catch (e) { + return { content: [{ type: "text", text: `Error saving snippet: ${(e as Error).message}` }], isError: true }; + } + } +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Start +// ═══════════════════════════════════════════════════════════════════════════ + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error("babylonjs-node-render-graph MCP server running on stdio"); + +const _shutdown = () => { + void sessionController.stopAsync(); + process.exit(0); +}; +process.on("SIGINT", _shutdown); +process.on("SIGTERM", _shutdown); diff --git a/packages/tools/nrge-mcp-server/src/renderGraph.ts b/packages/tools/nrge-mcp-server/src/renderGraph.ts new file mode 100644 index 00000000000..45775e6ba63 --- /dev/null +++ b/packages/tools/nrge-mcp-server/src/renderGraph.ts @@ -0,0 +1,657 @@ +/** + * RenderGraphManager – holds an in-memory representation of Node Render Graphs + * that the MCP tools build up incrementally. When the agent is satisfied, + * the graph can be serialised to NRGE-compatible JSON that Babylon.js can + * load via NodeRenderGraph.Parse(). + * + * Design goals + * ──────────── + * 1. **No Babylon.js runtime dependency** – the MCP server is a lightweight, + * standalone process. We work purely with the JSON data model that mirrors + * the output of NodeRenderGraph.serialize() / NodeRenderGraphBlock.serialize(). + * 2. **Idempotent & stateful** – the manager stores graphs in memory so an + * AI agent can add blocks, wire connections, set properties, and finally + * export. Multiple graphs can coexist (keyed by name). + * + * Serialisation format quick reference + * ───────────────────────────────────── + * A complete render graph JSON looks like: + * ```json + * { + * "customType": "BABYLON.NodeRenderGraph", + * "name": "MyGraph", + * "comment": "...", + * "outputNodeId": 3, // uniqueId of the NodeRenderGraphOutputBlock + * "blocks": [ + * { + * "customType": "BABYLON.NodeRenderGraphInputBlock", + * "id": 1, + * "name": "Color Texture", + * "additionalConstructionParameters": [1], // type = Texture + * "inputs": [], + * "outputs": [{ "name": "output" }] + * }, + * { + * "customType": "BABYLON.NodeRenderGraphClearBlock", + * "id": 2, + * "name": "Clear", + * "inputs": [ + * { + * "name": "target", + * "inputName": "target", + * "targetBlockId": 1, + * "targetConnectionName": "output" + * } + * ], + * "outputs": [{ "name": "output" }, { "name": "outputDepth" }] + * }, + * ... + * ], + * "editorData": { "locations": [] } + * } + * ``` + * + * Connection encoding: + * Each CONNECTED input stores: + * `inputName` – this input's port name (same as `name`) + * `targetBlockId` – uniqueId of the source block + * `targetConnectionName` – output port name on the source block + */ + +import { ValidateNodeRenderGraphAttachmentPayload } from "@tools/mcp-server-core"; + +import { BlockRegistry } from "./blockRegistry.js"; + +// ─── Types ───────────────────────────────────────────────────────────────── + +/** A single serialised connection point on a block */ +export interface ISerializedConnectionPoint { + /** Connection-point port name */ + name: string; + /** Optional UI display label (overrides name in the editor) */ + displayName?: string; + /** Present only on connected inputs */ + inputName?: string; + /** Id of the block that provides the value (source / upstream block) */ + targetBlockId?: number; + /** Output port name on the source block */ + targetConnectionName?: string; + /** Whether this port is exposed on the frame graph */ + isExposedOnFrame?: boolean; + /** Position index when exposed on frame */ + exposedPortPosition?: number; +} + +/** A single serialised block */ +export interface ISerializedBlock { + /** "BABYLON." */ + customType: string; + /** Auto-assigned sequential integer, unique within this graph */ + id: number; + /** Human-readable block name */ + name: string; + /** Free-text comment (shown in editor) */ + comments?: string; + /** Whether the block is visible in the frame graph view */ + visibleOnFrame?: boolean; + /** When true the block is skipped during execution */ + disabled?: boolean; + /** Constructor arguments beyond (name, frameGraph, scene) */ + additionalConstructionParameters?: unknown[]; + /** All input connection points */ + inputs: ISerializedConnectionPoint[]; + /** All output connection points */ + outputs: ISerializedConnectionPoint[]; + /** Block-specific property overrides (e.g., color, clearDepth, …) */ + [key: string]: unknown; +} + +/** Complete serialised render graph */ +export interface ISerializedRenderGraph { + /** Must be "BABYLON.NodeRenderGraph" */ + customType: string; + /** Graph name */ + name: string; + /** Optional free-text comment */ + comment?: string; + /** uniqueId of the NodeRenderGraphOutputBlock */ + outputNodeId?: number; + /** All blocks */ + blocks: ISerializedBlock[]; + /** Editor layout data (positions, etc.) */ + editorData?: { + locations: Array<{ blockId: number; x: number; y: number; isCollapsed?: boolean }>; + [key: string]: unknown; + }; +} + +// ─── Manager ─────────────────────────────────────────────────────────────── + +/** Manages multiple Node Render Graph definitions in memory, allowing incremental construction and export. */ +export class RenderGraphManager { + /** All active render graphs, keyed by name */ + private readonly _graphs = new Map(); + /** Auto-incrementing block ID counter */ + private _nextId = 1; + + // ── Graph lifecycle ───────────────────────────────────────────────── + + /** + * Create a new empty render graph. + * @param name - Unique name for the render graph + * @param comment - Optional description of the pipeline + * @returns The newly created serialised render graph + */ + public create(name: string, comment?: string): ISerializedRenderGraph { + if (this._graphs.has(name)) { + throw new Error(`A render graph named "${name}" already exists. Choose a different name or delete the existing one first.`); + } + const graph: ISerializedRenderGraph = { + customType: "BABYLON.NodeRenderGraph", + name, + comment, + blocks: [], + editorData: { locations: [] }, + }; + this._graphs.set(name, graph); + return graph; + } + + /** + * Delete a render graph. Throws if it does not exist. + * @param name - Name of the render graph to delete + */ + public delete(name: string): void { + if (!this._graphs.has(name)) { + throw new Error(`Render graph "${name}" not found.`); + } + this._graphs.delete(name); + } + + /** + * Remove all render graphs from memory, resetting the manager to its initial state. + */ + public clearAll(): void { + this._graphs.clear(); + this._nextId = 1; + } + + /** + * List names of all current render graphs. + * @returns Array of graph names + */ + public list(): string[] { + return [...this._graphs.keys()]; + } + + // ── Retrieval ─────────────────────────────────────────────────────── + + /** + * Get a graph by name or throw. + * @param name - Graph name + * @returns The serialised render graph + */ + public get(name: string): ISerializedRenderGraph { + const g = this._graphs.get(name); + if (!g) { + throw new Error(`Render graph "${name}" not found. Use create_render_graph first.`); + } + return g; + } + + // ── Block operations ───────────────────────────────────────────────── + + /** + * Add a new block to a graph. + * + * @param graphName Name of the target graph + * @param blockType Babylon class name WITHOUT the "BABYLON." prefix (e.g. "NodeRenderGraphClearBlock") + * @param blockName Human-friendly name for the block (defaults to blockType) + * @param additionalConstructionParameters Constructor args beyond (name, frameGraph, scene). + * If omitted, the catalog's `defaultAdditionalConstructionParameters` is used. + * @returns The newly created serialised block + */ + public addBlock(graphName: string, blockType: string, blockName?: string, additionalConstructionParameters?: unknown[]): ISerializedBlock { + const graph = this.get(graphName); + const info = BlockRegistry[blockType]; + if (!info) { + throw new Error(`Unknown block type "${blockType}". Call list_block_types to see all available block types.`); + } + + const id = this._nextId++; + const name = blockName ?? blockType; + + // Resolve construction parameters + const acp = + additionalConstructionParameters !== undefined + ? additionalConstructionParameters + : info.defaultAdditionalConstructionParameters !== undefined + ? info.defaultAdditionalConstructionParameters + : undefined; + + // Build the initial port arrays from the catalog + const inputs: ISerializedConnectionPoint[] = info.inputs.map((p) => ({ name: p.name })); + const outputs: ISerializedConnectionPoint[] = info.outputs.map((p) => ({ name: p.name })); + + const block: ISerializedBlock = { + customType: `BABYLON.${blockType}`, + id, + name, + inputs, + outputs, + }; + + if (acp !== undefined) { + block.additionalConstructionParameters = acp; + } + + // Mark the OutputBlock in the graph metadata + if (blockType === "NodeRenderGraphOutputBlock") { + graph.outputNodeId = id; + } + + graph.blocks.push(block); + + // Simple staggered layout + graph.editorData!.locations.push({ blockId: id, x: (graph.blocks.length - 1) * 220, y: 100 }); + + return block; + } + + /** + * Add multiple blocks in one call. + * @param graphName - Name of the target graph + * @param blocks - Array of block descriptors to add + * @returns Array of created block descriptors in the same order as input. + */ + public addBlocksBatch( + graphName: string, + blocks: Array<{ + /** Block class name WITHOUT the "BABYLON." prefix */ + blockType: string; + /** Optional human-friendly label */ + blockName?: string; + /** Constructor args beyond (name, frameGraph, scene) */ + additionalConstructionParameters?: unknown[]; + }> + ): ISerializedBlock[] { + return blocks.map((b) => this.addBlock(graphName, b.blockType, b.blockName, b.additionalConstructionParameters)); + } + + /** + * Remove a block from a graph, also removing all connections that reference it. + * + * @param graphName Graph name + * @param blockId Block id (from addBlock result or describe_block) + */ + public removeBlock(graphName: string, blockId: number): void { + const graph = this.get(graphName); + const idx = graph.blocks.findIndex((b) => b.id === blockId); + if (idx === -1) { + throw new Error(`Block id ${blockId} not found in graph "${graphName}".`); + } + graph.blocks.splice(idx, 1); + + // Remove all connections that referenced this block + for (const block of graph.blocks) { + for (const input of block.inputs) { + if (input.targetBlockId === blockId) { + delete input.inputName; + delete input.targetBlockId; + delete input.targetConnectionName; + } + } + } + + // Clear outputNodeId if this was the output block + if (graph.outputNodeId === blockId) { + delete graph.outputNodeId; + } + + // Remove editor location entry + if (graph.editorData) { + graph.editorData.locations = graph.editorData.locations.filter((l) => l.blockId !== blockId); + } + } + + // ── Connection operations ──────────────────────────────────────────── + + /** + * Connect one block's output to another block's input. + * + * @param graphName Graph name + * @param sourceBlockId Id of the block providing the output value + * @param sourcePortName Output port name on the source block + * @param targetBlockId Id of the block receiving the input + * @param targetPortName Input port name on the target block + */ + public connect(graphName: string, sourceBlockId: number, sourcePortName: string, targetBlockId: number, targetPortName: string): void { + const graph = this.get(graphName); + const sourceBlock = graph.blocks.find((b) => b.id === sourceBlockId); + const targetBlock = graph.blocks.find((b) => b.id === targetBlockId); + + if (!sourceBlock) { + throw new Error(`Source block id ${sourceBlockId} not found in graph "${graphName}".`); + } + if (!targetBlock) { + throw new Error(`Target block id ${targetBlockId} not found in graph "${graphName}".`); + } + + // Validate source port exists + const sourcePort = sourceBlock.outputs.find((o) => o.name === sourcePortName); + if (!sourcePort) { + throw new Error( + `Output port "${sourcePortName}" not found on block "${sourceBlock.name}" (id ${sourceBlockId}). ` + + `Available outputs: ${sourceBlock.outputs.map((o) => o.name).join(", ") || "(none)"}` + ); + } + + // Validate target port exists + const targetPort = targetBlock.inputs.find((i) => i.name === targetPortName); + if (!targetPort) { + throw new Error( + `Input port "${targetPortName}" not found on block "${targetBlock.name}" (id ${targetBlockId}). ` + + `Available inputs: ${targetBlock.inputs.map((i) => i.name).join(", ") || "(none)"}` + ); + } + + // Set connection data on the target input + targetPort.inputName = targetPortName; + targetPort.targetBlockId = sourceBlockId; + targetPort.targetConnectionName = sourcePortName; + } + + /** + * Connect multiple block pairs in one call. + * @param graphName - Name of the target graph + * @param connections - Array of source→target port pairs to wire + */ + public connectBatch( + graphName: string, + connections: Array<{ + /** Id of the block providing the output value */ + sourceBlockId: number; + /** Output port name on the source block */ + sourcePortName: string; + /** Id of the block receiving the input */ + targetBlockId: number; + /** Input port name on the target block */ + targetPortName: string; + }> + ): void { + for (const c of connections) { + this.connect(graphName, c.sourceBlockId, c.sourcePortName, c.targetBlockId, c.targetPortName); + } + } + + /** + * Disconnect an input port (remove an existing connection). + * @param graphName - Graph name + * @param blockId - Id of the block whose input should be disconnected + * @param inputPortName - Name of the input port to disconnect + */ + public disconnectInput(graphName: string, blockId: number, inputPortName: string): void { + const graph = this.get(graphName); + const block = graph.blocks.find((b) => b.id === blockId); + if (!block) { + throw new Error(`Block id ${blockId} not found in graph "${graphName}".`); + } + const port = block.inputs.find((i) => i.name === inputPortName); + if (!port) { + throw new Error(`Input port "${inputPortName}" not found on block id ${blockId}.`); + } + if (port.targetBlockId === undefined) { + throw new Error(`Input port "${inputPortName}" on block id ${blockId} is already disconnected.`); + } + delete port.inputName; + delete port.targetBlockId; + delete port.targetConnectionName; + } + + // ── Properties ─────────────────────────────────────────────────────── + + /** + * Set one or more properties on a block. + * Properties become top-level keys in the block's serialisation object, + * which is how Babylon.js block deserialisation picks them up. + * + * Special keys: + * `additionalConstructionParameters` – must be an array; replaces the stored value. + * `outputNodeId` – if the block type is NodeRenderGraphOutputBlock, + * this is managed automatically; do not set manually. + * @param graphName - Graph name + * @param blockId - Id of the block to update + * @param properties - Key-value pairs to merge into the block's serialisation + */ + public setBlockProperties(graphName: string, blockId: number, properties: Record): void { + const graph = this.get(graphName); + const block = graph.blocks.find((b) => b.id === blockId); + if (!block) { + throw new Error(`Block id ${blockId} not found in graph "${graphName}".`); + } + + for (const [key, value] of Object.entries(properties)) { + if (key === "id" || key === "customType" || key === "inputs" || key === "outputs") { + throw new Error(`Property "${key}" is reserved and cannot be set via set_block_properties.`); + } + if (key === "additionalConstructionParameters" && !Array.isArray(value)) { + throw new Error(`"additionalConstructionParameters" must be an array.`); + } + (block as Record)[key] = value; + } + } + + // ── Describe / inspect ─────────────────────────────────────────────── + + /** + * Return a human-readable description of a single block. + * @param graphName - Graph name + * @param blockId - Id of the block to describe + * @returns Markdown-formatted string with ports, connections, and properties + */ + public describeBlock(graphName: string, blockId: number): string { + const graph = this.get(graphName); + const block = graph.blocks.find((b) => b.id === blockId); + if (!block) { + throw new Error(`Block id ${blockId} not found in graph "${graphName}".`); + } + return this._formatBlock(block, graph); + } + + /** + * Return a human-readable description of the entire graph. + * @param graphName - Graph name + * @returns Markdown-formatted string listing all blocks and their connections + */ + public describeGraph(graphName: string): string { + const graph = this.get(graphName); + const lines: string[] = [ + `# Render Graph: "${graph.name}"`, + graph.comment ? `Comment: ${graph.comment}` : "", + `Output block id: ${graph.outputNodeId ?? "(not set)"}`, + `Blocks (${graph.blocks.length}):`, + "", + ].filter(Boolean); + + for (const block of graph.blocks) { + lines.push(this._formatBlock(block, graph)); + lines.push(""); + } + return lines.join("\n"); + } + + private _formatBlock(block: ISerializedBlock, graph: ISerializedRenderGraph): string { + const lines = [`**[${block.id}] ${block.name}** (${block.customType.replace("BABYLON.", "")})`]; + + // Inputs + if (block.inputs.length > 0) { + lines.push(" Inputs:"); + for (const inp of block.inputs) { + if (inp.targetBlockId !== undefined) { + const srcBlock = graph.blocks.find((b) => b.id === inp.targetBlockId); + lines.push(` • ${inp.name} ← [${inp.targetBlockId}] ${srcBlock?.name ?? "?"}.${inp.targetConnectionName}`); + } else { + lines.push(` • ${inp.name} (not connected)`); + } + } + } + + // Outputs + if (block.outputs.length > 0) { + lines.push(" Outputs:"); + for (const out of block.outputs) { + // Find all downstream connections + const consumers: string[] = []; + for (const b of graph.blocks) { + for (const inp of b.inputs) { + if (inp.targetBlockId === block.id && inp.targetConnectionName === out.name) { + consumers.push(`[${b.id}] ${b.name}.${inp.name}`); + } + } + } + if (consumers.length > 0) { + lines.push(` • ${out.name} → ${consumers.join(", ")}`); + } else { + lines.push(` • ${out.name} (unconnected)`); + } + } + } + + // Extra properties (not id, customType, inputs, outputs, name, comments, etc.) + const reservedKeys = new Set(["customType", "id", "name", "comments", "visibleOnFrame", "disabled", "inputs", "outputs"]); + const extraKeys = Object.keys(block).filter((k) => !reservedKeys.has(k)); + if (extraKeys.length > 0) { + lines.push(" Properties:"); + for (const k of extraKeys) { + lines.push(` ${k}: ${JSON.stringify((block as Record)[k])}`); + } + } + + return lines.join("\n"); + } + + // ── Validation ─────────────────────────────────────────────────────── + + /** + * Validate the graph, checking for common issues. + * @param graphName - Name of the render graph to validate + * @returns Object with `valid` (boolean) and `messages` (string[]) listing all issues found + */ + public validate(graphName: string): { valid: boolean; messages: string[] } { + const graph = this.get(graphName); + const messages: string[] = []; + + // Must have an OutputBlock + if (graph.outputNodeId === undefined) { + messages.push("ERROR: No NodeRenderGraphOutputBlock found. " + "Add one and connect a final texture to its 'texture' input."); + } else { + const outputBlock = graph.blocks.find((b) => b.id === graph.outputNodeId); + if (!outputBlock) { + messages.push(`ERROR: outputNodeId=${graph.outputNodeId} does not match any block. ` + "The OutputBlock may have been removed."); + } else { + // Check that the texture input is connected + const textureInput = outputBlock.inputs.find((i) => i.name === "texture"); + if (!textureInput || textureInput.targetBlockId === undefined) { + messages.push("ERROR: NodeRenderGraphOutputBlock is missing a connection on its 'texture' input. " + "Connect a rendered colour texture to it."); + } + } + } + + // Check all blocks exist and required inputs are connected + for (const block of graph.blocks) { + const typeName = block.customType.replace("BABYLON.", ""); + const info = BlockRegistry[typeName]; + if (!info) { + messages.push( + `WARNING: Block "${block.name}" (id ${block.id}) has unknown type "${block.customType}". ` + + "It may have been added with import_graph_json from an external source." + ); + continue; + } + + for (const portInfo of info.inputs) { + if (portInfo.isOptional) { + continue; + } + const serialPort = block.inputs.find((i) => i.name === portInfo.name); + if (!serialPort || serialPort.targetBlockId === undefined) { + messages.push(`WARNING: Block "${block.name}" (id ${block.id}) has a required input ` + `"${portInfo.name}" that is not connected.`); + } + } + } + + // Check for dangling targetBlockId references + const blockIds = new Set(graph.blocks.map((b) => b.id)); + for (const block of graph.blocks) { + for (const inp of block.inputs) { + if (inp.targetBlockId !== undefined && !blockIds.has(inp.targetBlockId)) { + messages.push( + `ERROR: Block "${block.name}" (id ${block.id}) input "${inp.name}" references ` + `missing block id ${inp.targetBlockId}. Connection must be removed.` + ); + } + } + } + + // Check for NodeRenderGraphInputBlocks without additionalConstructionParameters + for (const block of graph.blocks) { + if (block.customType === "BABYLON.NodeRenderGraphInputBlock") { + const acp = block.additionalConstructionParameters; + if (!acp || !Array.isArray(acp) || acp.length === 0) { + messages.push( + `WARNING: InputBlock "${block.name}" (id ${block.id}) is missing ` + + "additionalConstructionParameters[0] (the connection-point type). " + + `Set it via set_block_properties with key "additionalConstructionParameters". ` + + "Common values: Texture=1, TextureDepthStencilAttachment=8, Camera=16777216, ObjectList=33554432." + ); + } + } + } + + return { valid: messages.filter((m) => m.startsWith("ERROR")).length === 0, messages }; + } + + // ── Export / Import ────────────────────────────────────────────────── + + /** + * Serialise the graph to NRGE-compatible JSON (string). + * This JSON can be passed to NodeRenderGraph.Parse() in Babylon.js or + * to the Scene MCP server's attach_node_render_graph tool. + * @param graphName - Name of the render graph to export + * @returns JSON string compatible with NodeRenderGraph.ParseAsync() + */ + public exportJson(graphName: string): string { + const graph = this.get(graphName); + return JSON.stringify(graph, null, 2); + } + + /** + * Import a render graph from an existing NRGE JSON string. + * If `graphName` already exists and `overwrite` is false, throws. + * The imported graph is stored under `graphName` (which overrides the JSON's `name`). + * @param graphName - Name to assign to the imported graph in memory + * @param json - NRGE-compatible JSON string + * @param overwrite - If true, replace any existing graph with the same name + * @returns The imported serialised render graph + */ + public importJson(graphName: string, json: string, overwrite = false): ISerializedRenderGraph { + const parsed = ValidateNodeRenderGraphAttachmentPayload(json) as unknown as ISerializedRenderGraph; + + if (this._graphs.has(graphName) && !overwrite) { + throw new Error(`A render graph named "${graphName}" already exists. ` + "Pass overwrite=true to replace it."); + } + + // Re-key the graph under the requested name + parsed.name = graphName; + parsed.customType = "BABYLON.NodeRenderGraph"; + + // Advance _nextId past all existing block ids so new blocks don't collide + for (const block of parsed.blocks) { + if (typeof block.id === "number" && block.id >= this._nextId) { + this._nextId = block.id + 1; + } + } + + this._graphs.set(graphName, parsed); + return parsed; + } +} diff --git a/packages/tools/nrge-mcp-server/test/unit/generateExamples.test.ts b/packages/tools/nrge-mcp-server/test/unit/generateExamples.test.ts new file mode 100644 index 00000000000..d60941de630 --- /dev/null +++ b/packages/tools/nrge-mcp-server/test/unit/generateExamples.test.ts @@ -0,0 +1,195 @@ +/** + * Node Render Graph MCP Server – Example Render Graph Generator + * + * Builds several reference Node Render Graph definitions via the + * RenderGraphManager API, validates them, and writes them to the examples/ + * directory. + * + * Run: npx ts-node --esm test/unit/generateExamples.ts + * Or simply include as a test file – Jest will run it and the examples are + * written to disk as a side effect. + */ + +import * as fs from "fs"; +import * as path from "path"; +import { RenderGraphManager } from "../../src/renderGraph"; + +const EXAMPLES_DIR = path.resolve(__dirname, "../../examples"); + +function writeExample(name: string, json: string): void { + fs.mkdirSync(EXAMPLES_DIR, { recursive: true }); + const filePath = path.join(EXAMPLES_DIR, `${name}.json`); + fs.writeFileSync(filePath, json, "utf-8"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 1 – Basic Forward Rendering +// The simplest useful render graph: clear a back-buffer, render objects, output. +// InputBlock(BackBuffer) → Clear → ObjectRenderer → OutputBlock +// Also needs Camera and ObjectList inputs. +// ═══════════════════════════════════════════════════════════════════════════ + +function buildBasicForward(): string { + const mgr = new RenderGraphManager(); + mgr.create("BasicForward", "Minimal forward rendering pipeline: clear, render, output."); + + // Input: back-buffer colour texture (TextureBackBuffer = 2) + const colorInput = mgr.addBlock("BasicForward", "NodeRenderGraphInputBlock", "BackBuffer Color", [2]); + mgr.setBlockProperties("BasicForward", colorInput.id, { isExternal: true }); + + // Input: back-buffer depth (TextureBackBufferDepthStencilAttachment = 4) + const depthInput = mgr.addBlock("BasicForward", "NodeRenderGraphInputBlock", "BackBuffer Depth", [4]); + mgr.setBlockProperties("BasicForward", depthInput.id, { isExternal: true }); + + // Input: camera (Camera = 0x01000000 = 16777216) + const cameraInput = mgr.addBlock("BasicForward", "NodeRenderGraphInputBlock", "Camera", [16777216]); + mgr.setBlockProperties("BasicForward", cameraInput.id, { isExternal: true }); + + // Input: object list (ObjectList = 0x02000000 = 33554432) + const objectsInput = mgr.addBlock("BasicForward", "NodeRenderGraphInputBlock", "Objects", [33554432]); + mgr.setBlockProperties("BasicForward", objectsInput.id, { isExternal: true }); + + // Clear block + const clearBlock = mgr.addBlock("BasicForward", "NodeRenderGraphClearBlock", "Clear"); + mgr.setBlockProperties("BasicForward", clearBlock.id, { + color: { r: 0.2, g: 0.2, b: 0.3, a: 1 }, + clearColor: true, + clearDepth: true, + }); + mgr.connect("BasicForward", colorInput.id, "output", clearBlock.id, "target"); + mgr.connect("BasicForward", depthInput.id, "output", clearBlock.id, "depth"); + + // Object renderer + const renderer = mgr.addBlock("BasicForward", "NodeRenderGraphObjectRendererBlock", "Renderer"); + mgr.connect("BasicForward", clearBlock.id, "output", renderer.id, "target"); + mgr.connect("BasicForward", clearBlock.id, "outputDepth", renderer.id, "depth"); + mgr.connect("BasicForward", cameraInput.id, "output", renderer.id, "camera"); + mgr.connect("BasicForward", objectsInput.id, "output", renderer.id, "objects"); + + // Output + const outputBlock = mgr.addBlock("BasicForward", "NodeRenderGraphOutputBlock", "Output"); + mgr.connect("BasicForward", renderer.id, "output", outputBlock.id, "texture"); + + const { valid, messages } = mgr.validate("BasicForward"); + expect(valid).toBe(true); + + return mgr.exportJson("BasicForward"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 2 – Bloom Post-Process +// Forward rendering with a bloom post-process pass before output. +// ═══════════════════════════════════════════════════════════════════════════ + +function buildBloomPipeline(): string { + const mgr = new RenderGraphManager(); + mgr.create("BloomPipeline", "Forward rendering with bloom post-process."); + + // Inputs + const colorInput = mgr.addBlock("BloomPipeline", "NodeRenderGraphInputBlock", "BackBuffer Color", [2]); + mgr.setBlockProperties("BloomPipeline", colorInput.id, { isExternal: true }); + + const depthInput = mgr.addBlock("BloomPipeline", "NodeRenderGraphInputBlock", "BackBuffer Depth", [4]); + mgr.setBlockProperties("BloomPipeline", depthInput.id, { isExternal: true }); + + const cameraInput = mgr.addBlock("BloomPipeline", "NodeRenderGraphInputBlock", "Camera", [16777216]); + mgr.setBlockProperties("BloomPipeline", cameraInput.id, { isExternal: true }); + + const objectsInput = mgr.addBlock("BloomPipeline", "NodeRenderGraphInputBlock", "Objects", [33554432]); + mgr.setBlockProperties("BloomPipeline", objectsInput.id, { isExternal: true }); + + // Clear + const clearBlock = mgr.addBlock("BloomPipeline", "NodeRenderGraphClearBlock", "Clear"); + mgr.setBlockProperties("BloomPipeline", clearBlock.id, { + color: { r: 0.1, g: 0.1, b: 0.15, a: 1 }, + clearColor: true, + clearDepth: true, + }); + mgr.connect("BloomPipeline", colorInput.id, "output", clearBlock.id, "target"); + mgr.connect("BloomPipeline", depthInput.id, "output", clearBlock.id, "depth"); + + // Render objects + const renderer = mgr.addBlock("BloomPipeline", "NodeRenderGraphObjectRendererBlock", "Renderer"); + mgr.connect("BloomPipeline", clearBlock.id, "output", renderer.id, "target"); + mgr.connect("BloomPipeline", clearBlock.id, "outputDepth", renderer.id, "depth"); + mgr.connect("BloomPipeline", cameraInput.id, "output", renderer.id, "camera"); + mgr.connect("BloomPipeline", objectsInput.id, "output", renderer.id, "objects"); + + // Bloom post-process + const bloom = mgr.addBlock("BloomPipeline", "NodeRenderGraphBloomPostProcessBlock", "Bloom"); + mgr.setBlockProperties("BloomPipeline", bloom.id, { + threshold: 0.8, + weight: 0.6, + kernel: 64, + scale: 0.5, + }); + mgr.connect("BloomPipeline", renderer.id, "output", bloom.id, "source"); + mgr.connect("BloomPipeline", renderer.id, "outputDepth", bloom.id, "target"); + + // Output + const outputBlock = mgr.addBlock("BloomPipeline", "NodeRenderGraphOutputBlock", "Output"); + mgr.connect("BloomPipeline", bloom.id, "output", outputBlock.id, "texture"); + + const { valid, messages } = mgr.validate("BloomPipeline"); + expect(valid).toBe(true); + + return mgr.exportJson("BloomPipeline"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 3 – Clear Only +// The absolute minimum: clear a texture and output it. No object rendering. +// Useful as a starting template. +// ═══════════════════════════════════════════════════════════════════════════ + +function buildClearOnly(): string { + const mgr = new RenderGraphManager(); + mgr.create("ClearOnly", "Minimal pipeline: clear a back-buffer and output."); + + const colorInput = mgr.addBlock("ClearOnly", "NodeRenderGraphInputBlock", "BackBuffer Color", [2]); + mgr.setBlockProperties("ClearOnly", colorInput.id, { isExternal: true }); + + const clearBlock = mgr.addBlock("ClearOnly", "NodeRenderGraphClearBlock", "Clear"); + mgr.setBlockProperties("ClearOnly", clearBlock.id, { + color: { r: 0.4, g: 0.6, b: 0.9, a: 1 }, + clearColor: true, + }); + mgr.connect("ClearOnly", colorInput.id, "output", clearBlock.id, "target"); + + const outputBlock = mgr.addBlock("ClearOnly", "NodeRenderGraphOutputBlock", "Output"); + mgr.connect("ClearOnly", clearBlock.id, "output", outputBlock.id, "texture"); + + const { valid, messages } = mgr.validate("ClearOnly"); + expect(valid).toBe(true); + + return mgr.exportJson("ClearOnly"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Jest Test Wrapper +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Node Render Graph MCP Server – Example Generation", () => { + it("generates BasicForward example", () => { + const json = buildBasicForward(); + const parsed = JSON.parse(json); + expect(parsed.customType).toBe("BABYLON.NodeRenderGraph"); + expect(parsed.blocks.length).toBe(7); // 4 inputs + clear + renderer + output + expect(parsed.outputNodeId).toBeDefined(); + writeExample("BasicForward", json); + }); + + it("generates BloomPipeline example", () => { + const json = buildBloomPipeline(); + const parsed = JSON.parse(json); + expect(parsed.blocks.length).toBe(8); // 4 inputs + clear + renderer + bloom + output + writeExample("BloomPipeline", json); + }); + + it("generates ClearOnly example", () => { + const json = buildClearOnly(); + const parsed = JSON.parse(json); + expect(parsed.blocks.length).toBe(3); // 1 input + clear + output + writeExample("ClearOnly", json); + }); +}); diff --git a/packages/tools/nrge-mcp-server/test/unit/renderGraph.test.ts b/packages/tools/nrge-mcp-server/test/unit/renderGraph.test.ts new file mode 100644 index 00000000000..51a24ab2c49 --- /dev/null +++ b/packages/tools/nrge-mcp-server/test/unit/renderGraph.test.ts @@ -0,0 +1,28 @@ +import { RenderGraphManager } from "../../src/renderGraph"; + +describe("Node Render Graph MCP Server – RenderGraphManager", () => { + it("imports a valid render graph JSON", () => { + const mgr = new RenderGraphManager(); + + const graph = mgr.importJson( + "graph", + JSON.stringify({ + customType: "BABYLON.NodeRenderGraph", + name: "original", + blocks: [], + }) + ); + + expect(graph.name).toBe("graph"); + expect(graph.customType).toBe("BABYLON.NodeRenderGraph"); + expect(graph.blocks).toEqual([]); + }); + + it("rejects invalid render graph JSON", () => { + const mgr = new RenderGraphManager(); + + expect(() => mgr.importJson("graph", "not json")).toThrow("Invalid NRG JSON: parse error."); + expect(() => mgr.importJson("graph", '{"customType":"WRONG","blocks":[]}')).toThrow("Invalid NRG JSON"); + expect(() => mgr.importJson("graph", '{"customType":"BABYLON.NodeRenderGraph"}')).toThrow("Invalid NRG JSON"); + }); +}); diff --git a/packages/tools/nrge-mcp-server/tsconfig.json b/packages/tools/nrge-mcp-server/tsconfig.json new file mode 100644 index 00000000000..2c6df59671a --- /dev/null +++ b/packages/tools/nrge-mcp-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "types": ["node"], + "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/rollup.config.mcp.mjs b/packages/tools/rollup.config.mcp.mjs new file mode 100644 index 00000000000..10f6d4d85c4 --- /dev/null +++ b/packages/tools/rollup.config.mcp.mjs @@ -0,0 +1,122 @@ +/** + * Shared rollup configuration for all Babylon.js MCP servers. + * + * Each server imports this and calls `createConfig("./src/index.ts")` + * to produce a single self-contained, minified ESM bundle at dist/index.js + * with no external dependencies (only Node built-ins are external). + */ + +import typescript from "@rollup/plugin-typescript"; +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; +import json from "@rollup/plugin-json"; +import { builtinModules } from "node:module"; +import { transform } from "esbuild"; + +/** + * Strip shebang lines from source so the banner is the only one. + * @returns A Rollup plugin that removes source shebangs. + */ +function stripShebang() { + return { + name: "strip-shebang", + transform(code, id) { + if (code.startsWith("#!")) { + return { code: code.replace(/^#![^\n]*\n/, ""), map: null }; + } + return null; + }, + }; +} + +/** + * Minify bundled chunks using esbuild (handles modern JS incl. private fields). + * @returns A Rollup plugin that minifies chunks with esbuild. + */ +function esbuildMinify() { + return { + name: "esbuild-minify", + async renderChunk(code) { + const result = await transform(code, { + minify: true, + target: "node18", + format: "esm", + }); + return { code: result.code, map: result.map || null }; + }, + }; +} + +/** + * Resolve zod-to-json-schema's v3 compatibility import to the installed zod package. + * @returns A Rollup plugin that aliases zod/v3 imports. + */ +function zodV3CompatAlias() { + return { + name: "zod-v3-compat-alias", + async resolveId(source, importer, options) { + if (source !== "zod/v3") { + return null; + } + return await this.resolve("zod", importer, { ...options, skipSelf: true }); + }, + }; +} + +function isKnownDependencyCircularWarning(warning) { + if (warning.code !== "CIRCULAR_DEPENDENCY") { + return false; + } + + const ids = warning.ids ?? [warning.message ?? ""]; + return ids.some((id) => id.includes("/node_modules/zod") || id.includes("/node_modules/zod-to-json-schema")); +} + +/** Node built-in modules that must stay external (e.g. "fs", "node:fs"). */ +const nodeBuiltins = [...builtinModules, ...builtinModules.map((m) => `node:${m}`)]; + +/** + * Modules that should remain external even though they appear in + * dependency code. The snippet-loader lazy-imports Monaco's TypeScript + * services for playground transpilation — MCP servers never trigger that + * code path (they only load data snippets), so we exclude it. + */ +const alwaysExternal = [...nodeBuiltins, /^monaco-editor/]; + +/** + * @param input Entry point relative to the server package root. + * @returns The Rollup options for an MCP server package. + */ +export function createConfig(input = "./src/index.ts") { + return { + input, + onwarn(warning, defaultHandler) { + if (isKnownDependencyCircularWarning(warning)) { + return; + } + defaultHandler(warning); + }, + output: { + file: "dist/index.js", + format: "es", + sourcemap: true, + // Preserve the shebang so `npx` / `bin` invocation still works. + banner: "#!/usr/bin/env node", + }, + external: alwaysExternal, + plugins: [ + stripShebang(), + typescript({ + tsconfig: "./tsconfig.json", + // Declarations are not needed in the bundle. + declaration: false, + declarationMap: false, + }), + zodV3CompatAlias(), + nodeResolve({ preferBuiltins: true }), + commonjs(), + json(), + esbuildMinify(), + ], + }; +} diff --git a/packages/tools/smart-filters-mcp-server/README.md b/packages/tools/smart-filters-mcp-server/README.md new file mode 100644 index 00000000000..b22d81b9780 --- /dev/null +++ b/packages/tools/smart-filters-mcp-server/README.md @@ -0,0 +1,100 @@ +# Smart Filters MCP Server + +An MCP (Model Context Protocol) server for authoring, editing, validating, and exporting **Babylon.js Smart Filter** graphs. This server lets MCP clients (AI assistants, editors, automation pipelines) build Smart Filter post-processing chains entirely through tool calls — no GUI required. + +## Binary + +``` +babylonjs-smart-filters +``` + +## Build & Run + +```bash +# From the workspace root +npm install +npm run build -w @tools/smart-filters-mcp-server + +# Run the server (stdio transport) +npx babylonjs-smart-filters +``` + +## Tool Categories + +| Category | Tools | +| ----------------- | ----------------------------------------------------------------------------------------------------- | +| **Lifecycle** | `create_filter_graph`, `list_filter_graphs`, `delete_filter_graph`, `clone_filter_graph`, `clear_all` | +| **Discovery** | `list_block_types`, `get_block_type_info`, `list_categories`, `describe_graph`, `describe_block` | +| **Block Editing** | `add_block`, `add_blocks_batch`, `remove_block`, `set_block_properties`, `get_block_properties` | +| **Connections** | `connect_blocks`, `connect_blocks_batch`, `disconnect_input`, `list_connections` | +| **Validation** | `validate_graph`, `list_issues` | +| **Import/Export** | `export_filter_graph_json`, `import_filter_graph_json` | +| **Search** | `find_blocks`, `find_block_types` | + +## Resources + +| URI | Description | +| ------------------------------- | ---------------------------------- | +| `smart-filters://block-catalog` | Full block type catalog | +| `smart-filters://enums` | ConnectionPointType enum reference | +| `smart-filters://concepts` | Key Smart Filter concepts | + +## Prompts + +| Name | Description | +| -------------------------- | ------------------------------------------------ | +| `create-basic-filter` | Step-by-step: single-effect filter chain | +| `create-blur-filter` | Step-by-step: blur filter with configurable size | +| `create-tinted-desaturate` | Step-by-step: desaturate + tint composition | + +## Example Workflow + +```text +1. create_filter_graph name="myFilter" +2. add_block graphId="myFilter" blockType="Texture" name="source" +3. add_block graphId="myFilter" blockType="BlackAndWhiteBlock" name="bw" +4. connect_blocks graphId="myFilter" outputBlockId= outputName="output" + inputBlockId= inputName="input" +5. connect_blocks graphId="myFilter" outputBlockId= outputName="output" + inputBlockId=1 inputName="input" +6. validate_graph graphId="myFilter" +7. export_filter_graph_json graphId="myFilter" outputFile="myFilter.json" +``` + +## Output Format + +Exports conform to the **Smart Filter V1** serialization format: + +```json +{ + "format": "smartFilter", + "formatVersion": 1, + "name": "myFilter", + "blocks": [ ... ], + "connections": [ ... ] +} +``` + +## Available Block Types + +### Effects + +BlackAndWhiteBlock, KaleidoscopeBlock, PosterizeBlock, DesaturateBlock, ContrastBlock, GreenScreenBlock, PixelateBlock, ExposureBlock, MaskBlock, SpritesheetBlock, BlurBlock, DirectionalBlurBlock, PremultiplyAlphaBlock + +### Transitions + +CompositionBlock, TintBlock, WipeBlock + +### Inputs + +Float, Color3, Color4, Texture, Vector2, Boolean + +### Output + +OutputBlock (auto-created with every new graph) + +## Limitations + +- **No snippet integration** — save/load from the Babylon.js snippet server is not yet supported. +- **No runtime hooks** — `export_runtime_descriptor` and `validate_against_runtime` are deferred (would require Babylon.js engine dependencies). +- **No live-preview** — the server operates on an in-memory model; real-time preview requires a running Babylon.js scene. diff --git a/packages/tools/smart-filters-mcp-server/examples/BlackAndWhite.json b/packages/tools/smart-filters-mcp-server/examples/BlackAndWhite.json new file mode 100644 index 00000000000..a3e1ae23db5 --- /dev/null +++ b/packages/tools/smart-filters-mcp-server/examples/BlackAndWhite.json @@ -0,0 +1,68 @@ +{ + "format": "smartFilter", + "formatVersion": 1, + "name": "BlackAndWhite", + "namespace": null, + "comments": null, + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 680, + "y": 0 + }, + { + "blockId": 2, + "x": 0, + "y": 0 + }, + { + "blockId": 3, + "x": 340, + "y": 0 + } + ] + }, + "blocks": [ + { + "name": "outputBlock", + "namespace": null, + "uniqueId": 1, + "blockType": "OutputBlock", + "comments": null, + "data": {} + }, + { + "name": "source", + "namespace": "Inputs", + "uniqueId": 2, + "blockType": "Texture", + "comments": null, + "data": { + "value": null + } + }, + { + "name": "bwEffect", + "namespace": "Babylon.Demo.Effects", + "uniqueId": 3, + "blockType": "BlackAndWhiteBlock", + "comments": null, + "data": {} + } + ], + "connections": [ + { + "outputBlock": 2, + "outputConnectionPoint": "output", + "inputBlock": 3, + "inputConnectionPoint": "input" + }, + { + "outputBlock": 3, + "outputConnectionPoint": "output", + "inputBlock": 1, + "inputConnectionPoint": "input" + } + ] +} \ No newline at end of file diff --git a/packages/tools/smart-filters-mcp-server/examples/BlurChain.json b/packages/tools/smart-filters-mcp-server/examples/BlurChain.json new file mode 100644 index 00000000000..89886f8eef8 --- /dev/null +++ b/packages/tools/smart-filters-mcp-server/examples/BlurChain.json @@ -0,0 +1,70 @@ +{ + "format": "smartFilter", + "formatVersion": 1, + "name": "BlurChain", + "namespace": null, + "comments": null, + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 680, + "y": 0 + }, + { + "blockId": 2, + "x": 0, + "y": 0 + }, + { + "blockId": 3, + "x": 340, + "y": 0 + } + ] + }, + "blocks": [ + { + "name": "outputBlock", + "namespace": null, + "uniqueId": 1, + "blockType": "OutputBlock", + "comments": null, + "data": {} + }, + { + "name": "source", + "namespace": "Inputs", + "uniqueId": 2, + "blockType": "Texture", + "comments": null, + "data": { + "value": null + } + }, + { + "name": "blur", + "namespace": "Babylon.Demo.Effects", + "uniqueId": 3, + "blockType": "BlurBlock", + "comments": null, + "data": { + "blurSize": 8 + } + } + ], + "connections": [ + { + "outputBlock": 2, + "outputConnectionPoint": "output", + "inputBlock": 3, + "inputConnectionPoint": "input" + }, + { + "outputBlock": 3, + "outputConnectionPoint": "output", + "inputBlock": 1, + "inputConnectionPoint": "input" + } + ] +} \ No newline at end of file diff --git a/packages/tools/smart-filters-mcp-server/examples/ContrastFilter.json b/packages/tools/smart-filters-mcp-server/examples/ContrastFilter.json new file mode 100644 index 00000000000..a5987b238f3 --- /dev/null +++ b/packages/tools/smart-filters-mcp-server/examples/ContrastFilter.json @@ -0,0 +1,89 @@ +{ + "format": "smartFilter", + "formatVersion": 1, + "name": "ContrastFilter", + "namespace": null, + "comments": null, + "editorData": { + "locations": [ + { + "blockId": 1, + "x": 680, + "y": 0 + }, + { + "blockId": 2, + "x": 0, + "y": 0 + }, + { + "blockId": 3, + "x": 0, + "y": 180 + }, + { + "blockId": 4, + "x": 340, + "y": 0 + } + ] + }, + "blocks": [ + { + "name": "outputBlock", + "namespace": null, + "uniqueId": 1, + "blockType": "OutputBlock", + "comments": null, + "data": {} + }, + { + "name": "source", + "namespace": "Inputs", + "uniqueId": 2, + "blockType": "Texture", + "comments": null, + "data": { + "value": null + } + }, + { + "name": "intensity", + "namespace": "Inputs", + "uniqueId": 3, + "blockType": "Float", + "comments": null, + "data": { + "value": 1.5 + } + }, + { + "name": "contrast", + "namespace": "Babylon.Demo.Effects", + "uniqueId": 4, + "blockType": "ContrastBlock", + "comments": null, + "data": {} + } + ], + "connections": [ + { + "outputBlock": 2, + "outputConnectionPoint": "output", + "inputBlock": 4, + "inputConnectionPoint": "input" + }, + { + "outputBlock": 3, + "outputConnectionPoint": "output", + "inputBlock": 4, + "inputConnectionPoint": "intensity" + }, + { + "outputBlock": 4, + "outputConnectionPoint": "output", + "inputBlock": 1, + "inputConnectionPoint": "input" + } + ] +} \ No newline at end of file diff --git a/packages/tools/smart-filters-mcp-server/package.json b/packages/tools/smart-filters-mcp-server/package.json new file mode 100644 index 00000000000..0b1c561c421 --- /dev/null +++ b/packages/tools/smart-filters-mcp-server/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tools/smart-filters-mcp-server", + "version": "1.0.0", + "private": true, + "description": "MCP server for AI-driven Smart Filters graph authoring 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/smart-filters-mcp-server/rollup.config.mjs b/packages/tools/smart-filters-mcp-server/rollup.config.mjs new file mode 100644 index 00000000000..b6a4d32812a --- /dev/null +++ b/packages/tools/smart-filters-mcp-server/rollup.config.mjs @@ -0,0 +1,3 @@ +import { createConfig } from "../rollup.config.mcp.mjs"; + +export default createConfig(); diff --git a/packages/tools/smart-filters-mcp-server/src/blockRegistry.ts b/packages/tools/smart-filters-mcp-server/src/blockRegistry.ts new file mode 100644 index 00000000000..6efe6bc1700 --- /dev/null +++ b/packages/tools/smart-filters-mcp-server/src/blockRegistry.ts @@ -0,0 +1,374 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * Complete registry of all Smart Filter block types available in Babylon.js. + * Each entry describes the block's type, category, inputs, outputs, and properties. + * + * This registry is purely informational — the MCP server works with a JSON data + * model and never instantiates real Babylon.js block classes. + */ + +/** + * Describes a single input or output connection point on a block. + */ +export interface IConnectionPointInfo { + /** Name of the connection point (e.g. "input", "output") */ + name: string; + /** Data type of the connection point (e.g. "Texture", "Float", "Color3") */ + type: string; + /** Whether the connection is optional */ + isOptional?: boolean; +} + +/** + * Describes a block type in the Smart Filters catalog. + */ +export interface IBlockTypeInfo { + /** The block type identifier used in serialization */ + blockType: string; + /** Category for grouping (e.g. "Effects", "Transitions", "Utilities", "Inputs") */ + category: string; + /** The namespace of the block */ + namespace: string; + /** Human-readable description of what this block does */ + description: string; + /** List of input connection points */ + inputs: IConnectionPointInfo[]; + /** List of output connection points */ + outputs: IConnectionPointInfo[]; + /** Extra properties that can be configured on the block */ + properties?: Record; + /** Whether this is an input block */ + isInput?: boolean; +} + +/** + * ConnectionPointType enum values matching the Smart Filters framework. + */ +export const ConnectionPointTypes: Record = { + Float: 1, + Texture: 2, + Color3: 3, + Color4: 4, + Boolean: 5, + Vector2: 6, +}; + +/** + * Reverse map from ConnectionPointType number to name + */ +export const ConnectionPointTypeNames: Record = { + 1: "Float", + 2: "Texture", + 3: "Color3", + 4: "Color4", + 5: "Boolean", + 6: "Vector2", +}; + +/** + * Full catalog of Smart Filter block types. + */ +export const BlockRegistry: Record = { + // ═══════════════════════════════════════════════════════════════════════ + // Effects + // ═══════════════════════════════════════════════════════════════════════ + BlackAndWhiteBlock: { + blockType: "BlackAndWhiteBlock", + category: "Effects", + namespace: "Babylon.Demo.Effects", + description: "Transforms the input texture to black and white.", + inputs: [{ name: "input", type: "Texture" }], + outputs: [{ name: "output", type: "Texture" }], + }, + KaleidoscopeBlock: { + blockType: "KaleidoscopeBlock", + category: "Effects", + namespace: "Babylon.Demo.Effects", + description: "Applies a kaleidoscope effect to the input texture. Requires a time/angle float input.", + inputs: [ + { name: "input", type: "Texture" }, + { name: "time", type: "Float" }, + ], + outputs: [{ name: "output", type: "Texture" }], + }, + PosterizeBlock: { + blockType: "PosterizeBlock", + category: "Effects", + namespace: "Babylon.Demo.Effects", + description: "Applies a posterize effect to the input texture, reducing the number of color levels.", + inputs: [ + { name: "input", type: "Texture" }, + { name: "intensity", type: "Float" }, + ], + outputs: [{ name: "output", type: "Texture" }], + }, + DesaturateBlock: { + blockType: "DesaturateBlock", + category: "Effects", + namespace: "Babylon.Demo.Effects", + description: "Desaturates the input texture, reducing color saturation towards greyscale.", + inputs: [ + { name: "input", type: "Texture" }, + { name: "intensity", type: "Float" }, + ], + outputs: [{ name: "output", type: "Texture" }], + }, + ContrastBlock: { + blockType: "ContrastBlock", + category: "Effects", + namespace: "Babylon.Demo.Effects", + description: "Adjusts the contrast of the input texture.", + inputs: [ + { name: "input", type: "Texture" }, + { name: "intensity", type: "Float" }, + ], + outputs: [{ name: "output", type: "Texture" }], + }, + GreenScreenBlock: { + blockType: "GreenScreenBlock", + category: "Effects", + namespace: "Babylon.Demo.Effects", + description: "Removes a green screen background and replaces it with the background texture.", + inputs: [ + { name: "input", type: "Texture" }, + { name: "background", type: "Texture" }, + { name: "reference", type: "Color3" }, + { name: "distance", type: "Float" }, + ], + outputs: [{ name: "output", type: "Texture" }], + }, + PixelateBlock: { + blockType: "PixelateBlock", + category: "Effects", + namespace: "Babylon.Demo.Effects", + description: "Adds a pixelation effect to the input texture.", + inputs: [ + { name: "input", type: "Texture" }, + { name: "intensity", type: "Float" }, + ], + outputs: [{ name: "output", type: "Texture" }], + }, + ExposureBlock: { + blockType: "ExposureBlock", + category: "Effects", + namespace: "Babylon.Demo.Effects", + description: "Adjusts the exposure of the input texture.", + inputs: [ + { name: "input", type: "Texture" }, + { name: "amount", type: "Float" }, + ], + outputs: [{ name: "output", type: "Texture" }], + }, + MaskBlock: { + blockType: "MaskBlock", + category: "Effects", + namespace: "Babylon.Demo.Effects", + description: "Applies a mask texture to the input texture, masking out portions of the image.", + inputs: [ + { name: "input", type: "Texture" }, + { name: "mask", type: "Texture" }, + ], + outputs: [{ name: "output", type: "Texture" }], + }, + SpritesheetBlock: { + blockType: "SpritesheetBlock", + category: "Effects", + namespace: "Babylon.Demo.Effects", + description: "Animates a sprite sheet texture, cycling through frames over time.", + inputs: [ + { name: "input", type: "Texture" }, + { name: "time", type: "Float", isOptional: true }, + { name: "rows", type: "Float", isOptional: true }, + { name: "columns", type: "Float", isOptional: true }, + { name: "frames", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "Texture" }], + }, + BlurBlock: { + blockType: "BlurBlock", + category: "Effects", + namespace: "Babylon.Demo.Effects", + description: "Blurs the input texture using multiple directional blur passes. " + "This is an aggregate block containing 4 internal DirectionalBlurBlocks.", + inputs: [{ name: "input", type: "Texture" }], + outputs: [{ name: "output", type: "Texture" }], + properties: { + blurSize: "number — the blur kernel size. Default: 2", + blurTextureRatioPerPass: "number — texture ratio for each blur pass. Default: 0.5", + }, + }, + DirectionalBlurBlock: { + blockType: "DirectionalBlurBlock", + category: "Effects", + namespace: "Babylon.Demo.Effects", + description: "Applies a single-pass directional blur to the input texture.", + inputs: [{ name: "input", type: "Texture" }], + outputs: [{ name: "output", type: "Texture" }], + properties: { + blurTextureRatio: "number — texture ratio for this blur pass. Default: 0.5", + blurHorizontalWidth: "number — horizontal blur width. Default: 0", + blurVerticalWidth: "number — vertical blur width. Default: 1", + }, + }, + CompositionBlock: { + blockType: "CompositionBlock", + category: "Effects", + namespace: "Babylon.Demo.Effects", + description: "Composites a foreground texture over a background texture with configurable position, size, and alpha blending.", + inputs: [ + { name: "background", type: "Texture" }, + { name: "foreground", type: "Texture", isOptional: true }, + { name: "foregroundTop", type: "Float", isOptional: true }, + { name: "foregroundLeft", type: "Float", isOptional: true }, + { name: "foregroundWidth", type: "Float", isOptional: true }, + { name: "foregroundHeight", type: "Float", isOptional: true }, + { name: "foregroundAlphaScale", type: "Float", isOptional: true }, + ], + outputs: [{ name: "output", type: "Texture" }], + properties: { + alphaMode: "number — alpha blending mode. 0=ALPHA_DISABLE, 1=ALPHA_ADD, 2=ALPHA_COMBINE, " + "3=ALPHA_SUBTRACT, 4=ALPHA_MULTIPLY. Default: 2 (ALPHA_COMBINE)", + }, + }, + TintBlock: { + blockType: "TintBlock", + category: "Effects", + namespace: "Babylon.Demo.Effects", + description: "Adds a colored tint to the input texture.", + inputs: [ + { name: "input", type: "Texture" }, + { name: "tint", type: "Color3" }, + { name: "amount", type: "Float" }, + ], + outputs: [{ name: "output", type: "Texture" }], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Transitions + // ═══════════════════════════════════════════════════════════════════════ + WipeBlock: { + blockType: "WipeBlock", + category: "Transitions", + namespace: "Babylon.Demo.Transitions", + description: "Performs a vertical wipe transition from textureB to textureA based on a progress float.", + inputs: [ + { name: "textureA", type: "Texture" }, + { name: "textureB", type: "Texture" }, + { name: "progress", type: "Float" }, + ], + outputs: [{ name: "output", type: "Texture" }], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Utilities + // ═══════════════════════════════════════════════════════════════════════ + PremultiplyAlphaBlock: { + blockType: "PremultiplyAlphaBlock", + category: "Utilities", + namespace: "Babylon.Demo.Utilities", + description: "Premultiplies the input texture's color channels by its alpha channel.", + inputs: [{ name: "input", type: "Texture" }], + outputs: [{ name: "output", type: "Texture" }], + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Inputs + // ═══════════════════════════════════════════════════════════════════════ + Float: { + blockType: "Float", + category: "Inputs", + namespace: "Inputs", + description: "A floating point input value.", + inputs: [], + outputs: [{ name: "output", type: "Float" }], + isInput: true, + }, + Color3: { + blockType: "Color3", + category: "Inputs", + namespace: "Inputs", + description: "A Color3 (RGB) input value.", + inputs: [], + outputs: [{ name: "output", type: "Color3" }], + isInput: true, + }, + Color4: { + blockType: "Color4", + category: "Inputs", + namespace: "Inputs", + description: "A Color4 (RGBA) input value.", + inputs: [], + outputs: [{ name: "output", type: "Color4" }], + isInput: true, + }, + Texture: { + blockType: "Texture", + category: "Inputs", + namespace: "Inputs", + description: "A texture input. Provide a URL or texture reference.", + inputs: [], + outputs: [{ name: "output", type: "Texture" }], + isInput: true, + }, + Vector2: { + blockType: "Vector2", + category: "Inputs", + namespace: "Inputs", + description: "A Vector2 input value.", + inputs: [], + outputs: [{ name: "output", type: "Vector2" }], + isInput: true, + }, + Boolean: { + blockType: "Boolean", + category: "Inputs", + namespace: "Inputs", + description: "A boolean input value.", + inputs: [], + outputs: [{ name: "output", type: "Boolean" }], + isInput: true, + }, + + // ═══════════════════════════════════════════════════════════════════════ + // Output (internal — always auto-created by the graph) + // ═══════════════════════════════════════════════════════════════════════ + OutputBlock: { + blockType: "OutputBlock", + category: "Output", + namespace: "", + description: "The final output block of the Smart Filter graph. Automatically created; do not add manually.", + inputs: [{ name: "input", type: "Texture" }], + outputs: [], + }, +}; + +/** + * Returns a text summary of the block catalog grouped by category. + * @returns A formatted text summary of all block types. + */ +export function GetBlockCatalogSummary(): string { + const byCategory = new Map(); + for (const [key, info] of Object.entries(BlockRegistry)) { + if (key === "OutputBlock") { + continue; + } // internal + if (!byCategory.has(info.category)) { + byCategory.set(info.category, []); + } + byCategory.get(info.category)!.push(` ${key}: ${info.description.split(".")[0]}`); + } + + const lines: string[] = []; + for (const [cat, entries] of byCategory) { + lines.push(`\n## ${cat}`); + lines.push(...entries); + } + return lines.join("\n"); +} + +/** + * Get detailed info about a specific block type. + * @param blockType - The block type name. + * @returns The block type info, or undefined if not found. + */ +export function GetBlockTypeDetails(blockType: string): IBlockTypeInfo | undefined { + return BlockRegistry[blockType]; +} diff --git a/packages/tools/smart-filters-mcp-server/src/index.ts b/packages/tools/smart-filters-mcp-server/src/index.ts new file mode 100644 index 00000000000..89769207152 --- /dev/null +++ b/packages/tools/smart-filters-mcp-server/src/index.ts @@ -0,0 +1,974 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-console */ +/** + * Smart Filters MCP Server (babylonjs-smart-filters) + * ────────────── + * A Model Context Protocol server that exposes tools for building Babylon.js + * Smart Filter graphs programmatically. An AI agent (or any MCP client) can: + * + * • Create / manage Smart Filter graphs + * • Add blocks from the Smart Filters block catalog + * • Connect blocks together + * • Set block properties + * • Validate the graph + * • Export the final Smart Filter JSON (loadable by the Smart Filters editor/runtime) + * • Import existing Smart Filter 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 { + CreateErrorResponse, + CreateInlineJsonSchema, + CreateJsonFileSchema, + CreateJsonImportResponse, + CreateOutputFileSchema, + CreateTextResponse, + McpEditorSessionController, + WriteTextFileEnsuringDirectory, +} from "@tools/mcp-server-core"; + +import { BlockRegistry, GetBlockCatalogSummary, GetBlockTypeDetails } from "./blockRegistry.js"; +import { SmartFiltersGraphManager } from "./smartFiltersGraph.js"; + +// ─── Singleton graph manager ────────────────────────────────────────────── +const manager = new SmartFiltersGraphManager(); +const sessionController = new McpEditorSessionController( + { + serverName: "Smart Filters MCP Session Server", + documentKind: "smart-filter", + managerUnavailableMessage: "Smart Filters graph manager is not available", + getDocument: (manager, session) => manager.exportJSON(session.name), + setDocument: (manager, session, document) => { + const result = manager.importJSON(session.name, document); + return result && result !== "OK" ? result : undefined; + }, + }, + { + defaultPort: 3001, + statusTitle: "Smart Filters MCP Session Server", + } +); + +/** + * Notify SSE subscribers if a session exists for the given Smart Filter graph. + * @param graphName - The graph name to check for active sessions. + */ +function _notifyIfSession(graphName: string): void { + const sessionId = sessionController.getSessionIdForName(graphName); + if (sessionId) { + sessionController.notifySessionUpdate(sessionId); + } +} + +/** + * Import Smart Filter JSON and notify a matching live session on success. + * @param graphName - The graph name to import into. + * @param jsonText - Serialized Smart Filter JSON. + * @returns "OK" on success, or an error string. + */ +function _importFilterGraphJson(graphName: string, jsonText: string): string { + const result = manager.importJSON(graphName, jsonText); + if (result === "OK") { + _notifyIfSession(graphName); + } + return result; +} + +// ─── MCP Server ─────────────────────────────────────────────────────────── +const server = new McpServer( + { + name: "babylonjs-smart-filters", + version: "1.0.0", + }, + { + instructions: [ + "You build Babylon.js Smart Filter graphs (post-processing effect chains).", + "Workflow: create_filter_graph → add_block (input blocks for textures/values, then effect blocks) → connect_blocks → set_block_properties → validate_graph → export_filter_graph_json.", + "Every graph auto-creates an OutputBlock. Connect your final effect block's output to the OutputBlock's input.", + "Smart Filters are Texture-in, Texture-out chains. Most effect blocks have a 'input' (Texture) and 'output' (Texture).", + "Use get_block_type_info to discover ports before connecting.", + ].join(" "), + } +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Resources (read-only reference data) +// ═══════════════════════════════════════════════════════════════════════════ + +server.registerResource("block-catalog", "smart-filters://block-catalog", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: `# Smart Filters Block Catalog\n${GetBlockCatalogSummary()}`, + }, + ], +})); + +server.registerResource("enums", "smart-filters://enums", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# Smart Filters Enumerations Reference", + "", + "## ConnectionPointType", + "Float (1), Texture (2), Color3 (3), Color4 (4), Boolean (5), Vector2 (6)", + "", + "## CompositionBlock.alphaMode", + "ALPHA_DISABLE (0), ALPHA_ADD (1), ALPHA_COMBINE (2), ALPHA_SUBTRACT (3), ALPHA_MULTIPLY (4)", + ].join("\n"), + }, + ], +})); + +server.registerResource("concepts", "smart-filters://concepts", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# Smart Filters Concepts", + "", + "## What is a Smart Filter?", + "A Smart Filter is a visual, graph-based post-processing effect chain in Babylon.js.", + "Instead of writing shader code, you connect typed blocks that represent image processing", + "operations. The graph evaluates at runtime to produce a final output texture.", + "", + "## Graph Structure", + "Every Smart Filter graph has an OutputBlock (auto-created) that receives the final processed texture.", + "You build a chain: Input textures → Effect blocks → OutputBlock.", + "", + "## Data Flow: Texture In, Texture Out", + "Most effect blocks take a Texture input and produce a Texture output.", + "Some blocks also accept Float, Color3, or other parameters to control the effect.", + "", + "## Input Blocks", + "Input blocks provide values to the graph:", + " • Texture — a source image/video", + " • Float — a numeric parameter (intensity, amount, etc.)", + " • Color3 — an RGB color", + " • Color4 — an RGBA color", + " • Vector2 — a 2D vector", + " • Boolean — a toggle", + "", + "## Effect Blocks", + "Process textures: BlackAndWhite, Blur, Contrast, Desaturate, Exposure, GreenScreen,", + "Kaleidoscope, Mask, Pixelate, Posterize, Spritesheet, Composition, Tint, DirectionalBlur.", + "", + "## Transition Blocks", + "Blend between textures: Wipe.", + "", + "## Utility Blocks", + "Helper operations: PremultiplyAlpha.", + "", + "## The Simplest Smart Filter", + "```", + "Texture input → BlackAndWhiteBlock.input → BlackAndWhiteBlock.output → OutputBlock.input", + "```", + "", + "## Common Mistakes", + "1. Not connecting to OutputBlock — the filter produces no output", + "2. Connecting incompatible types (e.g. Float to Texture input)", + "3. Creating cycles in the graph", + "4. Input blocks (Float, Color3, etc.) have output ports named 'output' — not 'value'", + ].join("\n"), + }, + ], +})); + +// ═══════════════════════════════════════════════════════════════════════════ +// Prompts (reusable prompt templates) +// ═══════════════════════════════════════════════════════════════════════════ + +server.registerPrompt("create-basic-filter", { description: "Step-by-step instructions for building a simple black and white filter" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a simple black and white Smart Filter. Steps:", + "1. create_filter_graph with name 'BasicBW'", + "2. Add a Texture input block named 'source'", + "3. Add a BlackAndWhiteBlock named 'bw'", + "4. Connect source.output → bw.input", + "5. Connect bw.output → outputBlock.input (OutputBlock has uniqueId 1)", + "6. validate_graph, then export_filter_graph_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-blur-filter", { description: "Step-by-step instructions for building a blur filter with adjustable intensity" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a blur Smart Filter with adjustable blur size. Steps:", + "1. create_filter_graph with name 'AdjustableBlur'", + "2. Add a Texture input block named 'source'", + "3. Add a BlurBlock named 'blur' with properties { blurSize: 4 }", + "4. Connect source.output → blur.input", + "5. Connect blur.output → outputBlock.input (OutputBlock has uniqueId 1)", + "6. validate_graph, then export_filter_graph_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-tinted-desaturate", { description: "Step-by-step instructions for combining desaturate with a color tint" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a Smart Filter that desaturates then tints the image. Steps:", + "1. create_filter_graph with name 'TintedDesaturate'", + "2. Add a Texture input block named 'source'", + "3. Add a Float input block named 'desatAmount' with properties { value: 0.7 }", + "4. Add a DesaturateBlock named 'desat'", + "5. Connect source.output → desat.input", + "6. Connect desatAmount.output → desat.intensity", + "7. Add a Color3 input block named 'tintColor' with properties { value: { r: 0.8, g: 0.6, b: 0.4 } }", + "8. Add a Float input block named 'tintAmount' with properties { value: 0.3 }", + "9. Add a TintBlock named 'tint'", + "10. Connect desat.output → tint.input", + "11. Connect tintColor.output → tint.tint", + "12. Connect tintAmount.output → tint.amount", + "13. Connect tint.output → outputBlock.input (OutputBlock has uniqueId 1)", + "14. validate_graph, then export_filter_graph_json", + ].join("\n"), + }, + }, + ], +})); + +// ═══════════════════════════════════════════════════════════════════════════ +// Tools +// ═══════════════════════════════════════════════════════════════════════════ + +// ── Graph lifecycle ───────────────────────────────────────────────────── + +server.registerTool( + "create_filter_graph", + { + description: + "Create a new empty Smart Filter graph in memory. This is always the first step. " + + "An OutputBlock (uniqueId=1) is automatically created — connect your final effect to it.", + inputSchema: { + name: z.string().describe("Unique name for the filter graph (e.g. 'MyBlurFilter', 'GreenScreenEffect')"), + comments: z.string().optional().describe("Optional description of what this filter does"), + }, + }, + async ({ name, comments }) => { + manager.createGraph(name, comments); + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(name); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return CreateTextResponse( + `Created Smart Filter graph "${name}" with OutputBlock [1]. Add blocks with add_block, connect with connect_blocks, then export with export_filter_graph_json.\n\nMCP Session URL: ${sessionUrl}` + ); + } +); + +server.registerTool( + "list_filter_graphs", + { + description: "List all Smart Filter graphs currently in memory.", + }, + async () => { + const names = manager.listGraphs(); + return { + content: [ + { + type: "text", + text: names.length > 0 ? `Filter graphs in memory:\n${names.map((n) => ` • ${n}`).join("\n")}` : "No filter graphs in memory.", + }, + ], + }; + } +); + +server.registerTool( + "delete_filter_graph", + { + description: "Delete a Smart Filter graph from memory.", + inputSchema: { + name: z.string().describe("Name of the filter graph to delete"), + }, + }, + async ({ name }) => { + const ok = manager.deleteGraph(name); + if (ok) { + sessionController.closeSessionForName(name); + } + return { + content: [{ type: "text", text: ok ? `Deleted "${name}".` : `Filter graph "${name}" not found.` }], + }; + } +); + +server.registerTool( + "clone_filter_graph", + { + description: "Clone an existing Smart Filter graph under a new name.", + inputSchema: { + sourceName: z.string().describe("Name of the filter graph to clone"), + targetName: z.string().describe("New name for the cloned graph"), + }, + }, + async ({ sourceName, targetName }) => { + const result = manager.cloneGraph(sourceName, targetName); + if (typeof result === "string") { + return { content: [{ type: "text", text: `Error: ${result}` }], isError: true }; + } + _notifyIfSession(targetName); + return { + content: [{ type: "text", text: `Cloned "${sourceName}" → "${targetName}" (${result.blocks.length} blocks, ${result.connections.length} connections).` }], + }; + } +); + +server.registerTool( + "clear_all", + { + description: "Remove all Smart Filter graphs from memory, resetting the server to a clean state.", + }, + async () => { + const names = manager.listGraphs(); + manager.clearAll(); + for (const name of names) { + sessionController.closeSessionForName(name); + } + return { + content: [ + { + type: "text", + text: names.length > 0 ? `Cleared ${names.length} filter graph(s): ${names.join(", ")}` : "Nothing to clear — memory was already empty.", + }, + ], + }; + } +); + +server.registerTool( + "get_session_url", + { + description: "Get or create a live-session URL for a Smart Filter graph. The URL can be pasted into the Smart Filters Editor MCP session panel.", + inputSchema: { + graphName: z.string().describe("Name of the Smart Filter graph"), + }, + }, + async ({ graphName }) => { + const graphs = manager.listGraphs(); + if (!graphs.includes(graphName)) { + return CreateErrorResponse(`Filter graph "${graphName}" not found.`); + } + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(graphName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return CreateTextResponse(`MCP Session URL: ${sessionUrl}`); + } +); + +server.registerTool( + "start_session", + { + description: "Start a live editor session for a Smart Filter graph and return its URL.", + inputSchema: { + graphName: z.string().describe("Name of the Smart Filter graph"), + }, + }, + async ({ graphName }) => { + const graphs = manager.listGraphs(); + if (!graphs.includes(graphName)) { + return CreateErrorResponse(`Filter graph "${graphName}" not found.`); + } + const port = await sessionController.startAsync(manager); + const sessionId = sessionController.createSession(graphName); + const sessionUrl = sessionController.getSessionUrl(sessionId, port); + return CreateTextResponse(`Started Smart Filters editor session for "${graphName}".\n\nMCP Session URL: ${sessionUrl}`); + } +); + +server.registerTool( + "close_session", + { + description: "Close the live editor session for a Smart Filter graph.", + inputSchema: { + graphName: z.string().describe("Name of the Smart Filter graph"), + }, + }, + async ({ graphName }) => { + const closed = sessionController.closeSessionForName(graphName); + return CreateTextResponse(closed ? `Closed Smart Filters editor session for "${graphName}".` : `No active Smart Filters editor session for "${graphName}".`); + } +); + +server.registerTool("stop_session_server", { description: "Stop the local Smart Filters MCP HTTP/SSE session server and close all active sessions." }, async () => { + await sessionController.stopAsync(); + return CreateTextResponse("Smart Filters MCP session server stopped."); +}); + +// ── Block operations ──────────────────────────────────────────────────── + +server.registerTool( + "add_block", + { + description: "Add a new block to a Smart Filter graph. Returns the block's uniqueId for use in connect_blocks. " + "Do NOT add OutputBlock — it is auto-created.", + inputSchema: { + graphName: z.string().describe("Name of the filter graph to add the block to"), + blockType: z.string().describe("The block type from the registry (e.g. 'BlackAndWhiteBlock', 'BlurBlock', 'Float', 'Texture'). " + "Use list_block_types to see all."), + name: z.string().optional().describe("Human-friendly name for this block instance"), + properties: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Key-value properties. For input blocks: { value: ... }. " + + "For BlurBlock: { blurSize: 4, blurTextureRatioPerPass: 0.5 }. " + + "For CompositionBlock: { alphaMode: 2 }." + ), + }, + }, + async ({ graphName, blockType, name, properties }) => { + const result = manager.addBlock(graphName, blockType, name, properties as Record); + if (typeof result === "string") { + return { content: [{ type: "text", text: `Error: ${result}` }], isError: true }; + } + _notifyIfSession(graphName); + const lines = [`Added block [${result.block.uniqueId}] "${result.block.name}" (${blockType}). Use uniqueId ${result.block.uniqueId} to connect it.`]; + if (result.warnings) { + lines.push("", "Warnings:", ...result.warnings); + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + } +); + +server.registerTool( + "add_blocks_batch", + { + description: "Add multiple blocks at once. More efficient than calling add_block repeatedly.", + inputSchema: { + graphName: z.string().describe("Name of the filter graph"), + blocks: z + .array( + z.object({ + blockType: z.string().describe("Block type name"), + name: z.string().optional().describe("Instance name for the block"), + properties: z.record(z.string(), z.unknown()).optional().describe("Block properties"), + }) + ) + .describe("Array of blocks to add"), + }, + }, + async ({ graphName, blocks }) => { + const results: string[] = []; + let changed = false; + for (const blockDef of blocks) { + const result = manager.addBlock(graphName, blockDef.blockType, blockDef.name, blockDef.properties as Record); + if (typeof result === "string") { + results.push(`Error adding ${blockDef.blockType}: ${result}`); + } else { + changed = true; + let line = `[${result.block.uniqueId}] ${result.block.name} (${blockDef.blockType})`; + if (result.warnings) { + line += `\n ⚠ ${result.warnings.join("\n ⚠ ")}`; + } + results.push(line); + } + } + if (changed) { + _notifyIfSession(graphName); + } + return { content: [{ type: "text", text: `Added blocks:\n${results.join("\n")}` }] }; + } +); + +server.registerTool( + "remove_block", + { + description: "Remove a block from a Smart Filter graph. Also removes any connections to/from it.", + inputSchema: { + graphName: z.string().describe("Name of the filter graph"), + blockId: z.number().describe("The block uniqueId to remove"), + }, + }, + async ({ graphName, blockId }) => { + const result = manager.removeBlock(graphName, blockId); + if (result === "OK") { + _notifyIfSession(graphName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Removed block ${blockId}.` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "set_block_properties", + { + description: "Set or update properties on an existing block. For input blocks, set 'value'. " + "For BlurBlock, set 'blurSize'. For CompositionBlock, set 'alphaMode'.", + inputSchema: { + graphName: z.string().describe("Name of the filter graph"), + blockId: z.number().describe("The block uniqueId to modify"), + properties: z.record(z.string(), z.unknown()).describe("Key-value properties to set."), + }, + }, + async ({ graphName, blockId, properties }) => { + const result = manager.setBlockProperties(graphName, blockId, properties as Record); + if (result === "OK") { + _notifyIfSession(graphName); + } + return { + content: [{ type: "text", text: result === "OK" ? `Updated block ${blockId}.` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "get_block_properties", + { + description: "Get the current properties of a block.", + inputSchema: { + graphName: z.string().describe("Name of the filter graph"), + blockId: z.number().describe("The block uniqueId to inspect"), + }, + }, + async ({ graphName, blockId }) => { + const result = manager.getBlockProperties(graphName, blockId); + if (typeof result === "string") { + return { content: [{ type: "text", text: `Error: ${result}` }], isError: true }; + } + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } +); + +// ── Connections ────────────────────────────────────────────────────────── + +server.registerTool( + "connect_blocks", + { + description: "Connect an output of one block to an input of another block. " + "Data flows from source output → target input. Types must match.", + inputSchema: { + graphName: z.string().describe("Name of the filter graph"), + sourceBlockId: z.number().describe("Block uniqueId to connect FROM (the one with the output)"), + outputName: z.string().describe("Name of the output on the source block (e.g. 'output')"), + targetBlockId: z.number().describe("Block uniqueId to connect TO (the one with the input)"), + inputName: z.string().describe("Name of the input on the target block (e.g. 'input', 'intensity')"), + }, + }, + async ({ graphName, sourceBlockId, outputName, targetBlockId, inputName }) => { + const result = manager.connectBlocks(graphName, sourceBlockId, outputName, targetBlockId, inputName); + if (result === "OK") { + _notifyIfSession(graphName); + } + return { + content: [ + { + type: "text", + text: result === "OK" ? `Connected [${sourceBlockId}].${outputName} → [${targetBlockId}].${inputName}` : `Error: ${result}`, + }, + ], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "connect_blocks_batch", + { + description: "Connect multiple block pairs at once. More efficient than calling connect_blocks repeatedly.", + inputSchema: { + graphName: z.string().describe("Name of the filter graph"), + connections: z + .array( + z.object({ + sourceBlockId: z.number(), + outputName: z.string(), + targetBlockId: z.number(), + inputName: z.string(), + }) + ) + .describe("Array of connections to make"), + }, + }, + async ({ graphName, connections }) => { + const results: string[] = []; + let changed = false; + for (const conn of connections) { + const result = manager.connectBlocks(graphName, conn.sourceBlockId, conn.outputName, conn.targetBlockId, conn.inputName); + if (result === "OK") { + changed = true; + results.push(`[${conn.sourceBlockId}].${conn.outputName} → [${conn.targetBlockId}].${conn.inputName}`); + } else { + results.push(`Error: ${result}`); + } + } + if (changed) { + _notifyIfSession(graphName); + } + return { content: [{ type: "text", text: `Connections:\n${results.join("\n")}` }] }; + } +); + +server.registerTool( + "disconnect_input", + { + description: "Disconnect an input on a block (remove an existing connection).", + inputSchema: { + graphName: z.string().describe("Name of the filter graph"), + blockId: z.number().describe("The block uniqueId whose input to disconnect"), + inputName: z.string().describe("Name of the input to disconnect"), + }, + }, + async ({ graphName, blockId, inputName }) => { + const result = manager.disconnectInput(graphName, blockId, inputName); + if (result === "OK") { + _notifyIfSession(graphName); + } + return { + content: [ + { + type: "text", + text: result === "OK" ? `Disconnected [${blockId}].${inputName}` : `Error: ${result}`, + }, + ], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "list_connections", + { + description: "List all connections in a filter graph.", + inputSchema: { + graphName: z.string().describe("Name of the filter graph"), + }, + }, + async ({ graphName }) => { + const result = manager.listConnections(graphName); + return { content: [{ type: "text", text: result }] }; + } +); + +// ── Query tools ───────────────────────────────────────────────────────── + +server.registerTool( + "describe_graph", + { + description: "Get a human-readable description of the current state of a Smart Filter graph, " + "including all blocks and their connections.", + inputSchema: { + graphName: z.string().describe("Name of the filter 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 in a filter graph.", + inputSchema: { + graphName: z.string().describe("Name of the filter graph"), + blockId: z.number().describe("The block uniqueId 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 Smart Filter block types, grouped by category.", + inputSchema: { + category: z.string().optional().describe("Optionally filter by category (Effects, Transitions, Utilities, Inputs)"), + }, + }, + async ({ category }) => { + if (category) { + const matching = Object.entries(BlockRegistry) + .filter(([key, info]) => key !== "OutputBlock" && info.category.toLowerCase() === category.toLowerCase()) + .map(([key, info]) => ` ${key}: ${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 inputs, outputs, properties, and description.", + inputSchema: { + blockType: z.string().describe("The block type name (e.g. 'BlurBlock', 'BlackAndWhiteBlock', 'Float')"), + }, + }, + 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}`); + lines.push(`Category: ${info.category}`); + lines.push(`Namespace: ${info.namespace}`); + lines.push(`Description: ${info.description}`); + + lines.push("\n### Inputs:"); + if (info.inputs.length === 0) { + lines.push(" (none)"); + } + for (const inp of info.inputs) { + const opt = inp.isOptional ? " (optional)" : " (required)"; + lines.push(` • ${inp.name}: ${inp.type}${opt}`); + } + + lines.push("\n### Outputs:"); + if (info.outputs.length === 0) { + lines.push(" (none)"); + } + for (const out of info.outputs) { + lines.push(` • ${out.name}: ${out.type}`); + } + + if (info.properties) { + lines.push("\n### Configurable Properties:"); + for (const [k, v] of Object.entries(info.properties)) { + lines.push(` • ${k}: ${v}`); + } + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } +); + +server.registerTool( + "list_categories", + { + description: "List all block categories available in the Smart Filters system.", + }, + async () => { + const categories = new Set(); + for (const [key, info] of Object.entries(BlockRegistry)) { + if (key !== "OutputBlock") { + categories.add(info.category); + } + } + const lines = Array.from(categories).map((c) => ` • ${c}`); + return { + content: [{ type: "text", text: `Block categories:\n${lines.join("\n")}` }], + }; + } +); + +// ── Validation ────────────────────────────────────────────────────────── + +server.registerTool( + "validate_graph", + { + description: "Run validation checks on a Smart Filter graph. Reports missing connections, type mismatches, orphan blocks, and more.", + inputSchema: { + graphName: z.string().describe("Name of the filter 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")), + }; + } +); + +server.registerTool( + "list_issues", + { + description: "Same as validate_graph — returns all validation issues for a graph.", + inputSchema: { + graphName: z.string().describe("Name of the filter graph to check"), + }, + }, + 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_filter_graph_json", + { + description: + "Export the Smart Filter graph as V1 JSON. This JSON can be loaded in the Babylon.js Smart Filters Editor " + + "or via SmartFilterDeserializer at runtime. " + + "When outputFile is provided, the JSON is written to disk and only the file path is returned.", + inputSchema: { + graphName: z.string().describe("Name of the filter graph to export"), + outputFile: CreateOutputFileSchema(z), + }, + }, + async ({ graphName, outputFile }) => { + const json = manager.exportJSON(graphName); + if (!json) { + return { + content: [{ type: "text", text: `Filter graph "${graphName}" not found.` }], + isError: true, + }; + } + if (outputFile) { + try { + WriteTextFileEnsuringDirectory(outputFile, json); + return { content: [{ type: "text", text: `Smart Filter JSON written to: ${outputFile}` }] }; + } catch (e) { + return { + content: [{ type: "text", text: `Error writing file: ${(e as Error).message}` }], + isError: true, + }; + } + } + return { content: [{ type: "text", text: json }] }; + } +); + +server.registerTool( + "import_filter_graph_json", + { + description: "Import an existing Smart Filter V1 JSON into memory for editing. " + "Provide either the inline json string OR a jsonFile path (not both).", + inputSchema: { + graphName: z.string().describe("Name to give the imported filter graph"), + json: CreateInlineJsonSchema(z, "The Smart Filter JSON string to import"), + jsonFile: CreateJsonFileSchema(z, "Absolute path to a file containing the Smart Filter JSON (alternative to inline json)"), + }, + }, + async ({ graphName, json, jsonFile }) => { + return CreateJsonImportResponse({ + json, + jsonFile, + fileDescription: "Smart Filter JSON file", + importJson: (jsonText: string) => _importFilterGraphJson(graphName, jsonText), + describeImported: () => manager.describeGraph(graphName), + }); + } +); + +// ── Search helpers ────────────────────────────────────────────────────── + +server.registerTool( + "find_blocks", + { + description: "Search for blocks in a filter graph by name, type, or namespace substring.", + inputSchema: { + graphName: z.string().describe("Name of the filter graph to search"), + query: z.string().describe("Search string (matches against block name, type, and namespace)"), + }, + }, + async ({ graphName, query }) => { + const result = manager.findBlocks(graphName, query); + return { content: [{ type: "text", text: result }] }; + } +); + +server.registerTool( + "find_block_types", + { + description: "Search block types in the registry by name, category, or description substring.", + inputSchema: { + query: z.string().describe("Search string (matches against block type name, category, and description)"), + }, + }, + async ({ query }) => { + const q = query.toLowerCase(); + const matches = Object.entries(BlockRegistry) + .filter( + ([key, info]) => + key !== "OutputBlock" && + (key.toLowerCase().includes(q) || + info.category.toLowerCase().includes(q) || + info.description.toLowerCase().includes(q) || + info.namespace.toLowerCase().includes(q)) + ) + .map(([key, info]) => ` ${key} (${info.category}): ${info.description.split(".")[0]}`); + + if (matches.length === 0) { + return { + content: [{ type: "text", text: `No block types matching "${query}" found.` }], + }; + } + return { + content: [ + { + type: "text", + text: `Found ${matches.length} block type(s) matching "${query}":\n${matches.join("\n")}`, + }, + ], + }; + } +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Start the server +// ═══════════════════════════════════════════════════════════════════════════ + +async function Main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Babylon.js Smart Filters MCP Server running on stdio"); +} + +try { + await Main(); +} catch (err) { + console.error("Fatal error:", err); + process.exit(1); +} + +const _shutdownAsync = async () => { + await sessionController.stopAsync(); + process.exit(0); +}; + +process.on("SIGINT", _shutdownAsync); +process.on("SIGTERM", _shutdownAsync); diff --git a/packages/tools/smart-filters-mcp-server/src/smartFiltersGraph.ts b/packages/tools/smart-filters-mcp-server/src/smartFiltersGraph.ts new file mode 100644 index 00000000000..a8bd13900e1 --- /dev/null +++ b/packages/tools/smart-filters-mcp-server/src/smartFiltersGraph.ts @@ -0,0 +1,937 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * SmartFiltersGraphManager – holds an in-memory representation of Smart Filter + * graphs that the MCP tools build up incrementally. When the user is satisfied, + * the graph can be serialised to the V1 Smart Filter JSON format that the + * Smart Filters editor and runtime understand. + * + * 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 + * the serialisation format (SerializedSmartFilterV1). + * 2. **Idempotent & stateful** – the manager stores the current graph in memory + * so an AI agent can add blocks, connect them, tweak properties, and finally + * export. Multiple filter graphs can coexist (keyed by name). + */ + +import { BlockRegistry, ConnectionPointTypes, type IBlockTypeInfo } from "./blockRegistry.js"; + +// ─── Types (mirrors the Smart Filter V1 serialization format) ───────────── + +/** + * Serialized form of a single block in a Smart Filter graph. + */ +export interface ISerializedBlockV1 { + /** Block instance name */ + name: string; + /** Block namespace */ + namespace: string | null; + /** Session-unique ID */ + uniqueId: number; + /** Block type identifier (e.g. "BlurBlock", "Float", "OutputBlock") */ + blockType: string; + /** Optional user comment */ + comments: string | null; + /** Shader block output texture options */ + outputTextureOptions?: { ratio: number; format: number; type: number }; + /** Block-specific serialized data */ + data: Record; +} + +/** + * Serialized form of a connection between two blocks. + */ +export interface ISerializedConnectionV1 { + /** Source block uniqueId */ + outputBlock: number; + /** Source port name */ + outputConnectionPoint: string; + /** Target block uniqueId */ + inputBlock: number; + /** Target port name */ + inputConnectionPoint: string; +} + +/** + * Serialized form of a complete Smart Filter graph (V1 format). + */ +export interface ISerializedSmartFilterV1 { + /** Format discriminator */ + format: "smartFilter"; + /** Format version */ + formatVersion: 1; + /** Filter name */ + name: string; + /** Optional namespace */ + namespace: string | null; + /** Optional description */ + comments: string | null; + /** Optional editor layout data */ + editorData: unknown | null; + /** All blocks in the graph */ + blocks: ISerializedBlockV1[]; + /** All connections between blocks */ + connections: ISerializedConnectionV1[]; +} + +// ─── Default values for input blocks ────────────────────────────────────── + +const InputBlockDefaults: Record = { + Float: { connectionType: ConnectionPointTypes.Float, defaultValue: 0 }, + Color3: { connectionType: ConnectionPointTypes.Color3, defaultValue: { r: 1, g: 1, b: 1 } }, + Color4: { connectionType: ConnectionPointTypes.Color4, defaultValue: { r: 1, g: 1, b: 1, a: 1 } }, + Texture: { connectionType: ConnectionPointTypes.Texture, defaultValue: null }, + Vector2: { connectionType: ConnectionPointTypes.Vector2, defaultValue: { x: 0, y: 0 } }, + Boolean: { connectionType: ConnectionPointTypes.Boolean, defaultValue: false }, +}; + +// ─── Manager ────────────────────────────────────────────────────────────── + +/** + * Holds in-memory representations of Smart Filter graphs that MCP tools build up incrementally. + */ +export class SmartFiltersGraphManager { + /** All managed filter graphs, keyed by name. */ + private _graphs = new Map(); + /** Auto-increment block uniqueId counter per graph */ + private _nextId = new Map(); + + // ── Lifecycle ────────────────────────────────────────────────────── + + /** + * Create a new empty filter graph with an OutputBlock. + * @param name - Unique name for the filter graph. + * @param comments - Optional description. + * @returns The newly created serialized filter graph. + */ + createGraph(name: string, comments?: string): ISerializedSmartFilterV1 { + const graph: ISerializedSmartFilterV1 = { + format: "smartFilter", + formatVersion: 1, + name, + namespace: null, + comments: comments ?? null, + editorData: null, + blocks: [], + connections: [], + }; + + // Every Smart Filter graph needs an OutputBlock + const outputBlock: ISerializedBlockV1 = { + name: "outputBlock", + namespace: null, + uniqueId: 1, + blockType: "OutputBlock", + comments: null, + data: {}, + }; + graph.blocks.push(outputBlock); + + this._graphs.set(name, graph); + this._nextId.set(name, 2); // 1 is taken by OutputBlock + return graph; + } + + /** + * Retrieve a filter graph by name. + * @param name - The graph name. + * @returns The graph, or undefined if not found. + */ + getGraph(name: string): ISerializedSmartFilterV1 | undefined { + return this._graphs.get(name); + } + + /** + * List the names of all managed filter graphs. + * @returns An array of graph names. + */ + listGraphs(): string[] { + return Array.from(this._graphs.keys()); + } + + /** + * Delete a filter graph by name. + * @param name - The graph name. + * @returns True if deleted, false if not found. + */ + deleteGraph(name: string): boolean { + this._nextId.delete(name); + return this._graphs.delete(name); + } + + /** + * Clone a filter graph under a new name. + * @param sourceName - The graph to clone. + * @param targetName - The new graph name. + * @returns The cloned graph, or an error string. + */ + cloneGraph(sourceName: string, targetName: string): ISerializedSmartFilterV1 | string { + const source = this._graphs.get(sourceName); + if (!source) { + return `Filter graph "${sourceName}" not found.`; + } + if (this._graphs.has(targetName)) { + return `Filter graph "${targetName}" already exists.`; + } + + const clone: ISerializedSmartFilterV1 = JSON.parse(JSON.stringify(source)); + clone.name = targetName; + this._graphs.set(targetName, clone); + this._nextId.set(targetName, this._nextId.get(sourceName)!); + return clone; + } + + /** + * Remove all filter graphs from memory. + */ + clearAll(): void { + this._graphs.clear(); + this._nextId.clear(); + } + + // ── Block CRUD ───────────────────────────────────────────────────── + + /** + * Add a block to a filter graph. + * @param graphName - The graph to add the block to. + * @param blockType - The block type (e.g. "BlurBlock"). + * @param blockName - Optional display name for the block. + * @param properties - Optional initial properties. + * @returns The created block, or an error string. + */ + addBlock(graphName: string, blockType: string, blockName?: string, properties?: Record): { block: ISerializedBlockV1; warnings?: string[] } | string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Filter graph "${graphName}" not found. Create it first.`; + } + + // Prevent adding a second OutputBlock + if (blockType === "OutputBlock") { + return "OutputBlock is automatically created with the graph. Do not add it manually."; + } + + const info: IBlockTypeInfo | undefined = BlockRegistry[blockType]; + if (!info) { + return `Unknown block type "${blockType}". Use list_block_types to see available blocks.`; + } + + const warnings: string[] = []; + + const id = this._nextId.get(graphName)!; + this._nextId.set(graphName, id + 1); + + const name = blockName ?? `${blockType}_${id}`; + + const data: Record = {}; + + // For input blocks, set default values + if (info.isInput) { + const defaults = InputBlockDefaults[blockType]; + if (defaults) { + data["value"] = defaults.defaultValue; + } + } + + // Apply user-supplied properties into data + if (properties) { + for (const [key, value] of Object.entries(properties)) { + // Special handling for block-level properties vs data properties + if (blockType === "BlurBlock" && (key === "blurSize" || key === "blurTextureRatioPerPass")) { + data[key] = value; + } else if (blockType === "DirectionalBlurBlock" && (key === "blurTextureRatio" || key === "blurHorizontalWidth" || key === "blurVerticalWidth")) { + data[key] = value; + } else if (blockType === "CompositionBlock" && key === "alphaMode") { + data[key] = value; + } else { + data[key] = value; + } + } + } + + // Input block: warn if Texture input has no url + if (blockType === "Texture" && !data["url"] && !data["value"]) { + warnings.push(`Texture input "${name}" has no url set. Use set_block_properties to set a url or connect a texture source.`); + } + + const block: ISerializedBlockV1 = { + name, + namespace: info.namespace || null, + uniqueId: id, + blockType, + comments: null, + data, + }; + + graph.blocks.push(block); + return { block, warnings: warnings.length > 0 ? warnings : undefined }; + } + + /** + * Remove a block from a filter graph by its uniqueId. + * @param graphName - The graph name. + * @param blockId - The unique ID of the block to remove. + * @returns "OK" on success, or an error string. + */ + removeBlock(graphName: string, blockId: number): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Filter graph "${graphName}" not found.`; + } + + const idx = graph.blocks.findIndex((b) => b.uniqueId === blockId); + if (idx === -1) { + return `Block ${blockId} not found.`; + } + + const block = graph.blocks[idx]; + if (block.blockType === "OutputBlock") { + return "Cannot remove the OutputBlock — it is required by the graph."; + } + + // Remove any connections involving this block + graph.connections = graph.connections.filter((c) => c.outputBlock !== blockId && c.inputBlock !== blockId); + + graph.blocks.splice(idx, 1); + return "OK"; + } + + /** + * Set properties on a block's data. + * @param graphName - The graph name. + * @param blockId - The unique ID of the block. + * @param properties - Key-value pairs to set. + * @returns "OK" on success, or an error string. + */ + setBlockProperties(graphName: string, blockId: number, properties: Record): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Filter graph "${graphName}" not found.`; + } + + const block = graph.blocks.find((b) => b.uniqueId === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + for (const [key, value] of Object.entries(properties)) { + if (key === "name") { + block.name = String(value); + } else if (key === "comments") { + block.comments = value != null ? String(value) : null; + } else { + block.data[key] = value; + } + } + + return "OK"; + } + + /** + * Get properties of a block. + * @param graphName - The graph name. + * @param blockId - The unique ID of the block. + * @returns A record of block properties, or an error string. + */ + getBlockProperties(graphName: string, blockId: number): Record | string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Filter graph "${graphName}" not found.`; + } + + const block = graph.blocks.find((b) => b.uniqueId === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + return { + name: block.name, + blockType: block.blockType, + namespace: block.namespace, + comments: block.comments, + ...block.data, + }; + } + + // ── Connections ──────────────────────────────────────────────────── + + /** + * Connect an output of one block to an input of another. + * @param graphName - The graph name. + * @param sourceBlockId - The block providing the output. + * @param outputName - The output connection point name. + * @param targetBlockId - The block receiving the input. + * @param inputName - The input connection point name. + * @returns "OK" on success, or an error string. + */ + connectBlocks(graphName: string, sourceBlockId: number, outputName: string, targetBlockId: number, inputName: string): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Filter graph "${graphName}" not found.`; + } + + const sourceBlock = graph.blocks.find((b) => b.uniqueId === sourceBlockId); + if (!sourceBlock) { + return `Source block ${sourceBlockId} not found.`; + } + + const targetBlock = graph.blocks.find((b) => b.uniqueId === targetBlockId); + if (!targetBlock) { + return `Target block ${targetBlockId} not found.`; + } + + const sourceInfo = BlockRegistry[sourceBlock.blockType]; + const targetInfo = BlockRegistry[targetBlock.blockType]; + + if (!sourceInfo) { + return `Unknown block type "${sourceBlock.blockType}" on source block.`; + } + if (!targetInfo) { + return `Unknown block type "${targetBlock.blockType}" on target block.`; + } + + const output = sourceInfo.outputs.find((o) => o.name === outputName); + if (!output) { + const available = sourceInfo.outputs.map((o) => o.name).join(", "); + return `Output "${outputName}" not found on block ${sourceBlockId} ("${sourceBlock.name}"). Available: ${available || "(none)"}`; + } + + const input = targetInfo.inputs.find((i) => i.name === inputName); + if (!input) { + const available = targetInfo.inputs.map((i) => i.name).join(", "); + return `Input "${inputName}" not found on block ${targetBlockId} ("${targetBlock.name}"). Available: ${available || "(none)"}`; + } + + // Type compatibility check + if (output.type !== input.type) { + return `Type mismatch: output "${outputName}" is ${output.type} but input "${inputName}" is ${input.type}.`; + } + + // Cycle detection: check if target is an ancestor of source + if (this._wouldCreateCycle(graph, sourceBlockId, targetBlockId)) { + return `Connection would create a cycle in the graph.`; + } + + // Remove existing connection to this input (inputs can have only one source) + graph.connections = graph.connections.filter((c) => !(c.inputBlock === targetBlockId && c.inputConnectionPoint === inputName)); + + graph.connections.push({ + outputBlock: sourceBlockId, + outputConnectionPoint: outputName, + inputBlock: targetBlockId, + inputConnectionPoint: inputName, + }); + + return "OK"; + } + + /** + * Disconnect an input on a block. + * @param graphName - The graph name. + * @param blockId - The unique ID of the block. + * @param inputName - The input connection point to disconnect. + * @returns "OK" on success, or an error string. + */ + disconnectInput(graphName: string, blockId: number, inputName: string): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Filter graph "${graphName}" not found.`; + } + + const block = graph.blocks.find((b) => b.uniqueId === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + const before = graph.connections.length; + graph.connections = graph.connections.filter((c) => !(c.inputBlock === blockId && c.inputConnectionPoint === inputName)); + + if (graph.connections.length === before) { + return `No connection found on input "${inputName}" of block ${blockId}.`; + } + + return "OK"; + } + + /** + * List all connections in a filter graph. + * @param graphName - The graph name. + * @returns A formatted string listing all connections. + */ + listConnections(graphName: string): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Filter graph "${graphName}" not found.`; + } + + if (graph.connections.length === 0) { + return "No connections."; + } + + const lines: string[] = [`Connections (${graph.connections.length}):`]; + for (const c of graph.connections) { + const src = graph.blocks.find((b) => b.uniqueId === c.outputBlock); + const tgt = graph.blocks.find((b) => b.uniqueId === c.inputBlock); + lines.push(` [${c.outputBlock}] ${src?.name ?? "?"}.${c.outputConnectionPoint} → [${c.inputBlock}] ${tgt?.name ?? "?"}.${c.inputConnectionPoint}`); + } + return lines.join("\n"); + } + + // ── Queries ──────────────────────────────────────────────────────── + + /** + * Get a description of a filter graph. + * @param graphName - The graph name. + * @returns A formatted string describing the graph. + */ + describeGraph(graphName: string): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Filter graph "${graphName}" not found.`; + } + + const lines: string[] = []; + lines.push(`Smart Filter: ${graph.name}`); + if (graph.comments) { + lines.push(`Description: ${graph.comments}`); + } + lines.push(`Blocks (${graph.blocks.length}):`); + + for (const block of graph.blocks) { + lines.push(` [${block.uniqueId}] ${block.name} (${block.blockType})`); + + // Show incoming connections + const incoming = graph.connections.filter((c) => c.inputBlock === block.uniqueId); + for (const c of incoming) { + const src = graph.blocks.find((b) => b.uniqueId === c.outputBlock); + lines.push(` ← ${c.inputConnectionPoint} ← [${c.outputBlock}] ${src?.name ?? "?"}.${c.outputConnectionPoint}`); + } + } + + const outputBlock = graph.blocks.find((b) => b.blockType === "OutputBlock"); + const hasOutputConnection = graph.connections.some((c) => c.inputBlock === outputBlock?.uniqueId); + lines.push(`Output block: ${outputBlock ? `[${outputBlock.uniqueId}]` : "(missing)"} — ${hasOutputConnection ? "connected" : "NOT connected"}`); + lines.push(`Connections: ${graph.connections.length}`); + + return lines.join("\n"); + } + + /** + * Describe a single block in detail. + * @param graphName - The graph name. + * @param blockId - The unique ID of the block. + * @returns A formatted string describing the block. + */ + describeBlock(graphName: string, blockId: number): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Filter graph "${graphName}" not found.`; + } + + const block = graph.blocks.find((b) => b.uniqueId === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + const info = BlockRegistry[block.blockType]; + const lines: string[] = []; + lines.push(`Block [${block.uniqueId}]: "${block.name}" — type ${block.blockType}`); + if (block.namespace) { + lines.push(`Namespace: ${block.namespace}`); + } + if (block.comments) { + lines.push(`Comments: ${block.comments}`); + } + + lines.push("\nInputs:"); + if (info) { + for (const inp of info.inputs) { + const conn = graph.connections.find((c) => c.inputBlock === blockId && c.inputConnectionPoint === inp.name); + if (conn) { + const src = graph.blocks.find((b) => b.uniqueId === conn.outputBlock); + lines.push(` • ${inp.name} (${inp.type}) ← connected to [${conn.outputBlock}] ${src?.name ?? "?"}.${conn.outputConnectionPoint}`); + } else { + lines.push(` • ${inp.name} (${inp.type}) — unconnected${inp.isOptional ? " (optional)" : ""}`); + } + } + } else { + lines.push(" (unknown block type — no port info)"); + } + + lines.push("\nOutputs:"); + if (info) { + for (const out of info.outputs) { + const consumers = graph.connections + .filter((c) => c.outputBlock === blockId && c.outputConnectionPoint === out.name) + .map((c) => { + const tgt = graph.blocks.find((b) => b.uniqueId === c.inputBlock); + return `[${c.inputBlock}] ${tgt?.name ?? "?"}.${c.inputConnectionPoint}`; + }); + if (consumers.length > 0) { + lines.push(` • ${out.name} (${out.type}) → ${consumers.join(", ")}`); + } else { + lines.push(` • ${out.name} (${out.type}) — unconnected`); + } + } + } + + // Show data properties + if (Object.keys(block.data).length > 0) { + lines.push("\nProperties:"); + for (const [k, v] of Object.entries(block.data)) { + lines.push(` ${k}: ${JSON.stringify(v)}`); + } + } + + return lines.join("\n"); + } + + // ── Validation ──────────────────────────────────────────────────── + + /** + * Validate a filter graph and return a list of issues. + * @param graphName - The graph name. + * @returns An array of issue strings. + */ + validateGraph(graphName: string): string[] { + const graph = this._graphs.get(graphName); + if (!graph) { + return [`Filter graph "${graphName}" not found.`]; + } + + const issues: string[] = []; + + // Check OutputBlock exists + const outputBlock = graph.blocks.find((b) => b.blockType === "OutputBlock"); + if (!outputBlock) { + issues.push("ERROR: Missing OutputBlock — every Smart Filter graph needs an OutputBlock."); + } + + // Check that OutputBlock has a connected input + if (outputBlock) { + const outputConnected = graph.connections.some((c) => c.inputBlock === outputBlock.uniqueId); + if (!outputConnected) { + issues.push("ERROR: OutputBlock input is not connected — the filter has no final output. " + "Connect a block's output to the OutputBlock's input."); + } + } + + // Check for unconnected required inputs + for (const block of graph.blocks) { + const info = BlockRegistry[block.blockType]; + if (!info) { + continue; + } + + for (const inp of info.inputs) { + if (inp.isOptional) { + continue; + } + const connected = graph.connections.some((c) => c.inputBlock === block.uniqueId && c.inputConnectionPoint === inp.name); + if (!connected) { + issues.push(`ERROR: Block [${block.uniqueId}] "${block.name}" (${block.blockType}) has required input "${inp.name}" (${inp.type}) that is not connected.`); + } + } + } + + // Check for dangling connection references + const allIds = new Set(graph.blocks.map((b) => b.uniqueId)); + for (const conn of graph.connections) { + if (!allIds.has(conn.outputBlock)) { + issues.push(`ERROR: Connection references non-existent source block ${conn.outputBlock}.`); + } + if (!allIds.has(conn.inputBlock)) { + issues.push(`ERROR: Connection references non-existent target block ${conn.inputBlock}.`); + } + } + + // Check for type mismatches in connections + for (const conn of graph.connections) { + const srcBlock = graph.blocks.find((b) => b.uniqueId === conn.outputBlock); + const tgtBlock = graph.blocks.find((b) => b.uniqueId === conn.inputBlock); + if (!srcBlock || !tgtBlock) { + continue; + } + + const srcInfo = BlockRegistry[srcBlock.blockType]; + const tgtInfo = BlockRegistry[tgtBlock.blockType]; + if (!srcInfo || !tgtInfo) { + continue; + } + + const output = srcInfo.outputs.find((o) => o.name === conn.outputConnectionPoint); + const input = tgtInfo.inputs.find((i) => i.name === conn.inputConnectionPoint); + + if (!output) { + issues.push(`WARNING: Connection uses output "${conn.outputConnectionPoint}" which doesn't exist on block [${srcBlock.uniqueId}] "${srcBlock.name}".`); + } + if (!input) { + issues.push(`WARNING: Connection uses input "${conn.inputConnectionPoint}" which doesn't exist on block [${tgtBlock.uniqueId}] "${tgtBlock.name}".`); + } + if (output && input && output.type !== input.type) { + issues.push( + `WARNING: Type mismatch in connection [${srcBlock.uniqueId}].${conn.outputConnectionPoint} (${output.type}) → [${tgtBlock.uniqueId}].${conn.inputConnectionPoint} (${input.type}).` + ); + } + } + + // Check for orphan blocks (no connections at all) + for (const block of graph.blocks) { + if (block.blockType === "OutputBlock") { + continue; + } + + const hasIncoming = graph.connections.some((c) => c.inputBlock === block.uniqueId); + const hasOutgoing = graph.connections.some((c) => c.outputBlock === block.uniqueId); + + if (!hasIncoming && !hasOutgoing) { + issues.push(`WARNING: Block [${block.uniqueId}] "${block.name}" (${block.blockType}) has no connections — it is an orphan.`); + } + } + + // Check for unreachable blocks (not in the path to OutputBlock) + if (outputBlock) { + const reachable = new Set(); + const visit = (blockId: number) => { + if (reachable.has(blockId)) { + return; + } + reachable.add(blockId); + for (const conn of graph.connections) { + if (conn.inputBlock === blockId) { + visit(conn.outputBlock); + } + } + }; + visit(outputBlock.uniqueId); + + for (const block of graph.blocks) { + if (block.blockType === "OutputBlock") { + continue; + } + if (!reachable.has(block.uniqueId)) { + const hasAnyConn = graph.connections.some((c) => c.outputBlock === block.uniqueId || c.inputBlock === block.uniqueId); + if (hasAnyConn) { + issues.push(`WARNING: Block [${block.uniqueId}] "${block.name}" (${block.blockType}) is connected but not reachable from the OutputBlock.`); + } + } + } + } + + if (issues.length === 0) { + issues.push("No issues found — graph looks valid."); + } + + return issues; + } + + // ── Search ──────────────────────────────────────────────────────── + + /** + * Find blocks in a graph matching a search string. + * @param graphName - The graph name. + * @param query - The search string to match against block names, types, or namespaces. + * @returns A formatted string listing matching blocks. + */ + findBlocks(graphName: string, query: string): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Filter graph "${graphName}" not found.`; + } + + const q = query.toLowerCase(); + const matches = graph.blocks.filter((b) => b.name.toLowerCase().includes(q) || b.blockType.toLowerCase().includes(q) || (b.namespace ?? "").toLowerCase().includes(q)); + + if (matches.length === 0) { + return `No blocks matching "${query}" found.`; + } + + const lines: string[] = [`Found ${matches.length} block(s) matching "${query}":`]; + for (const b of matches) { + lines.push(` [${b.uniqueId}] ${b.name} (${b.blockType})`); + } + return lines.join("\n"); + } + + // ── Serialisation ───────────────────────────────────────────────── + + /** + * Export to the Smart Filter V1 JSON format. + * @param graphName - The graph name. + * @returns The JSON string, or undefined if the graph is not found. + */ + exportJSON(graphName: string): string | undefined { + const graph = this._graphs.get(graphName); + if (!graph) { + return undefined; + } + + // Compute a simple layout for editor display + this._layoutGraph(graph); + + return JSON.stringify(graph, null, 2); + } + + /** + * Import a Smart Filter V1 JSON string. + * @param graphName - The name for the imported graph. + * @param json - The JSON string to import. + * @returns "OK" on success, or an error string. + */ + importJSON(graphName: string, json: string): string { + try { + const parsed = JSON.parse(json) as ISerializedSmartFilterV1; + + // Basic format validation + if (parsed.format !== "smartFilter" || parsed.formatVersion !== 1) { + return `Invalid format: expected format="smartFilter" formatVersion=1, got format="${parsed.format}" formatVersion=${parsed.formatVersion}.`; + } + + if (!Array.isArray(parsed.blocks) || !Array.isArray(parsed.connections)) { + return `Invalid format: blocks and connections must be arrays.`; + } + + parsed.name = graphName; + this._graphs.set(graphName, parsed); + + const maxId = parsed.blocks.reduce((max, b) => Math.max(max, b.uniqueId), 0); + this._nextId.set(graphName, maxId + 1); + + return "OK"; + } catch (e) { + return `Failed to parse JSON: ${(e as Error).message}`; + } + } + + // ── Private helpers ─────────────────────────────────────────────── + + /** + * Check if connecting sourceBlock → targetBlock would create a cycle. + * @param graph - The graph to check. + * @param sourceBlockId - The source block ID. + * @param targetBlockId - The target block ID. + * @returns True if a cycle would be created. + */ + private _wouldCreateCycle(graph: ISerializedSmartFilterV1, sourceBlockId: number, targetBlockId: number): boolean { + // If source == target, it's a self-loop + if (sourceBlockId === targetBlockId) { + return true; + } + + // BFS from target, following outgoing edges — if we reach source, there's a cycle + const visited = new Set(); + const queue: number[] = [targetBlockId]; + + while (queue.length > 0) { + const current = queue.shift()!; + if (current === sourceBlockId) { + return true; + } + if (visited.has(current)) { + continue; + } + visited.add(current); + + // Follow outgoing connections from this block + for (const conn of graph.connections) { + if (conn.outputBlock === current) { + queue.push(conn.inputBlock); + } + } + } + + return false; + } + + /** Horizontal spacing between columns in the editor (px). */ + private static readonly COL_WIDTH = 340; + /** Vertical spacing between blocks within a column (px). */ + private static readonly ROW_HEIGHT = 180; + + /** + * Compute a layered graph layout and write it into editorData. + * @param graph - The graph to layout. + */ + private _layoutGraph(graph: ISerializedSmartFilterV1): void { + const blocks = graph.blocks; + if (blocks.length === 0) { + return; + } + + // Build adjacency + const predecessors = new Map>(); + const successors = new Map>(); + for (const b of blocks) { + predecessors.set(b.uniqueId, new Set()); + successors.set(b.uniqueId, new Set()); + } + for (const c of graph.connections) { + predecessors.get(c.inputBlock)?.add(c.outputBlock); + successors.get(c.outputBlock)?.add(c.inputBlock); + } + + // Longest-path depth from OutputBlock + const depth = new Map(); + const queue: number[] = []; + + const outputBlock = blocks.find((b) => b.blockType === "OutputBlock"); + if (outputBlock) { + depth.set(outputBlock.uniqueId, 0); + queue.push(outputBlock.uniqueId); + } else if (blocks.length > 0) { + depth.set(blocks[blocks.length - 1].uniqueId, 0); + queue.push(blocks[blocks.length - 1].uniqueId); + } + + let head = 0; + while (head < queue.length) { + const id = queue[head++]; + const d = depth.get(id)!; + for (const predId of predecessors.get(id) ?? []) { + const existing = depth.get(predId); + if (existing === undefined || d + 1 > existing) { + depth.set(predId, d + 1); + queue.push(predId); + } + } + } + + // Disconnected blocks + const maxDepth = Math.max(0, ...depth.values()); + for (const b of blocks) { + if (!depth.has(b.uniqueId)) { + depth.set(b.uniqueId, maxDepth + 1); + } + } + + // Reverse so inputs are on the left + const totalMaxDepth = Math.max(0, ...depth.values()); + const column = new Map(); + for (const [id, d] of depth) { + column.set(id, totalMaxDepth - d); + } + + // Group blocks by column + const columns = new Map(); + for (const b of blocks) { + const col = column.get(b.uniqueId)!; + if (!columns.has(col)) { + columns.set(col, []); + } + columns.get(col)!.push(b.uniqueId); + } + + // Assign positions + const locations: Array<{ blockId: number; x: number; y: number }> = []; + for (const [col, blockIds] of columns) { + blockIds.forEach((id, row) => { + locations.push({ + blockId: id, + x: col * SmartFiltersGraphManager.COL_WIDTH, + y: row * SmartFiltersGraphManager.ROW_HEIGHT, + }); + }); + } + + graph.editorData = { locations }; + } +} diff --git a/packages/tools/smart-filters-mcp-server/test/unit/generateExamples.test.ts b/packages/tools/smart-filters-mcp-server/test/unit/generateExamples.test.ts new file mode 100644 index 00000000000..120ca710123 --- /dev/null +++ b/packages/tools/smart-filters-mcp-server/test/unit/generateExamples.test.ts @@ -0,0 +1,134 @@ +/** + * Smart Filters MCP Server – Example Smart Filter Generator + * + * Builds several reference Smart Filter graphs via the SmartFiltersGraphManager + * API, validates them, and writes them to the examples/ directory. + * + * Run: npx ts-node --esm test/unit/generateExamples.ts + * Or simply include as a test file – Jest will run it and the examples are + * written to disk as a side effect. + */ + +import * as fs from "fs"; +import * as path from "path"; +import { SmartFiltersGraphManager } from "../../src/smartFiltersGraph"; + +const EXAMPLES_DIR = path.resolve(__dirname, "../../examples"); + +function writeExample(name: string, json: string): void { + fs.mkdirSync(EXAMPLES_DIR, { recursive: true }); + const filePath = path.join(EXAMPLES_DIR, `${name}.json`); + fs.writeFileSync(filePath, json, "utf-8"); +} + +function blockId(result: ReturnType): number { + if (typeof result === "string") { + throw new Error(result); + } + return (result as any).block.uniqueId; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 1 – Black and White +// The simplest filter: Texture → BlackAndWhiteBlock → OutputBlock +// ═══════════════════════════════════════════════════════════════════════════ + +function buildBlackAndWhite(): string { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("BlackAndWhite"); + + const texId = blockId(mgr.addBlock("BlackAndWhite", "Texture", "source")); + const bwId = blockId(mgr.addBlock("BlackAndWhite", "BlackAndWhiteBlock", "bwEffect")); + + expect(mgr.connectBlocks("BlackAndWhite", texId, "output", bwId, "input")).toBe("OK"); + expect(mgr.connectBlocks("BlackAndWhite", bwId, "output", 1, "input")).toBe("OK"); + + const issues = mgr.validateGraph("BlackAndWhite"); + expect(issues).toEqual(["No issues found — graph looks valid."]); + + const json = mgr.exportJSON("BlackAndWhite"); + expect(json).toBeDefined(); + return json!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 2 – Blur Chain +// Texture → BlurBlock → OutputBlock with custom blur size. +// ═══════════════════════════════════════════════════════════════════════════ + +function buildBlurChain(): string { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("BlurChain"); + + const texId = blockId(mgr.addBlock("BlurChain", "Texture", "source")); + const blurId = blockId(mgr.addBlock("BlurChain", "BlurBlock", "blur", { blurSize: 8 })); + + expect(mgr.connectBlocks("BlurChain", texId, "output", blurId, "input")).toBe("OK"); + expect(mgr.connectBlocks("BlurChain", blurId, "output", 1, "input")).toBe("OK"); + + const issues = mgr.validateGraph("BlurChain"); + expect(issues).toEqual(["No issues found — graph looks valid."]); + + const json = mgr.exportJSON("BlurChain"); + expect(json).toBeDefined(); + return json!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Example 3 – Contrast with Intensity +// Texture → ContrastBlock (with Float input for intensity) → OutputBlock +// ═══════════════════════════════════════════════════════════════════════════ + +function buildContrastFilter(): string { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("ContrastFilter"); + + const texId = blockId(mgr.addBlock("ContrastFilter", "Texture", "source")); + const intensityId = blockId(mgr.addBlock("ContrastFilter", "Float", "intensity")); + mgr.setBlockProperties("ContrastFilter", intensityId, { value: 1.5 }); + + const contrastId = blockId(mgr.addBlock("ContrastFilter", "ContrastBlock", "contrast")); + + expect(mgr.connectBlocks("ContrastFilter", texId, "output", contrastId, "input")).toBe("OK"); + expect(mgr.connectBlocks("ContrastFilter", intensityId, "output", contrastId, "intensity")).toBe("OK"); + expect(mgr.connectBlocks("ContrastFilter", contrastId, "output", 1, "input")).toBe("OK"); + + const issues = mgr.validateGraph("ContrastFilter"); + expect(issues).toEqual(["No issues found — graph looks valid."]); + + const json = mgr.exportJSON("ContrastFilter"); + expect(json).toBeDefined(); + return json!; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Jest Test Wrapper +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Smart Filters MCP Server – Example Generation", () => { + it("generates BlackAndWhite example", () => { + const json = buildBlackAndWhite(); + const parsed = JSON.parse(json); + expect(parsed.format).toBe("smartFilter"); + expect(parsed.formatVersion).toBe(1); + expect(parsed.blocks.length).toBe(3); // OutputBlock + Texture + BlackAndWhiteBlock + expect(parsed.connections.length).toBe(2); + writeExample("BlackAndWhite", json); + }); + + it("generates BlurChain example", () => { + const json = buildBlurChain(); + const parsed = JSON.parse(json); + expect(parsed.blocks.length).toBe(3); + expect(parsed.connections.length).toBe(2); + writeExample("BlurChain", json); + }); + + it("generates ContrastFilter example", () => { + const json = buildContrastFilter(); + const parsed = JSON.parse(json); + expect(parsed.blocks.length).toBe(4); // OutputBlock + Texture + Float + ContrastBlock + expect(parsed.connections.length).toBe(3); + writeExample("ContrastFilter", json); + }); +}); diff --git a/packages/tools/smart-filters-mcp-server/test/unit/graphManager.test.ts b/packages/tools/smart-filters-mcp-server/test/unit/graphManager.test.ts new file mode 100644 index 00000000000..5bb2e5e223b --- /dev/null +++ b/packages/tools/smart-filters-mcp-server/test/unit/graphManager.test.ts @@ -0,0 +1,560 @@ +/** + * Smart Filters MCP Server – Graph Manager Tests + * + * Creates Smart Filter graphs via SmartFiltersGraphManager, exports them to JSON, + * validates the JSON structure, and exercises core operations. + */ + +import { SmartFiltersGraphManager } from "../../src/smartFiltersGraph"; +import { BlockRegistry } from "../../src/blockRegistry"; + +// ─── Test Helpers ───────────────────────────────────────────────────────── + +function validateSmartFilterJSON(json: string, label: string): any { + let parsed: any; + try { + parsed = JSON.parse(json); + } catch { + throw new Error(`${label}: invalid JSON`); + } + + expect(parsed.format).toBe("smartFilter"); + expect(parsed.formatVersion).toBe(1); + expect(Array.isArray(parsed.blocks)).toBe(true); + expect(Array.isArray(parsed.connections)).toBe(true); + + const allIds = new Set(parsed.blocks.map((b: any) => b.uniqueId)); + for (const block of parsed.blocks) { + expect(typeof block.blockType).toBe("string"); + expect(typeof block.uniqueId).toBe("number"); + expect(typeof block.name).toBe("string"); + } + + // Validate connections reference existing block IDs + for (const conn of parsed.connections) { + expect(allIds.has(conn.outputBlock)).toBe(true); + expect(allIds.has(conn.inputBlock)).toBe(true); + expect(typeof conn.outputConnectionPoint).toBe("string"); + expect(typeof conn.inputConnectionPoint).toBe("string"); + } + + return parsed; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Smart Filters MCP Server – Graph Manager", () => { + // ── Test 1: Simple filter graph ───────────────────────────────────── + + it("creates and exports a simple black-and-white filter with valid JSON", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("bw"); + + const texture = mgr.addBlock("bw", "Texture", "source"); + expect(typeof texture).not.toBe("string"); + const textureBlock = (texture as any).block; + + const bw = mgr.addBlock("bw", "BlackAndWhiteBlock", "bwEffect"); + expect(typeof bw).not.toBe("string"); + const bwBlock = (bw as any).block; + + // Connect: source.output → bwEffect.input + const conn1 = mgr.connectBlocks("bw", textureBlock.uniqueId, "output", bwBlock.uniqueId, "input"); + expect(conn1).toBe("OK"); + + // Connect: bwEffect.output → OutputBlock.input (OutputBlock is uniqueId=1) + const conn2 = mgr.connectBlocks("bw", bwBlock.uniqueId, "output", 1, "input"); + expect(conn2).toBe("OK"); + + const json = mgr.exportJSON("bw"); + expect(json).toBeDefined(); + const parsed = validateSmartFilterJSON(json!, "bw"); + + // OutputBlock + Texture + BlackAndWhiteBlock = 3 blocks + expect(parsed.blocks.length).toBe(3); + expect(parsed.connections.length).toBe(2); + + // Should have an OutputBlock + const outputBlocks = parsed.blocks.filter((b: any) => b.blockType === "OutputBlock"); + expect(outputBlocks.length).toBe(1); + }); + + // ── Test 2: Lifecycle operations ──────────────────────────────────── + + it("supports create, list, delete, clone lifecycle", () => { + const mgr = new SmartFiltersGraphManager(); + 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); + + // Clone + mgr.addBlock("b", "Texture", "src"); + const cloneResult = mgr.cloneGraph("b", "c"); + expect(typeof cloneResult).not.toBe("string"); + + const cGraph = mgr.getGraph("c"); + expect(cGraph).toBeDefined(); + // 2 blocks: OutputBlock + Texture + expect(cGraph!.blocks.length).toBe(2); + }); + + // ── Test 3: clearAll ──────────────────────────────────────────────── + + it("clearAll removes all graphs", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("x"); + mgr.createGraph("y"); + mgr.clearAll(); + expect(mgr.listGraphs()).toEqual([]); + }); + + // ── Test 4: Block add/remove ──────────────────────────────────────── + + it("adds and removes blocks correctly", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + const result = mgr.addBlock("test", "BlurBlock", "blur1", { blurSize: 4 }); + expect(typeof result).not.toBe("string"); + const block = (result as any).block; + expect(block.blockType).toBe("BlurBlock"); + expect(block.data.blurSize).toBe(4); + + // Remove it + const rmResult = mgr.removeBlock("test", block.uniqueId); + expect(rmResult).toBe("OK"); + + // Should only have OutputBlock + const graph = mgr.getGraph("test")!; + expect(graph.blocks.length).toBe(1); + expect(graph.blocks[0].blockType).toBe("OutputBlock"); + }); + + // ── Test 5: Cannot remove OutputBlock ─────────────────────────────── + + it("prevents removing OutputBlock", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + const result = mgr.removeBlock("test", 1); + expect(result).toContain("Cannot remove the OutputBlock"); + }); + + // ── Test 6: Cannot add second OutputBlock ─────────────────────────── + + it("prevents adding a second OutputBlock", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + const result = mgr.addBlock("test", "OutputBlock", "out2"); + expect(typeof result).toBe("string"); + expect(result).toContain("automatically created"); + }); + + // ── Test 7: Connection validation ─────────────────────────────────── + + it("validates connections: type mismatch", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + const floatInput = mgr.addBlock("test", "Float", "myFloat"); + expect(typeof floatInput).not.toBe("string"); + const floatBlock = (floatInput as any).block; + + const bw = mgr.addBlock("test", "BlackAndWhiteBlock", "bw"); + expect(typeof bw).not.toBe("string"); + const bwBlock = (bw as any).block; + + // Float output → Texture input should fail (type mismatch) + const result = mgr.connectBlocks("test", floatBlock.uniqueId, "output", bwBlock.uniqueId, "input"); + expect(result).toContain("Type mismatch"); + }); + + // ── Test 8: Connection validation: cycle detection ────────────────── + + it("detects cycles in connections", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + const tex = mgr.addBlock("test", "Texture", "src"); + const bw = mgr.addBlock("test", "BlackAndWhiteBlock", "bw"); + const contrast = mgr.addBlock("test", "ContrastBlock", "contrast"); + const intensityInput = mgr.addBlock("test", "Float", "intensity"); + + const texBlock = (tex as any).block; + const bwBlock = (bw as any).block; + const contrastBlock = (contrast as any).block; + const intBlock = (intensityInput as any).block; + + // tex → bw → contrast → OutputBlock + expect(mgr.connectBlocks("test", texBlock.uniqueId, "output", bwBlock.uniqueId, "input")).toBe("OK"); + expect(mgr.connectBlocks("test", bwBlock.uniqueId, "output", contrastBlock.uniqueId, "input")).toBe("OK"); + expect(mgr.connectBlocks("test", intBlock.uniqueId, "output", contrastBlock.uniqueId, "intensity")).toBe("OK"); + + // Now try to create a cycle: contrast.output → bw.input (bw already feeds contrast) + const cycleResult = mgr.connectBlocks("test", contrastBlock.uniqueId, "output", bwBlock.uniqueId, "input"); + expect(cycleResult).toContain("cycle"); + }); + + // ── Test 9: Connection validation: invalid port names ─────────────── + + it("rejects invalid port names", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + const tex = mgr.addBlock("test", "Texture", "src"); + const bw = mgr.addBlock("test", "BlackAndWhiteBlock", "bw"); + const texBlock = (tex as any).block; + const bwBlock = (bw as any).block; + + const result1 = mgr.connectBlocks("test", texBlock.uniqueId, "nonexistent", bwBlock.uniqueId, "input"); + expect(result1).toContain("not found"); + + const result2 = mgr.connectBlocks("test", texBlock.uniqueId, "output", bwBlock.uniqueId, "nonexistent"); + expect(result2).toContain("not found"); + }); + + // ── Test 10: Property setting ─────────────────────────────────────── + + it("sets block properties correctly", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + const result = mgr.addBlock("test", "Float", "intensity"); + expect(typeof result).not.toBe("string"); + const block = (result as any).block; + + const setResult = mgr.setBlockProperties("test", block.uniqueId, { value: 0.75 }); + expect(setResult).toBe("OK"); + + const props = mgr.getBlockProperties("test", block.uniqueId); + expect(typeof props).not.toBe("string"); + expect((props as any).value).toBe(0.75); + }); + + // ── Test 11: Validation – missing OutputBlock connection ──────────── + + it("validation reports unconnected OutputBlock", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + // Just the auto-created OutputBlock with nothing connected + const issues = mgr.validateGraph("test"); + expect(issues.some((i) => i.includes("OutputBlock input is not connected"))).toBe(true); + }); + + // ── Test 12: Validation – valid graph passes ──────────────────────── + + it("valid graph passes validation", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + const tex = mgr.addBlock("test", "Texture", "src"); + const bw = mgr.addBlock("test", "BlackAndWhiteBlock", "bw"); + const texBlock = (tex as any).block; + const bwBlock = (bw as any).block; + + mgr.connectBlocks("test", texBlock.uniqueId, "output", bwBlock.uniqueId, "input"); + mgr.connectBlocks("test", bwBlock.uniqueId, "output", 1, "input"); + + const issues = mgr.validateGraph("test"); + expect(issues).toEqual(["No issues found — graph looks valid."]); + }); + + // ── Test 13: Validation – orphan blocks ───────────────────────────── + + it("validation reports orphan blocks", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + const tex = mgr.addBlock("test", "Texture", "src"); + const bw = mgr.addBlock("test", "BlackAndWhiteBlock", "bw"); + mgr.addBlock("test", "BlurBlock", "orphan"); + const texBlock = (tex as any).block; + const bwBlock = (bw as any).block; + + mgr.connectBlocks("test", texBlock.uniqueId, "output", bwBlock.uniqueId, "input"); + mgr.connectBlocks("test", bwBlock.uniqueId, "output", 1, "input"); + + const issues = mgr.validateGraph("test"); + expect(issues.some((i) => i.includes("orphan"))).toBe(true); + }); + + // ── Test 14: Import/export round-trip ─────────────────────────────── + + it("supports import/export round-trip", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("original"); + + const tex = mgr.addBlock("original", "Texture", "src"); + const bw = mgr.addBlock("original", "BlackAndWhiteBlock", "bw"); + const texBlock = (tex as any).block; + const bwBlock = (bw as any).block; + + mgr.connectBlocks("original", texBlock.uniqueId, "output", bwBlock.uniqueId, "input"); + mgr.connectBlocks("original", bwBlock.uniqueId, "output", 1, "input"); + + const json = mgr.exportJSON("original"); + expect(json).toBeDefined(); + + // Import into a new graph + const importResult = mgr.importJSON("imported", json!); + expect(importResult).toBe("OK"); + + // Re-export and compare structure + const json2 = mgr.exportJSON("imported"); + expect(json2).toBeDefined(); + const parsed1 = JSON.parse(json!); + const parsed2 = JSON.parse(json2!); + + expect(parsed2.blocks.length).toBe(parsed1.blocks.length); + expect(parsed2.connections.length).toBe(parsed1.connections.length); + expect(parsed2.format).toBe("smartFilter"); + expect(parsed2.formatVersion).toBe(1); + }); + + // ── Test 15: Import validation ────────────────────────────────────── + + it("rejects invalid import JSON", () => { + const mgr = new SmartFiltersGraphManager(); + + const result1 = mgr.importJSON("bad", "not json"); + expect(result1).toContain("Failed to parse"); + + const result2 = mgr.importJSON("bad", JSON.stringify({ format: "wrong", formatVersion: 1, blocks: [], connections: [] })); + expect(result2).toContain("Invalid format"); + + const result3 = mgr.importJSON("bad", JSON.stringify({ format: "smartFilter", formatVersion: 2, blocks: [], connections: [] })); + expect(result3).toContain("Invalid format"); + }); + + // ── Test 16: Disconnect ───────────────────────────────────────────── + + it("disconnects inputs correctly", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + const tex = mgr.addBlock("test", "Texture", "src"); + const bw = mgr.addBlock("test", "BlackAndWhiteBlock", "bw"); + const texBlock = (tex as any).block; + const bwBlock = (bw as any).block; + + mgr.connectBlocks("test", texBlock.uniqueId, "output", bwBlock.uniqueId, "input"); + + const graph = mgr.getGraph("test")!; + expect(graph.connections.length).toBe(1); + + const discResult = mgr.disconnectInput("test", bwBlock.uniqueId, "input"); + expect(discResult).toBe("OK"); + expect(graph.connections.length).toBe(0); + }); + + // ── Test 17: Describe graph ───────────────────────────────────────── + + it("describes graph with useful info", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test", "A test filter"); + + mgr.addBlock("test", "Texture", "src"); + + const desc = mgr.describeGraph("test"); + expect(desc).toContain("Smart Filter: test"); + expect(desc).toContain("A test filter"); + expect(desc).toContain("OutputBlock"); + expect(desc).toContain("src"); + }); + + // ── Test 18: Describe block ───────────────────────────────────────── + + it("describes block with ports and properties", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + const result = mgr.addBlock("test", "BlurBlock", "blur1", { blurSize: 3 }); + const block = (result as any).block; + + const desc = mgr.describeBlock("test", block.uniqueId); + expect(desc).toContain("blur1"); + expect(desc).toContain("BlurBlock"); + expect(desc).toContain("input"); + expect(desc).toContain("output"); + expect(desc).toContain("blurSize"); + }); + + // ── Test 19: Find blocks ──────────────────────────────────────────── + + it("finds blocks by name/type/namespace substring", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + mgr.addBlock("test", "Texture", "videoSource"); + mgr.addBlock("test", "BlackAndWhiteBlock", "bwEffect"); + mgr.addBlock("test", "BlurBlock", "blurEffect"); + + const result1 = mgr.findBlocks("test", "effect"); + expect(result1).toContain("bwEffect"); + expect(result1).toContain("blurEffect"); + + const result2 = mgr.findBlocks("test", "Texture"); + expect(result2).toContain("videoSource"); + + const result3 = mgr.findBlocks("test", "nonexistent"); + expect(result3).toContain("No blocks matching"); + }); + + // ── Test 20: List connections ──────────────────────────────────────── + + it("lists connections", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + const tex = mgr.addBlock("test", "Texture", "src"); + const bw = mgr.addBlock("test", "BlackAndWhiteBlock", "bw"); + const texBlock = (tex as any).block; + const bwBlock = (bw as any).block; + + mgr.connectBlocks("test", texBlock.uniqueId, "output", bwBlock.uniqueId, "input"); + + const conns = mgr.listConnections("test"); + expect(conns).toContain("src.output"); + expect(conns).toContain("bw.input"); + }); + + // ── Test 21: Complex multi-block filter ───────────────────────────── + + it("builds a complex multi-block filter chain", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("complex"); + + // Texture → Desaturate → Blur → Contrast → OutputBlock + const tex = mgr.addBlock("complex", "Texture", "source"); + const desatAmount = mgr.addBlock("complex", "Float", "desatIntensity", { value: 0.6 }); + const desat = mgr.addBlock("complex", "DesaturateBlock", "desaturate"); + const blur = mgr.addBlock("complex", "BlurBlock", "blur", { blurSize: 3 }); + const contrastAmount = mgr.addBlock("complex", "Float", "contrastIntensity", { value: 0.8 }); + const contrast = mgr.addBlock("complex", "ContrastBlock", "contrast"); + + const texId = (tex as any).block.uniqueId; + const desatAmountId = (desatAmount as any).block.uniqueId; + const desatId = (desat as any).block.uniqueId; + const blurId = (blur as any).block.uniqueId; + const contrastAmountId = (contrastAmount as any).block.uniqueId; + const contrastId = (contrast as any).block.uniqueId; + + expect(mgr.connectBlocks("complex", texId, "output", desatId, "input")).toBe("OK"); + expect(mgr.connectBlocks("complex", desatAmountId, "output", desatId, "intensity")).toBe("OK"); + expect(mgr.connectBlocks("complex", desatId, "output", blurId, "input")).toBe("OK"); + expect(mgr.connectBlocks("complex", blurId, "output", contrastId, "input")).toBe("OK"); + expect(mgr.connectBlocks("complex", contrastAmountId, "output", contrastId, "intensity")).toBe("OK"); + expect(mgr.connectBlocks("complex", contrastId, "output", 1, "input")).toBe("OK"); + + // Validate + const issues = mgr.validateGraph("complex"); + expect(issues).toEqual(["No issues found — graph looks valid."]); + + // Export + const json = mgr.exportJSON("complex"); + expect(json).toBeDefined(); + const parsed = validateSmartFilterJSON(json!, "complex"); + + // 1 OutputBlock + 1 Texture + 2 Float + 3 effects = 7 + expect(parsed.blocks.length).toBe(7); + expect(parsed.connections.length).toBe(6); + }); + + // ── Test 22: Block registry completeness ──────────────────────────── + + it("block registry has expected blocks", () => { + expect(BlockRegistry.BlackAndWhiteBlock).toBeDefined(); + expect(BlockRegistry.BlurBlock).toBeDefined(); + expect(BlockRegistry.ContrastBlock).toBeDefined(); + expect(BlockRegistry.DesaturateBlock).toBeDefined(); + expect(BlockRegistry.ExposureBlock).toBeDefined(); + expect(BlockRegistry.GreenScreenBlock).toBeDefined(); + expect(BlockRegistry.KaleidoscopeBlock).toBeDefined(); + expect(BlockRegistry.MaskBlock).toBeDefined(); + expect(BlockRegistry.PixelateBlock).toBeDefined(); + expect(BlockRegistry.PosterizeBlock).toBeDefined(); + expect(BlockRegistry.SpritesheetBlock).toBeDefined(); + expect(BlockRegistry.CompositionBlock).toBeDefined(); + expect(BlockRegistry.TintBlock).toBeDefined(); + expect(BlockRegistry.WipeBlock).toBeDefined(); + expect(BlockRegistry.PremultiplyAlphaBlock).toBeDefined(); + expect(BlockRegistry.DirectionalBlurBlock).toBeDefined(); + expect(BlockRegistry.Float).toBeDefined(); + expect(BlockRegistry.Color3).toBeDefined(); + expect(BlockRegistry.Color4).toBeDefined(); + expect(BlockRegistry.Texture).toBeDefined(); + expect(BlockRegistry.Vector2).toBeDefined(); + expect(BlockRegistry.Boolean).toBeDefined(); + expect(BlockRegistry.OutputBlock).toBeDefined(); + }); + + // ── Test 23: Overwrite connection ─────────────────────────────────── + + it("overwrites existing connection to same input", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + const tex1 = mgr.addBlock("test", "Texture", "source1"); + const tex2 = mgr.addBlock("test", "Texture", "source2"); + const bw = mgr.addBlock("test", "BlackAndWhiteBlock", "bw"); + + const tex1Id = (tex1 as any).block.uniqueId; + const tex2Id = (tex2 as any).block.uniqueId; + const bwId = (bw as any).block.uniqueId; + + // Connect source1 → bw + expect(mgr.connectBlocks("test", tex1Id, "output", bwId, "input")).toBe("OK"); + let graph = mgr.getGraph("test")!; + expect(graph.connections.length).toBe(1); + expect(graph.connections[0].outputBlock).toBe(tex1Id); + + // Overwrite with source2 → bw + expect(mgr.connectBlocks("test", tex2Id, "output", bwId, "input")).toBe("OK"); + graph = mgr.getGraph("test")!; + expect(graph.connections.length).toBe(1); + expect(graph.connections[0].outputBlock).toBe(tex2Id); + }); + + // ── Test 24: Self-loop detection ──────────────────────────────────── + + it("prevents self-loop connections", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + const bw = mgr.addBlock("test", "BlackAndWhiteBlock", "bw"); + const bwId = (bw as any).block.uniqueId; + + const result = mgr.connectBlocks("test", bwId, "output", bwId, "input"); + expect(result).toContain("cycle"); + }); + + // ── Test 25: Remove block cleans connections ──────────────────────── + + it("removing a block cleans up its connections", () => { + const mgr = new SmartFiltersGraphManager(); + mgr.createGraph("test"); + + const tex = mgr.addBlock("test", "Texture", "src"); + const bw = mgr.addBlock("test", "BlackAndWhiteBlock", "bw"); + const texId = (tex as any).block.uniqueId; + const bwId = (bw as any).block.uniqueId; + + mgr.connectBlocks("test", texId, "output", bwId, "input"); + mgr.connectBlocks("test", bwId, "output", 1, "input"); + + const graph = mgr.getGraph("test")!; + expect(graph.connections.length).toBe(2); + + mgr.removeBlock("test", bwId); + expect(graph.connections.length).toBe(0); + }); +}); diff --git a/packages/tools/smart-filters-mcp-server/tsconfig.json b/packages/tools/smart-filters-mcp-server/tsconfig.json new file mode 100644 index 00000000000..2c6df59671a --- /dev/null +++ b/packages/tools/smart-filters-mcp-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "types": ["node"], + "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/smartFiltersEditorControl/src/components/mcpSession/mcpSessionComponent.tsx b/packages/tools/smartFiltersEditorControl/src/components/mcpSession/mcpSessionComponent.tsx new file mode 100644 index 00000000000..93a6085e164 --- /dev/null +++ b/packages/tools/smartFiltersEditorControl/src/components/mcpSession/mcpSessionComponent.tsx @@ -0,0 +1,188 @@ +import { type FunctionComponent, useCallback, useEffect, useState } from "react"; +import { type GlobalState } from "../../globalState.js"; +import { LogEntry, LogLevel } from "../log/logComponent.js"; +import { LineContainerComponent } from "../../sharedComponents/lineContainerComponent.js"; +import { ButtonLineComponent } from "shared-ui-components/lines/buttonLineComponent"; +import { TextInputLineComponent } from "shared-ui-components/lines/textInputLineComponent"; +import { TextLineComponent } from "shared-ui-components/lines/textLineComponent"; +import { + CloseMcpEditorSessionEventSource, + NormalizeMcpEditorSessionUrl, + OpenMcpEditorSessionEventSource, + PostMcpEditorSessionDocumentAsync, +} from "shared-ui-components/mcp/mcpEditorSessionConnection"; + +interface IMcpSessionComponentProps { + globalState: GlobalState; +} + +/** + * Panel that connects to a live MCP session for bidirectional Smart Filter sync. + * @param props - Component props. + * @returns The React element. + */ +export const McpSessionComponent: FunctionComponent = (props) => { + const { globalState } = props; + const [url, setUrl] = useState(globalState.mcpSessionUrl ?? ""); + const [connected, setConnected] = useState(globalState.mcpSessionConnected); + + useEffect(() => { + const observer = globalState.onMcpSessionStateChangedObservable.add((state) => { + setConnected(state); + }); + setConnected(globalState.mcpSessionConnected); + if (globalState.mcpSessionUrl) { + setUrl(globalState.mcpSessionUrl); + } + return () => { + globalState.onMcpSessionStateChangedObservable.remove(observer); + }; + }, [globalState]); + + const logError = useCallback( + (message: string) => { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(message, LogLevel.Error)); + }, + [globalState] + ); + + const loadSmartFilterFromJson = useCallback( + (json: unknown) => { + void (async () => { + if (!globalState.pasteSmartFilterFromStringAsync) { + logError("MCP Session: Loading is not available in this host."); + return; + } + + try { + if (await globalState.pasteSmartFilterFromStringAsync(JSON.stringify(json))) { + globalState.stateManager.onSelectionChangedObservable.notifyObservers(null); + globalState.onClearUndoStack.notifyObservers(); + } + } catch (err) { + logError(`MCP Session: Load failed - ${err}`); + } + })(); + }, + [globalState, logError] + ); + + const pushSmartFilterAsync = useCallback( + async (sessionUrl: string) => { + if (!globalState.copySmartFilterToStringAsync) { + logError("MCP Session: Pushing is not available in this host."); + return; + } + + globalState.onSaveEditorDataRequiredObservable.notifyObservers(); + const serializedSmartFilter = await globalState.copySmartFilterToStringAsync(); + const res = await PostMcpEditorSessionDocumentAsync(sessionUrl, serializedSmartFilter); + if (!res.ok) { + logError(`MCP Session: Push failed (${res.status})`); + } + }, + [globalState, logError] + ); + + const handleConnect = useCallback( + async (pushOnConnect: boolean = false) => { + const sessionUrl = NormalizeMcpEditorSessionUrl(url); + if (!sessionUrl) { + return; + } + + try { + if (pushOnConnect) { + await pushSmartFilterAsync(sessionUrl); + } + + CloseMcpEditorSessionEventSource(globalState.mcpEventSource); + globalState.mcpEventSource = null; + + const eventSource = OpenMcpEditorSessionEventSource({ + sessionUrl, + onDocument: loadSmartFilterFromJson, + onSessionClosed: (reason) => { + globalState.onLogRequiredObservable.notifyObservers(new LogEntry(`MCP Session ended: ${reason}`, LogLevel.Log)); + globalState.mcpSessionConnected = false; + globalState.mcpEventSource = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, + onConnectionError: () => { + globalState.mcpSessionConnected = false; + globalState.mcpEventSource = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, + }); + globalState.mcpEventSource = eventSource; + + globalState.mcpSessionUrl = sessionUrl; + globalState.mcpSessionConnected = true; + globalState.onMcpSessionStateChangedObservable.notifyObservers(true); + } catch (err) { + logError(`MCP Session: Connection failed - ${err}`); + } + }, + [url, globalState, loadSmartFilterFromJson, logError, pushSmartFilterAsync] + ); + + const handleDisconnect = useCallback(() => { + CloseMcpEditorSessionEventSource(globalState.mcpEventSource); + globalState.mcpEventSource = null; + globalState.mcpSessionConnected = false; + globalState.mcpSessionUrl = null; + globalState.onMcpSessionStateChangedObservable.notifyObservers(false); + }, [globalState]); + + const handlePush = useCallback(async () => { + if (!globalState.mcpSessionUrl) { + return; + } + + try { + await pushSmartFilterAsync(globalState.mcpSessionUrl); + } catch (err) { + logError(`MCP Session: Push failed - ${err}`); + } + }, [globalState.mcpSessionUrl, logError, pushSmartFilterAsync]); + + return ( + + setUrl(value)} + placeholder="http://localhost:3001/session/..." + disabled={connected} + lockObject={globalState.lockObject} + /> + + {!connected ? ( + <> + { + void handleConnect(false); + }} + /> + { + void handleConnect(true); + }} + /> + + ) : ( + <> + + { + void handlePush(); + }} + /> + + )} + + ); +}; diff --git a/packages/tools/smartFiltersEditorControl/src/components/propertyTab/propertyTabComponent.tsx b/packages/tools/smartFiltersEditorControl/src/components/propertyTab/propertyTabComponent.tsx index 6c05c40c31d..be5a42dec45 100644 --- a/packages/tools/smartFiltersEditorControl/src/components/propertyTab/propertyTabComponent.tsx +++ b/packages/tools/smartFiltersEditorControl/src/components/propertyTab/propertyTabComponent.tsx @@ -14,6 +14,7 @@ import { TextInputLineComponent } from "shared-ui-components/lines/textInputLine import { ButtonLineComponent } from "shared-ui-components/lines/buttonLineComponent.js"; import { SliderLineComponent } from "shared-ui-components/lines/sliderLineComponent.js"; import { InputsPropertyTabComponent } from "./inputsPropertyTabComponent.js"; +import { McpSessionComponent } from "../mcpSession/mcpSessionComponent.js"; import { BlockTools } from "../../blockTools.js"; import { type Nullable } from "core/types"; @@ -72,7 +73,6 @@ export class PropertyTabComponent extends react.Component) => { const { selection } = options || {}; @@ -125,7 +125,6 @@ export class PropertyTabComponent extends react.ComponentSMART FILTER EDITOR
+ ; + mcpSessionUrl: Nullable = null; + + mcpSessionConnected: boolean = false; + + mcpEventSource: Nullable = null; + + onMcpSessionStateChangedObservable = new Observable(); + onlyShowCustomBlocksObservable = new Observable(); texturePresets: TexturePreset[]; diff --git a/tsconfig.json b/tsconfig.json index 43480594183..aa483c50c7b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,79 +2,86 @@ "extends": "./tsconfig.build.json", "compilerOptions": { + "ignoreDeprecations": "6.0", + "baseUrl": "packages", "experimentalDecorators": true, + "noImplicitAny": true, "noImplicitOverride": true, "noImplicitReturns": true, + "noImplicitThis": true, "noUnusedLocals": true, + "strictNullChecks": true, + "strictFunctionTypes": true, "skipLibCheck": true, "jsx": "react-jsx", "paths": { - "core/*": ["./packages/dev/core/src/*"], - "gui/*": ["./packages/dev/gui/src/*"], - "materials/*": ["./packages/dev/materials/src/*"], - "addons/*": ["./packages/dev/addons/src/*"], - "post-processes/*": ["./packages/dev/postProcesses/src/*"], - "procedural-textures/*": ["./packages/dev/proceduralTextures/src/*"], - "loaders/*": ["./packages/dev/loaders/src/*"], - "serializers/*": ["./packages/dev/serializers/src/*"], - "inspector-legacy/*": ["./packages/dev/inspector/src/*"], - "inspector/*": ["./packages/dev/inspector-v2/src/*"], - "node-editor/*": ["./packages/tools/nodeEditor/src/*"], - "node-geometry-editor/*": ["./packages/tools/nodeGeometryEditor/src/*"], - "node-render-graph-editor/*": ["./packages/tools/nodeRenderGraphEditor/src/*"], - "node-particle-editor/*": ["./packages/tools/nodeParticleEditor/src/*"], - "flow-graph-editor/*": ["./packages/tools/flowGraphEditor/src/*"], - "gui-editor/*": ["./packages/tools/guiEditor/src/*"], - "accessibility/*": ["./packages/tools/accessibility/src/*"], - "viewer/*": ["./packages/tools/viewer/src/*"], - "ktx2decoder/*": ["./packages/tools/ktx2Decoder/src/*"], - "shared-ui-components/*": ["./packages/dev/sharedUiComponents/src/*"], - "@tools/gui-editor/*": ["./packages/tools/guiEditor/src/*"], - "@tools/node-editor/*": ["./packages/tools/nodeEditor/src/*"], - "@tools/node-geometry-editor/*": ["./packages/tools/nodeGeometryEditor/src/*"], - "@tools/node-render-graph-editor/*": ["./packages/tools/nodeRenderGraphEditor/src/*"], - "@tools/node-particle-editor/*": ["./packages/tools/nodeParticleEditor/src/*"], - "@tools/flow-graph-editor/*": ["./packages/tools/flowGraphEditor/src/*"], - "@tools/accessibility/*": ["./packages/tools/accessibility/src/*"], - "@dev/core": ["./packages/dev/core/src"], - "@dev/gui": ["./packages/dev/gui/src"], - "@dev/materials": ["./packages/dev/materials/src"], - "@dev/addons": ["./packages/dev/addons/src"], - "@dev/post-processes": ["./packages/dev/postProcesses/src"], - "@dev/procedural-textures": ["./packages/dev/proceduralTextures/src"], - "@dev/loaders": ["./packages/dev/loaders/src"], - "@dev/serializers": ["./packages/dev/serializers/src"], - "@dev/inspector-legacy": ["./packages/dev/inspector/src"], - "@dev/inspector": ["./packages/dev/inspector-v2/src"], - "@dev/shared-ui-components": ["./packages/dev/sharedUiComponents/src"], - "@dev/core/*": ["./packages/dev/core/src/*"], - "@dev/gui/*": ["./packages/dev/gui/src/*"], - "@dev/materials/*": ["./packages/dev/materials/src/*"], - "@dev/post-processes/*": ["./packages/dev/postProcesses/src/*"], - "@dev/procedural-textures/*": ["./packages/dev/proceduralTextures/src/*"], - "@dev/loaders/*": ["./packages/dev/loaders/src/*"], - "@dev/serializers/*": ["./packages/dev/serializers/src/*"], - "@dev/inspector-legacy/*": ["./packages/dev/inspector/src/*"], - "@dev/inspector/*": ["./packages/dev/inspector-v2/src/*"], - "@dev/shared-ui-components/*": ["./packages/dev/sharedUiComponents/src/*"], - "@lts/core/*": ["./packages/lts/core/src/*"], - "@lts/gui/*": ["./packages/lts/gui/src/*"], - "@lts/materials/*": ["./packages/lts/materials/src/*"], - "@lts/post-processes/*": ["./packages/lts/postProcesses/src/*"], - "@lts/procedural-textures/*": ["./packages/lts/proceduralTextures/src/*"], - "@lts/loaders/*": ["./packages/lts/loaders/src/*"], - "@lts/serializers/*": ["./packages/lts/serializers/src/*"], - "@lts/inspector-legacy/*": ["./packages/lts/inspector/src/*"], - "@lts/shared-ui-components/*": ["./packages/lts/sharedUiComponents/src/*"], - "@tools/snippet-loader": ["./packages/tools/snippetLoader/src"], - "@tools/snippet-loader/*": ["./packages/tools/snippetLoader/src/*"], - "smart-filters": ["./packages/dev/smartFilters/src"], - "smart-filters/*": ["./packages/dev/smartFilters/src/*"], - "smart-filters-blocks": ["./packages/dev/smartFilterBlocks/src"], - "smart-filters-blocks/*": ["./packages/dev/smartFilterBlocks/src/*"], - "smart-filters-editor-control": ["./packages/tools/smartFiltersEditorControl/src"], - "lottie-player": ["./packages/dev/lottiePlayer/src"], - "lottie-player/*": ["./packages/dev/lottiePlayer/src/*"] + "core/*": ["dev/core/src/*"], + "gui/*": ["dev/gui/src/*"], + "materials/*": ["dev/materials/src/*"], + "addons/*": ["dev/addons/src/*"], + "post-processes/*": ["dev/postProcesses/src/*"], + "procedural-textures/*": ["dev/proceduralTextures/src/*"], + "loaders/*": ["dev/loaders/src/*"], + "serializers/*": ["dev/serializers/src/*"], + "inspector-legacy/*": ["dev/inspector/src/*"], + "inspector/*": ["dev/inspector-v2/src/*"], + "node-editor/*": ["tools/nodeEditor/src/*"], + "node-geometry-editor/*": ["tools/nodeGeometryEditor/src/*"], + "node-render-graph-editor/*": ["tools/nodeRenderGraphEditor/src/*"], + "node-particle-editor/*": ["tools/nodeParticleEditor/src/*"], + "gui-editor/*": ["tools/guiEditor/src/*"], + "flow-graph-editor/*": ["tools/flowGraphEditor/src/*"], + "accessibility/*": ["tools/accessibility/src/*"], + "viewer/*": ["tools/viewer/src/*"], + "ktx2decoder/*": ["tools/ktx2Decoder/src/*"], + "shared-ui-components/*": ["dev/sharedUiComponents/src/*"], + "@tools/gui-editor/*": ["tools/guiEditor/src/*"], + "@tools/node-editor/*": ["tools/nodeEditor/src/*"], + "@tools/node-geometry-editor/*": ["tools/nodeGeometryEditor/src/*"], + "@tools/node-render-graph-editor/*": ["tools/nodeRenderGraphEditor/src/*"], + "@tools/node-particle-editor/*": ["tools/nodeParticleEditor/src/*"], + "@tools/accessibility/*": ["tools/accessibility/src/*"], + "@tools/mcp-server-core": ["tools/mcp-server-core/src/index"], + "@tools/mcp-server-core/*": ["tools/mcp-server-core/src/*"], + "@dev/core": ["dev/core/src"], + "@dev/gui": ["dev/gui/src"], + "@dev/materials": ["dev/materials/src"], + "@dev/addons": ["dev/addons/src"], + "@dev/post-processes": ["dev/postProcesses/src"], + "@dev/procedural-textures": ["dev/proceduralTextures/src"], + "@dev/loaders": ["dev/loaders/src"], + "@dev/serializers": ["dev/serializers/src"], + "@dev/inspector-legacy": ["dev/inspector/src"], + "@dev/inspector": ["dev/inspector-v2/src"], + "@dev/shared-ui-components": ["dev/sharedUiComponents/src"], + "@dev/core/*": ["dev/core/src/*"], + "@dev/gui/*": ["dev/gui/src/*"], + "@dev/materials/*": ["dev/materials/src/*"], + "@dev/post-processes/*": ["dev/postProcesses/src/*"], + "@dev/procedural-textures/*": ["dev/proceduralTextures/src/*"], + "@dev/loaders/*": ["dev/loaders/src/*"], + "@dev/serializers/*": ["dev/serializers/src/*"], + "@dev/inspector-legacy/*": ["dev/inspector/src/*"], + "@dev/inspector/*": ["dev/inspector-v2/src/*"], + "@dev/shared-ui-components/*": ["dev/sharedUiComponents/src/*"], + "@lts/core/*": ["lts/core/src/*"], + "@lts/gui/*": ["lts/gui/src/*"], + "@lts/materials/*": ["lts/materials/src/*"], + "@lts/post-processes/*": ["lts/postProcesses/src/*"], + "@lts/procedural-textures/*": ["lts/proceduralTextures/src/*"], + "@lts/loaders/*": ["lts/loaders/src/*"], + "@lts/serializers/*": ["lts/serializers/src/*"], + "@lts/inspector-legacy/*": ["lts/inspector/src/*"], + "@lts/shared-ui-components/*": ["lts/sharedUiComponents/src/*"], + "@tools/snippet-loader": ["tools/snippetLoader/src"], + "@tools/snippet-loader/*": ["tools/snippetLoader/src/*"], + "smart-filters": ["dev/smartFilters/src"], + "smart-filters/*": ["dev/smartFilters/src/*"], + "smart-filters-blocks": ["dev/smartFilterBlocks/src"], + "smart-filters-blocks/*": ["dev/smartFilterBlocks/src/*"], + "smart-filters-editor-control": ["tools/smartFiltersEditorControl/src"], + "lottie-player": ["dev/lottiePlayer/src"], + "lottie-player/*": ["dev/lottiePlayer/src/*"] } } } diff --git a/vitest.config.mts b/vitest.config.mts index d68043b7c04..b3ace7719fb 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -16,7 +16,7 @@ const convertPathsToAliases = () => { for (const key in paths) { // Convert glob patterns to regex-compatible aliases const aliasKey = key.replace("/*", ""); - const aliasValue = path.resolve(__dirname, paths[key][0].replace("/*", "")); + const aliasValue = path.resolve(__dirname, "packages", paths[key][0].replace("/*", "")); aliases[aliasKey] = aliasValue; } return aliases; @@ -24,6 +24,12 @@ const convertPathsToAliases = () => { const aliases = convertPathsToAliases(); +// babylonjs-gltf2interface is a types-only package (const enums inlined by +// TypeScript at compile time). It has no JS entry point, so Vite's resolver +// cannot find one. Provide a runtime stub so glTF loader tests can import +// modules that reference this package. +const gltf2InterfaceStub = path.resolve(__dirname, "packages/public/glTF2Interface/babylonjs-gltf2interface.stub.ts"); + const createProjectConfig = (type: string) => { const globalSetupLocation = path.resolve(".", `vitest.${type}.setup.ts`); const setupFileLocation = path.resolve(".", `vitest.${type}.setup.afterEnv.ts`); @@ -50,12 +56,6 @@ const createProjectConfig = (type: string) => { }; }; -// babylonjs-gltf2interface is a types-only package (const enums inlined by -// TypeScript at compile time). It has no JS entry point, so Vite's resolver -// cannot find one. Provide a runtime stub so glTF loader tests can import -// modules that reference this package. -const gltf2InterfaceStub = path.resolve(__dirname, "packages/public/glTF2Interface/babylonjs-gltf2interface.stub.ts"); - export default defineConfig({ resolve: { alias: {