From ee27f86defebbff818b8301aac78b895d718a122 Mon Sep 17 00:00:00 2001 From: SS-360 <138029772+SS-360@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:09:29 +0530 Subject: [PATCH 1/2] Harden native material previews and export --- CHANGELOG.md | 13 ++ .../adapters/material_maker_adapter.gd | 124 +++++++++----- .../scripts/validate-addon.mjs | 7 +- .../scripts/validate-material-maker.mjs | 154 ++++++++++++------ docs/development/building.md | 2 +- docs/development/implementation-status.md | 9 +- docs/user/troubleshooting.md | 17 ++ scripts/create-abandoned-industrial-floor.mjs | 102 +++++++++++- 8 files changed, 329 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b2dab..b9b9fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,19 @@ All notable changes use Keep a Changelog conventions. ### Fixed +- Gated node previews, 3D material previews, and Blender export on the presence of a real Godot + rendering device so headless sessions return `CAPABILITY_NOT_AVAILABLE` instead of invalid shader + output or missing-material textures. +- Added native test preflight checks for stale installed bridge files and split renderer verification + into deterministic headless capability checks and GPU-backed windowed preview/export checks. +- Made the abandoned-industrial-floor production workflow honor `MATERIALPILOT_RUNTIME_FILE`, + allowing isolated authenticated bridge sessions instead of forcing the default user record. +- Replaced the Blender bridge's UI-oriented export call with bounded, project-local rendering of + connected Material channels, temporary-file publication, and post-write hash verification so + automation cannot hang on Material Maker's unrelated global preview render queue. +- Corrected the industrial-floor Normal Map recipe to use the native direct-sampling path within + its supported strength range, and added decoded PNG channel-variance verification that rejects + effectively flat normal previews. - Made Godot stable node IDs byte-for-byte compatible with the TypeScript domain model. - Removed serialized array positions from stable connection IDs and enabled deletion by public ID. - Made non-deletable Material output removal fail explicitly instead of returning a successful no-op. diff --git a/apps/material-maker-bridge/addon/materialpilot/adapters/material_maker_adapter.gd b/apps/material-maker-bridge/addon/materialpilot/adapters/material_maker_adapter.gd index 3a93ef5..ea8a4d6 100644 --- a/apps/material-maker-bridge/addon/materialpilot/adapters/material_maker_adapter.gd +++ b/apps/material-maker-bridge/addon/materialpilot/adapters/material_maker_adapter.gd @@ -10,6 +10,7 @@ func get_material_maker_version() -> String: func get_features() -> Dictionary: var graph = _current_graph() var editable := _is_root_graph(graph) + var rendering_available := _has_rendering_device() return { "application.read": true, "project.read": true, @@ -19,10 +20,10 @@ func get_features() -> Dictionary: "graph.write": editable, "graph.transactions": editable, "catalog.read": true, - "preview.capture": graph != null, - "preview.nodeOutput": graph != null, - "preview.material3d": graph != null and _main_window() != null, - "export.write": editable, + "preview.capture": graph != null and rendering_available, + "preview.nodeOutput": graph != null and rendering_available, + "preview.material3d": graph != null and _main_window() != null and rendering_available, + "export.write": editable and rendering_available, "painting.read": _current_project_type() == "painting", "painting.stroke": false, "customNode.write": false @@ -338,6 +339,8 @@ func render_node_output(params: Dictionary): return _error(-32004, "PROJECT_NOT_FOUND") if params.get("projectId") != _active_project_id(): return _error(-32004, "PROJECT_NOT_FOUND") + if !_has_rendering_device(): + return _error(-32030, "CAPABILITY_NOT_AVAILABLE", {"reason": "Godot has no rendering device."}) var native := _resolve_native(params.get("nodeId", ""), {}) if !graph.generator.has_node(native): return _error(-32011, "NODE_NOT_FOUND") @@ -547,6 +550,9 @@ func _main_window(): return globals.main_window return get_tree().root.find_child("MainWindow", true, false) +func _has_rendering_device() -> bool: + return RenderingServer.get_rendering_device() != null + func _current_graph(): var main = _main_window() return main.get_current_graph_edit() if main != null and main.has_method("get_current_graph_edit") else null @@ -640,6 +646,8 @@ func render_material_3d(params: Dictionary): var main = _main_window() if graph == null or main == null or params.get("projectId") != _active_project_id(): return _error(-32004, "PROJECT_NOT_FOUND") + if !_has_rendering_device(): + return _error(-32030, "CAPABILITY_NOT_AVAILABLE", {"reason": "Godot has no rendering device."}) if !("preview_3d" in main) or main.preview_3d == null: return _error(-32030, "CAPABILITY_NOT_AVAILABLE") var geometry := str(params.get("geometry", "")) @@ -745,25 +753,27 @@ func export_blender(params: Dictionary): var graph = _current_graph() if graph == null or params.get("projectId") != _active_project_id(): return _error(-32004, "PROJECT_NOT_FOUND") + if !_has_rendering_device(): + return _error(-32030, "CAPABILITY_NOT_AVAILABLE", {"reason": "Godot has no rendering device."}) if params.get("expectedRevision", -1) != _revision(graph): return _error(-32010, "REVISION_CONFLICT", {"actualRevision": _revision(graph)}) var prefix := str(params.get("prefix", "")).simplify_path() if !prefix.is_absolute_path() or !DirAccess.dir_exists_absolute(prefix.get_base_dir()): return _error(-32602, "A canonical absolute export prefix in an existing directory is required") - var suffixes := [ - ["_albedo.png", "image/png"], - ["_rough.exr", "image/x-exr"], - ["_metal.exr", "image/x-exr"], - ["_normal.png", "image/png"], - ["_displace.exr", "image/x-exr"], - ["_sss.exr", "image/x-exr"], - ["_emission.png", "image/png"], - ["_occlusion.exr", "image/x-exr"] + var export_specs := [ + {"input": 0, "output": 0, "suffix": "_albedo.png", "mimeType": "image/png", "format": "png"}, + {"input": 2, "output": 13, "suffix": "_rough.exr", "mimeType": "image/x-exr", "format": "exr"}, + {"input": 1, "output": 12, "suffix": "_metal.exr", "mimeType": "image/x-exr", "format": "exr"}, + {"input": 4, "output": 7, "suffix": "_normal.png", "mimeType": "image/png", "format": "png"}, + {"input": 6, "output": 8, "suffix": "_displace.exr", "mimeType": "image/x-exr", "format": "exr"}, + {"input": 8, "output": 5, "suffix": "_sss.exr", "mimeType": "image/x-exr", "format": "exr"}, + {"input": 3, "output": 2, "suffix": "_emission.png", "mimeType": "image/png", "format": "png"}, + {"input": 5, "output": 9, "suffix": "_occlusion.exr", "mimeType": "image/x-exr", "format": "exr"} ] if !params.get("overwrite", false): - for suffix in suffixes: - if FileAccess.file_exists(prefix + suffix[0]): - return _error(-32022, "OVERWRITE_NOT_ALLOWED", {"path": prefix + suffix[0]}) + for spec in export_specs: + if FileAccess.file_exists(prefix + spec.suffix): + return _error(-32022, "OVERWRITE_NOT_ALLOWED", {"path": prefix + spec.suffix}) var key := str(params.get("idempotencyKey", "")) if key.length() < 8: return _error(-32602, "An idempotency key is required") @@ -774,35 +784,71 @@ func export_blender(params: Dictionary): return _error(-32602, "Idempotency key reused with different arguments") return _idempotency[scoped_key].result var material_node = graph.get_material_node() if graph.has_method("get_material_node") else null - if material_node == null or !material_node.has_method("export_material"): + if material_node == null or !material_node.has_method("render_output"): return _error(-32020, "The active graph has no Blender-exportable Material node") var graph_before := _serialize_graph(graph.top_generator) - var selection_before := _selection(graph) - await material_node.export_material(prefix, "Blender", 0, true) - var graph_after := _serialize_graph(graph.top_generator) - if _graph_hash(graph_after) != _graph_hash(graph_before): - if !await _restore_graph(graph, graph_before): - return _error(-32020, "ROLLBACK_FAILED after Blender export") - _set_selection(graph, selection_before, {}) + var normalized_graph := _normalize_serialized_graph(graph_before) + var connected_inputs := {} + for connection in normalized_graph.get("connections", []): + if str(connection.get("to", "")) == str(material_node.name): + connected_inputs[int(connection.get("to_port", -1))] = true + var export_size := int(material_node.get_image_size()) if material_node.has_method("get_image_size") else 0 + if export_size < 16 or export_size > 2048: + return _error(-32020, "Material export size must be between 16 and 2048", {"size": export_size}) + var temporary_prefix := prefix + ".materialpilot-" + key.sha256_text().left(12) + var temporary_paths: Array = [] + var pending_artifacts: Array = [] + for spec in export_specs: + if !connected_inputs.has(spec.input): + continue + var image: Image = await material_node.render_output(spec.output, Vector2i(export_size, export_size)) + if image == null or image.is_empty(): + _cleanup_files(temporary_paths) + return _error(-32020, "Blender channel render failed", {"suffix": spec.suffix}) + var temporary_path: String = temporary_prefix + spec.suffix + var save_error: Error = image.save_png(temporary_path) if spec.format == "png" else image.save_exr(temporary_path) + if save_error != OK: + _cleanup_files(temporary_paths) + return _error(-32020, "Could not write Blender export channel", {"path": temporary_path, "error": save_error}) + temporary_paths.append(temporary_path) + pending_artifacts.append({"temporary": temporary_path, "final": prefix + spec.suffix, "mimeType": spec.mimeType}) + if pending_artifacts.is_empty(): + return _error(-32020, "Blender export has no connected Material channels") + for pending in pending_artifacts: + if FileAccess.file_exists(pending.final): + if !params.get("overwrite", false): + _cleanup_files(temporary_paths) + return _error(-32022, "OVERWRITE_NOT_ALLOWED", {"path": pending.final}) + var remove_error := DirAccess.remove_absolute(pending.final) + if remove_error != OK: + _cleanup_files(temporary_paths) + return _error(-32020, "Could not replace Blender export artifact", {"path": pending.final, "error": remove_error}) + var rename_error := DirAccess.rename_absolute(pending.temporary, pending.final) + if rename_error != OK: + _cleanup_files(temporary_paths) + return _error(-32020, "Could not publish Blender export artifact", {"path": pending.final, "error": rename_error}) var artifacts: Array = [] - for suffix in suffixes: - var path: String = prefix + suffix[0] - if FileAccess.file_exists(path): - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - return _error(-32020, "Could not verify exported artifact", {"path": path}) - artifacts.append({ - "path": path, - "mimeType": suffix[1], - "sizeBytes": file.get_length(), - "sha256": FileAccess.get_sha256(path) - }) - file.close() - if artifacts.is_empty(): - return _error(-32020, "Blender export produced no artifacts") + for pending in pending_artifacts: + var file := FileAccess.open(pending.final, FileAccess.READ) + if file == null or file.get_length() <= 0: + return _error(-32020, "Could not verify exported artifact", {"path": pending.final}) + artifacts.append({ + "path": pending.final, + "mimeType": pending.mimeType, + "sizeBytes": file.get_length(), + "sha256": FileAccess.get_sha256(pending.final) + }) + file.close() + if _graph_hash(_serialize_graph(graph.top_generator)) != _graph_hash(graph_before): + return _error(-32020, "Blender export changed the material graph") _idempotency[scoped_key] = {"hash": request_hash, "result": artifacts} return artifacts +func _cleanup_files(paths: Array) -> void: + for path in paths: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(path) + func _selection(graph) -> Array: var result: Array = [] if graph.has_method("get_selected_nodes"): diff --git a/apps/material-maker-bridge/scripts/validate-addon.mjs b/apps/material-maker-bridge/scripts/validate-addon.mjs index f99b20b..2e1b491 100644 --- a/apps/material-maker-bridge/scripts/validate-addon.mjs +++ b/apps/material-maker-bridge/scripts/validate-addon.mjs @@ -32,7 +32,12 @@ for (const marker of [ "await _restore_graph(graph, before)", 'get_node_or_null("/root/mm_loader")', "_stable_native_id", - "do_disconnect_node" + "do_disconnect_node", + "_has_rendering_device", + "Blender channel render failed", + "_cleanup_files", + '"preview.material3d": graph != null and _main_window() != null and rendering_available', + '"export.write": editable and rendering_available' ]) { if (!adapter.includes(marker)) throw new Error(`Native adapter is missing transaction marker: ${marker}`); diff --git a/apps/material-maker-bridge/scripts/validate-material-maker.mjs b/apps/material-maker-bridge/scripts/validate-material-maker.mjs index 78224fb..b5a5e69 100644 --- a/apps/material-maker-bridge/scripts/validate-material-maker.mjs +++ b/apps/material-maker-bridge/scripts/validate-material-maker.mjs @@ -13,6 +13,25 @@ if (!godot || !materialMaker) { throw new Error("Set GODOT_BIN and MATERIAL_MAKER_PROJECT for the native application contract."); } await access(join(materialMaker, "project.godot")); +for (const relative of [ + "adapters/material_maker_adapter.gd", + "bridge/bridge_server.gd", + "bridge/bridge_error.gd" +]) { + const source = await readFile(join(root, "addon", "materialpilot", relative)); + const installedPath = join(materialMaker, "addons", "materialpilot", relative); + let installed; + try { + installed = await readFile(installedPath); + } catch { + throw new Error(`Material Maker is missing the MaterialPilot addon file: ${installedPath}`); + } + if (!source.equals(installed)) { + throw new Error( + `Material Maker has a stale MaterialPilot addon file: ${installedPath}. Copy apps/material-maker-bridge/addon/materialpilot into the project's addons directory before testing.` + ); + } +} const { BridgeClient } = await import( pathToFileURL(join(repository, "packages", "bridge-protocol", "dist", "index.js")).href ); @@ -26,10 +45,10 @@ const userDataDirectory = join(runtimeDirectory, "user-data"); await mkdir(runtimeDirectory, { recursive: true }); await mkdir(userDataDirectory, { recursive: true }); const output = { value: "" }; -const applicationArgs = - process.env.MATERIALPILOT_WINDOWED_TEST === "1" - ? ["--path", materialMaker, "--position", "-10000,-10000", "--no-splash"] - : ["--headless", "--path", materialMaker, "--no-splash"]; +const windowedTest = process.env.MATERIALPILOT_WINDOWED_TEST === "1"; +const applicationArgs = windowedTest + ? ["--path", materialMaker, "--position", "-10000,-10000", "--no-splash"] + : ["--headless", "--path", materialMaker, "--no-splash"]; const child = spawn(godot, applicationArgs, { env: { ...process.env, @@ -188,6 +207,19 @@ try { if (!status?.activeProjectId) { throw new Error(`Material Maker did not initialize an active project:\n${output.value}`); } + const capabilities = await client.request("app.get_capabilities"); + for (const feature of [ + "preview.capture", + "preview.nodeOutput", + "preview.material3d", + "export.write" + ]) { + if (capabilities.features?.[feature] !== windowedTest) { + throw new Error( + `${feature} capability did not match renderer availability: ${JSON.stringify(capabilities)}` + ); + } + } let active = await client.request("project.get_active"); let snapshot = await client.request("graph.get_snapshot", { projectId: active.id }); if (!Array.isArray(snapshot.serializedGraph?.nodes)) { @@ -488,42 +520,58 @@ try { if (resolve(saved.path) !== resolve(savedPath) || saved.dirty !== false) { throw new Error(`Native project save contract failed: ${JSON.stringify(saved)}`); } - for (const geometry of ["sphere", "plane"]) { - const materialPreview = await client.request("preview.render_material_3d", { + if (windowedTest) { + for (const geometry of ["sphere", "plane"]) { + const materialPreview = await client.request("preview.render_material_3d", { + projectId: active.id, + geometry, + resolution: 256 + }); + if ( + materialPreview.mimeType !== "image/png" || + materialPreview.width !== 256 || + materialPreview.height !== 256 || + materialPreview.data.length < 32 + ) { + throw new Error(`Native ${geometry} material preview contract failed`); + } + assertPreviewHasMaterial(materialPreview.data, geometry); + } + const exportDirectory = join(runtimeDirectory, "export"); + await mkdir(exportDirectory); + const exportPrefix = join(exportDirectory, "native-capability-test"); + const artifacts = await client.request("export.blender", { projectId: active.id, - geometry, - resolution: 256 + prefix: exportPrefix, + expectedRevision: committed.revision, + idempotencyKey: `native-export-${process.pid}`, + overwrite: false }); if ( - materialPreview.mimeType !== "image/png" || - materialPreview.width !== 256 || - materialPreview.height !== 256 || - materialPreview.data.length < 32 + artifacts.length < 5 || + artifacts.some( + (artifact) => + typeof artifact.sha256 !== "string" || + artifact.sha256.length !== 64 || + artifact.sizeBytes <= 0 + ) ) { - throw new Error(`Native ${geometry} material preview contract failed`); + throw new Error(`Native Blender export verification failed: ${JSON.stringify(artifacts)}`); + } + } else { + let unavailableRejected = false; + try { + await client.request("preview.render_material_3d", { + projectId: active.id, + geometry: "sphere", + resolution: 256 + }); + } catch (error) { + unavailableRejected = String(error).includes("CAPABILITY_NOT_AVAILABLE"); + } + if (!unavailableRejected) { + throw new Error("Headless 3D preview did not reject the unavailable rendering capability"); } - assertPreviewHasMaterial(materialPreview.data, geometry); - } - const exportDirectory = join(runtimeDirectory, "export"); - await mkdir(exportDirectory); - const exportPrefix = join(exportDirectory, "native-capability-test"); - const artifacts = await client.request("export.blender", { - projectId: active.id, - prefix: exportPrefix, - expectedRevision: committed.revision, - idempotencyKey: `native-export-${process.pid}`, - overwrite: false - }); - if ( - artifacts.length < 5 || - artifacts.some( - (artifact) => - typeof artifact.sha256 !== "string" || - artifact.sha256.length !== 64 || - artifact.sizeBytes <= 0 - ) - ) { - throw new Error(`Native Blender export verification failed: ${JSON.stringify(artifacts)}`); } const committedConnection = committedDomain.connections[0]; const committedNormal = committedDomain.nodes.find( @@ -558,21 +606,23 @@ try { } const previewNode = committedDomain.nodes.find((node) => node.outputs.length > 0); if (!previewNode) throw new Error("Committed graph did not expose a previewable node"); - const preview = await client.request("preview.render_node_output", { - projectId: active.id, - nodeId: previewNode.id, - outputPort: previewNode.outputs[0].index, - resolution: 512 - }); - const previewBytes = Buffer.from(preview.data, "base64"); - if ( - preview.mimeType !== "image/png" || - preview.width !== 512 || - preview.height !== 512 || - typeof preview.data !== "string" || - previewBytes.length <= 65_535 - ) { - throw new Error("Native node-output preview did not cross the default WebSocket buffer"); + if (windowedTest) { + const preview = await client.request("preview.render_node_output", { + projectId: active.id, + nodeId: previewNode.id, + outputPort: previewNode.outputs[0].index, + resolution: 512 + }); + const previewBytes = Buffer.from(preview.data, "base64"); + if ( + preview.mimeType !== "image/png" || + preview.width !== 512 || + preview.height !== 512 || + typeof preview.data !== "string" || + previewBytes.length <= 65_535 + ) { + throw new Error("Native node-output preview did not cross the default WebSocket buffer"); + } } const afterDeletionPlan = await client.request("graph.get_snapshot", { projectId: active.id }); if ( @@ -625,7 +675,9 @@ try { throw new Error("Native patch emitted an unsupported leaf-node undo diagnostic"); } process.stdout.write( - `Material Maker ${status.materialMakerVersion} native project creation, patch, rollback, undo, redo, save, 3D preview, and Blender export contracts passed on Godot ${status.godotVersion}.\n` + windowedTest + ? `Material Maker ${status.materialMakerVersion} native project creation, patch, rollback, undo, redo, save, 3D preview, and Blender export contracts passed on Godot ${status.godotVersion}.\n` + : `Material Maker ${status.materialMakerVersion} headless graph contracts passed with rendering capabilities safely disabled on Godot ${status.godotVersion}.\n` ); } catch (error) { throw new Error( diff --git a/docs/development/building.md b/docs/development/building.md index d1ecb23..38b4df6 100644 --- a/docs/development/building.md +++ b/docs/development/building.md @@ -6,4 +6,4 @@ The test suite covers schemas, canonical hashing, catalog lookup and compatibili Godot is not bundled. `apps/material-maker-bridge/scripts/validate-addon.mjs` provides static safety-contract checks. Set `GODOT_BIN` to an official Godot 4.7 executable and run `pnpm test:godot` for the real parse, handshake, and status contract. `pnpm compatibility` runs this automatically when `GODOT_BIN` or the ignored local `.tools/godot-4.7` runtime is available. -For the full upstream application contract, install the add-on/autoload into a Material Maker 1.7 source checkout, build the TypeScript packages, set `MATERIAL_MAKER_PROJECT` and `GODOT_BIN`, then run `pnpm test:material-maker`. The test opens the real application headlessly, reads its active graph, creates and connects a representative multi-node graph, verifies native parameters, proves exact dry-run rollback, and exercises commit, undo, redo, and final restoration through Material Maker's own journal. `pnpm compatibility` includes this contract whenever both environment variables are set. +For the full upstream application contract, install the add-on/autoload into a Material Maker 1.7 source checkout, build the TypeScript packages, set `MATERIAL_MAKER_PROJECT` and `GODOT_BIN`, then run `pnpm test:material-maker`. The test first verifies that the installed bridge files match the repository source, then opens the real application headlessly, reads its active graph, creates and connects a representative multi-node graph, verifies native parameters, proves exact dry-run rollback, and exercises commit, undo, redo, and final restoration through Material Maker's own journal. Headless runs verify that rendering capabilities are explicitly disabled when Godot has no rendering device. Set `MATERIALPILOT_WINDOWED_TEST=1` to additionally exercise node rendering, non-magenta sphere and plane previews, and Blender export with a real rendering device. `pnpm compatibility` includes the headless contract whenever both environment variables are set. diff --git a/docs/development/implementation-status.md b/docs/development/implementation-status.md index 1ca3a0a..7e66010 100644 --- a/docs/development/implementation-status.md +++ b/docs/development/implementation-status.md @@ -8,9 +8,14 @@ MaterialPilot 0.1.0 is a tested implementation preview, not the stable 1.0 descr - MCP server over STDIO and authenticated loopback HTTP with 143 schema-defined tools spanning application, project, graph, catalog, node, connection, transaction, layout, PBR, preview, export, diagnostics, performance, and material workflows, plus resources and prompts. - Authenticated loopback WebSocket bridge for Material Maker, multi-project discovery, idempotent and rollback-safe native material creation, policy-constrained project save, canonical and named recovery snapshots, root-graph patch transactions, revision checks, selection, undo/redo, and exact failure/dry-run rollback. - Generated coverage for 392 unique Material Maker 1.7 node definitions, plus stable graph comments and linked remote controls. -- Bounded in-memory PNG rendering for individual live node outputs and sphere/plane material previews, plus verified Blender-profile PBR export. +- Renderer-gated, bounded in-memory PNG rendering for individual live node outputs and sphere/plane + material previews, plus project-local Blender-profile PBR export with temporary publication, + dimensions, sizes, and SHA-256 verification. - CLI doctor, catalog verification, project linting, project normalization, package scripts, CI, SBOM, checksum, release archive, fixtures, and user/developer documentation. -- Automated validation with unit/integration tests, an official Godot 4.7 parse/handshake contract, and a full Material Maker 1.7 headless multi-node commit/rollback/undo/redo contract. +- Automated validation with unit/integration tests, an official Godot 4.7 parse/handshake contract, + a Material Maker 1.7 headless multi-node commit/rollback/undo/redo contract that verifies + rendering capability rejection, and a GPU-backed windowed contract that rejects missing-material + previews and verifies Blender export. ## Capability-gated in 0.1.0 diff --git a/docs/user/troubleshooting.md b/docs/user/troubleshooting.md index 0e67c6a..189c875 100644 --- a/docs/user/troubleshooting.md +++ b/docs/user/troubleshooting.md @@ -16,6 +16,23 @@ The default mode is assisted. Codex can be configured to prompt for write tools; This is graceful degradation, not a hidden failure. `app_get_status` reports `offlineMode: true`, and live preview/export/painting capabilities are false. +## Preview shows a purple or magenta missing material + +Do not treat a structurally valid graph as visual proof. First confirm `app_get_capabilities` reports +`preview.material3d: true` and `export.write: true`. Those features require a real Godot rendering +device and are deliberately disabled in headless sessions. Close Material Maker, copy the current +`apps/material-maker-bridge/addon/materialpilot` directory over the installed +`addons/materialpilot` directory, restart Material Maker normally, and then restart the MCP client. +The GPU-backed native contract rejects previews dominated by Godot's magenta missing-material +fallback. + +## Blender export does not finish + +Update both the MCP server and installed native addon. Current releases render only the connected +Material channels through the bridge, stage them under temporary filenames, publish the completed +PNG/EXR files, and return verified sizes and SHA-256 hashes. Older addon builds delegated to the UI +export routine and could wait indefinitely on unrelated background preview work. + ## STDIO protocol corruption MaterialPilot logs only to stderr. If another wrapper writes banners to stdout, remove that wrapper or redirect its logs before using it as an MCP command. diff --git a/scripts/create-abandoned-industrial-floor.mjs b/scripts/create-abandoned-industrial-floor.mjs index 8beab4b..c99f344 100644 --- a/scripts/create-abandoned-industrial-floor.mjs +++ b/scripts/create-abandoned-industrial-floor.mjs @@ -1,6 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; +import { inflateSync } from "node:zlib"; import { NativeBackend } from "../apps/mcp-server/dist/backend.js"; import { BridgeClient } from "../packages/bridge-protocol/dist/index.js"; @@ -10,7 +11,10 @@ const smokeMode = process.env.MATERIALPILOT_SMOKE === "1"; const root = path.resolve( process.env.MATERIALPILOT_ARTIFACT_ROOT ?? "artifacts/abandoned-industrial-floor" ); -const bridgeRecord = path.join(process.env.USERPROFILE ?? "", ".materialpilot", "bridge.json"); +const bridgeRecord = path.resolve( + process.env.MATERIALPILOT_RUNTIME_FILE ?? + path.join(process.env.USERPROFILE ?? process.env.HOME ?? "", ".materialpilot", "bridge.json") +); const expectedChannels = [ "albedo", "roughness", @@ -75,6 +79,72 @@ function exrDimensions(data) { } throw new Error("OpenEXR dataWindow is missing"); } +function pngChannelRanges(data) { + let width = 0; + let height = 0; + let channels = 0; + const imageData = []; + for (let offset = 8; offset + 12 <= data.length;) { + const length = data.readUInt32BE(offset); + const type = data.toString("ascii", offset + 4, offset + 8); + const chunk = data.subarray(offset + 8, offset + 8 + length); + if (type === "IHDR") { + width = chunk.readUInt32BE(0); + height = chunk.readUInt32BE(4); + if (chunk[8] !== 8 || ![2, 6].includes(chunk[9])) { + throw new Error(`Unsupported preview PNG format: depth=${chunk[8]} color=${chunk[9]}`); + } + channels = chunk[9] === 6 ? 4 : 3; + } else if (type === "IDAT") { + imageData.push(chunk); + } else if (type === "IEND") { + break; + } + offset += length + 12; + } + const filtered = inflateSync(Buffer.concat(imageData)); + const stride = width * channels; + const pixels = Buffer.alloc(stride * height); + const ranges = Array.from({ length: channels }, () => ({ minimum: 255, maximum: 0 })); + const paeth = (a, b, c) => { + const estimate = a + b - c; + const distanceA = Math.abs(estimate - a); + const distanceB = Math.abs(estimate - b); + const distanceC = Math.abs(estimate - c); + return distanceA <= distanceB && distanceA <= distanceC ? a : distanceB <= distanceC ? b : c; + }; + let source = 0; + for (let y = 0; y < height; y += 1) { + const filter = filtered[source++]; + const row = y * stride; + const previous = row - stride; + for (let x = 0; x < stride; x += 1) { + const left = x >= channels ? pixels[row + x - channels] : 0; + const above = y > 0 ? pixels[previous + x] : 0; + const upperLeft = y > 0 && x >= channels ? pixels[previous + x - channels] : 0; + const predictor = + filter === 0 + ? 0 + : filter === 1 + ? left + : filter === 2 + ? above + : filter === 3 + ? Math.floor((left + above) / 2) + : filter === 4 + ? paeth(left, above, upperLeft) + : (() => { + throw new Error(`Unsupported PNG filter ${filter}`); + })(); + const value = (filtered[source++] + predictor) & 0xff; + pixels[row + x] = value; + const channel = x % channels; + ranges[channel].minimum = Math.min(ranges[channel].minimum, value); + ranges[channel].maximum = Math.max(ranges[channel].maximum, value); + } + } + return ranges; +} function gradient(...hexColors) { const toColor = (hex) => ({ kind: "color", @@ -270,7 +340,12 @@ function graphOperations(values, initialOutputNodeId) { connect("Chip Depth", "output_0", "Final Height", "in2") ); ops.push( - node("Surface Normal", "mm.filter.normal-map", 2260, -300, { size: outputSize, strength: 7.2 }) + node("Surface Normal", "mm.filter.normal-map", 2260, -300, { + size: outputSize, + strength: 2, + buffer: 0, + param2: 0 + }) ); ops.push(connect("Final Height", "output_0", "Surface Normal", "input")); ops.push(node("AO Occlusion", "mm.generated.math", 2260, -100, { op: 0 })); @@ -710,6 +785,16 @@ async function main() { throw new Error(`Bridge export verification mismatch: ${artifact.path}`); if (verified.width !== 2048 || verified.height !== 2048) throw new Error(`Blender map is not 2048 x 2048: ${artifact.path}`); + if (artifact.path.endsWith("_normal.png")) { + const ranges = pngChannelRanges(await readFile(artifact.path)); + if ( + ranges[0].maximum - ranges[0].minimum < 8 || + ranges[1].maximum - ranges[1].minimum < 8 + ) + throw new Error( + `Blender normal export is effectively flat: ${JSON.stringify(ranges)}` + ); + } } step(`export for Blender: ${displayName}`, { artifacts: exported.length }); } @@ -734,15 +819,14 @@ async function main() { albedo: ["Final Albedo", "output"], roughness: ["Final Roughness", "output_0"], metallic: ["Metallic", "output_0"], - normal: ["Material Output", "normal_blender"], + normal: ["Surface Normal", "normal"], height: ["Final Height", "output_0"], ambient_occlusion: ["Surface AO", "output_0"] }; for (const channel of expectedChannels) { process.stderr.write(`[material-build] rendering channel preview: ${channel}\n`); const [nodeName, outputPort] = outputMap[channel]; - const nodeId = - channel === "normal" ? initialOutput.id : applied.temporaryIds[temp(nodeName)]; + const nodeId = applied.temporaryIds[temp(nodeName)]; const target = graph.nodes.find((candidate) => candidate.id === nodeId); if (!target) throw new Error(`Missing preview source: ${channel}`); const rendered = await backend.renderNodePreview( @@ -756,6 +840,14 @@ async function main() { const verified = await verifyArtifact(previewPath, "channel-preview"); if (verified.width !== previewResolution || verified.height !== previewResolution) throw new Error(`Unexpected channel preview dimensions: ${previewPath}`); + if (channel === "normal") { + const ranges = pngChannelRanges(await readFile(previewPath)); + if ( + ranges[0].maximum - ranges[0].minimum < 8 || + ranges[1].maximum - ranges[1].minimum < 8 + ) + throw new Error(`Normal preview is effectively flat: ${JSON.stringify(ranges)}`); + } } step("render channel previews", { channels: expectedChannels }); } From 963ea9ff34733d3ebd34e71aa1e439e00da924f5 Mon Sep 17 00:00:00 2001 From: SS-360 <138029772+SS-360@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:23:12 +0530 Subject: [PATCH 2/2] Rewrite project README --- README.md | 485 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 473 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d7a3902..28f860b 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,491 @@ # MaterialPilot -MaterialPilot is a local-first Model Context Protocol integration for Material Maker. It exposes editable procedural graphs through a stable TypeScript domain model, deterministic validation, revision-safe transactions, and a native GDScript bridge. +### A native, local-first MCP bridge for editable procedural materials in Material Maker -The current server negotiates **143 structured MCP tools** across application health, native project creation and save, graph observation and recovery snapshots, catalog discovery, atomic and batch editing, connections, transactions, layout, PBR channels, previews, Blender export, diagnostics, performance inspection, and procedural texture workflows. Every registered tool has an input schema, read/write annotations, and MaterialPilot safety metadata. +[![CI](https://github.com/SS-360/materialpilot/actions/workflows/ci.yml/badge.svg)](https://github.com/SS-360/materialpilot/actions/workflows/ci.yml) +[![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](LICENSE) +[![Material Maker](https://img.shields.io/badge/Material%20Maker-1.7-478CBF)](https://github.com/RodZill4/material-maker) +[![Godot](https://img.shields.io/badge/Godot-4.7-478CBF)](https://godotengine.org/) +[![Node.js](https://img.shields.io/badge/Node.js-%3E%3D22-339933)](https://nodejs.org/) -The repository is currently an implementation preview targeting Material Maker 1.7 and Godot 4.7. It ships a generated 392-node Material Maker catalog, graph comments and remote controls, bounded node and 3D material previews, and policy-constrained Blender-profile export. The long-range production plan extends toward 150+ tools with asset-library, custom-node, mesh-map, additional export, and painting workflows. Those higher-risk surfaces remain capability-gated until their native transaction and policy contracts are implemented. +MaterialPilot lets MCP-compatible AI agents inspect, create, organize, validate, preview, save, and +export real Material Maker projects. It works through Material Maker's native graph and undo APIs, +not UI coordinates, and keeps the procedural graph editable for artists. + +The current `0.1.0` implementation preview exposes **143 typed MCP tools**, a generated catalog of +**392 Material Maker 1.7 node definitions**, revision-safe graph transactions, deterministic +validation, bounded rendering, named recovery snapshots, native undo/redo, and verified +Blender-profile PBR export. + +> [!IMPORTANT] +> MaterialPilot is a tested implementation preview, not the complete `1.0` described in the +> long-range production plan. Unsupported and higher-risk operations are capability-gated instead +> of being simulated. See [Current limitations](#current-limitations). + +## Why MaterialPilot? + +Procedural material graphs are valuable because they remain editable, reusable, and parameterized. +Conventional text-to-texture workflows often stop at flattened images, while general desktop +automation cannot reliably understand graph ports, project revisions, renderer state, or undo +history. + +MaterialPilot gives agents structured access to the underlying creative system: + +- **Editable output:** the primary artifact is a native `.ptex` graph, not only exported images. +- **Native integration:** graph operations use Material Maker and Godot APIs rather than mouse + coordinates. +- **Observation before mutation:** agents inspect the project, revision, catalog, and capabilities + before editing. +- **Atomic changes:** a graph patch either commits as one undo step or restores its exact pre-state. +- **Deterministic safety:** schemas, port compatibility, path policy, revisions, and validation are + enforced outside the language model. +- **Visual evidence:** live node outputs and sphere/plane previews provide bounded PNG evidence. +- **Verified delivery:** Blender exports include dimensions, file sizes, and SHA-256 hashes. +- **Local-first operation:** native communication is authenticated and bound to loopback. + +## How it works + +```mermaid +flowchart LR + Host["MCP host
Codex · IDE · CLI"] + Server["MaterialPilot MCP server
TypeScript"] + Bridge["Native bridge
GDScript"] + Maker["Material Maker 1.7
Godot 4.7"] + Core["Domain services
schemas · graph engine · validator · security"] + + Host <-->|"MCP over STDIO or authenticated HTTP"| Server + Server <-->|"Authenticated loopback WebSocket JSON-RPC"| Bridge + Bridge <-->|"Native graph, renderer, save, export, undo"| Maker + Server --- Core +``` + +Material Maker writes an authenticated discovery record while the native bridge is running. The +MCP server reads that record, negotiates available capabilities, and connects locally. If no live +bridge is available, the server can fall back to an offline reference engine for catalog, planning, +schema, and transaction work; live-only capabilities remain false. + +Every native graph mutation follows the same boundary: + +```text +inspect revision -> validate patch -> snapshot -> apply operations + -> structural validation -> commit one undo entry + -> emit revision, diff, diagnostics, and evidence + +failure at any point -> restore serialized snapshot -> return a structured error +``` + +For the design rationale, see the [architecture overview](docs/architecture/overview.md) and +[architecture decision records](docs/adr). + +## Capabilities + +| Area | Current support | +| ----------- | ------------------------------------------------------------------------------------------------------------------- | +| Application | Health, status, version, capability, limit, and live/offline-mode discovery | +| Projects | List, inspect, create native material tabs, save `.ptex`, and create named recovery snapshots | +| Catalog | Search and inspect 392 generated Material Maker 1.7 node descriptors, ports, parameters, aliases, and compatibility | +| Graphs | Read canonical snapshots, plan patches, dry-run, apply atomically, diff, validate, undo, and redo | +| Editing | Create, delete, connect, disconnect, move, parameterize, batch-edit, comment, and expose remote controls | +| Layout | Semantic stages, comments, positions, selection, graph organization, and output-preserving repair workflows | +| PBR | Albedo, roughness, metallic, normal, height/depth, ambient occlusion, emission, and related output inspection | +| Preview | Bounded node-output PNGs and renderer-backed sphere/plane material previews | +| Export | Policy-constrained Blender-profile PNG/EXR maps with temporary publication and hash verification | +| Diagnostics | Deterministic validation, transaction history, performance inspection, structured errors, and correlation evidence | +| Workflows | Cracked stone, basalt, sandstone, lava, oxidized metal, and a complete abandoned-industrial-floor workflow | + +The authoritative schema and behavior for each tool are documented in the +[tool reference](docs/tools/core-tools.md). + +## Safety model + +MaterialPilot treats user projects, graph comments, downloaded metadata, file paths, and model +output as untrusted data. + +The default security controls include: + +- loopback-only native and HTTP listeners; +- an ephemeral authenticated bridge token; +- workspace read, write, and export roots; +- canonical path checks and explicit overwrite policy; +- optimistic concurrency through `expectedRevision`; +- idempotency keys for mutations and exports; +- snapshot-first transactions and rollback; +- native undo/redo integration; +- renderer-dependent capability negotiation; +- custom shader writes and network operations disabled by default; and +- stderr-only diagnostics for the STDIO MCP server. + +Choose the narrowest permission mode that fits the workflow: + +| Mode | Behavior | +| ---------------------- | ------------------------------------------------------------------------------------- | +| `observe` | Read-only inspection; mutations require an approval token | +| `assisted` | Default; protected writes require single-use approval | +| `workspace-autonomous` | Allows reversible graph editing inside approved workspace roots | +| `developer` | Broader local development access; custom code and network permissions remain separate | + +Read [SECURITY.md](SECURITY.md) and the [threat model](docs/security/threat-model.md) before enabling +automation in a sensitive workspace. + +## Requirements + +| Component | Supported version | Purpose | +| -------------- | -------------------------- | ------------------------------------------------------- | +| Node.js | 22 or newer | MCP server, CLI, packages, and tests | +| pnpm | 11.9.0 | Source builds and development | +| Material Maker | 1.7 | Live procedural material editing | +| Godot | 4.7 stable | Running or exporting Material Maker from source | +| MCP host | Current compatible release | Codex Desktop, Codex CLI, an IDE, or another MCP client | + +The TypeScript workspace and CI target Windows, Linux, and macOS. The native compatibility suite is +pinned to Material Maker 1.7 and Godot 4.7. Renderer-backed preview and export operations require a +real Godot rendering device and are deliberately unavailable in headless sessions. ## Quick start -From a source checkout: +### 1. Build MaterialPilot ```bash -pnpm install +git clone https://github.com/SS-360/materialpilot.git +cd materialpilot +corepack enable +corepack prepare pnpm@11.9.0 --activate +pnpm install --frozen-lockfile pnpm build +pnpm check +``` + +Run the diagnostic CLI: + +```bash +node packages/cli/dist/main.js doctor +``` + +### 2. Install the native addon + +Close Material Maker before installing or updating the addon. Copy: + +```text +apps/material-maker-bridge/addon/materialpilot +``` + +into the Material Maker 1.7 source project as: + +```text +addons/materialpilot +``` + +For example, in PowerShell: + +```powershell +$materialPilot = 'D:\path\to\materialpilot' +$materialMaker = 'D:\path\to\material-maker-1.7' +$target = Join-Path $materialMaker 'addons\materialpilot' + +New-Item -ItemType Directory -Force $target | Out-Null +Copy-Item -Path (Join-Path $materialPilot 'apps\material-maker-bridge\addon\materialpilot\*') ` + -Destination $target -Recurse -Force +``` + +Open the Material Maker project in Godot and enable **MaterialPilot Native Bridge** under +**Project → Project Settings → Plugins**. The plugin installs this autoload: + +```ini +[autoload] +MaterialPilotBridge="*res://addons/materialpilot/bridge/bridge_server.gd" +``` + +Launch Material Maker normally. While it is running, the default discovery record is: + +- Windows: `%USERPROFILE%\.materialpilot\bridge.json` +- Linux/macOS: `~/.materialpilot/bridge.json` + +The record contains an ephemeral token. Do not publish, commit, or share it. + +Verify the live bridge: + +```bash +node packages/cli/dist/main.js bridge status +``` + +### 3. Connect an MCP client + +STDIO is the recommended transport for one local client. Configure the client to launch Node with +the absolute MCP server path: + +```text +node /absolute/path/to/materialpilot/apps/mcp-server/dist/main.js +``` + +For Codex CLI on Windows: + +```powershell +codex mcp add materialpilot ` + --env MATERIALPILOT_PERMISSION_MODE=workspace-autonomous ` + -- 'C:\Program Files\nodejs\node.exe' ` + 'D:\absolute\path\to\materialpilot\apps\mcp-server\dist\main.js' +``` + +Or add a source checkout directly to `~/.codex/config.toml`: + +```toml +[mcp_servers.materialpilot] +enabled = true +command = 'C:\Program Files\nodejs\node.exe' +args = ['D:\absolute\path\to\materialpilot\apps\mcp-server\dist\main.js'] +cwd = 'D:\absolute\path\to\materialpilot' +startup_timeout_sec = 10 +tool_timeout_sec = 60 + +[mcp_servers.materialpilot.env] +MATERIALPILOT_PERMISSION_MODE = "workspace-autonomous" +``` + +Start Material Maker before the MCP client. If the client started first and entered offline mode, +restart it after the bridge record appears. + +For packaged releases, Linux/macOS configuration, authenticated HTTP transport, and runtime-path +overrides, follow the complete [installation and run guide](docs/user/installation.md). + +## Verify a live session + +Ask the MCP host to inspect status before modifying anything: + +```text +Use MaterialPilot's app_get_status and app_get_capabilities tools. Report whether the native +Material Maker bridge is connected, whether offline mode is active, the Material Maker and Godot +versions, the active project ID, and whether graph.read, graph.write, graph.transactions, +preview.material3d, and export.write are available. Do not change the graph. +``` + +A renderer-backed live session should report values equivalent to: + +```json +{ + "bridgeConnected": true, + "offlineMode": false, + "materialMakerVersion": "1.7", + "godotVersion": "4.7-stable" +} +``` + +If `offlineMode` is `true`, do not expect visible Material Maker changes. See +[Troubleshooting](docs/user/troubleshooting.md). + +## Example agent workflow + +```text +Using MaterialPilot, inspect the active Material Maker project and node catalog. Create an editable, +seamlessly tiling industrial floor with cracked concrete, exposed metal plates, rust, oil stains, +wetness, chipped edges, and dirt concentrated in cracks. + +Create a named recovery snapshot. Organize the graph into labeled Sources, Masks, PBR Surface, +Albedo, Controls, and Output stages. Plan and dry-run the complete patch before applying it as one +revision-safe transaction. Expose artist controls for scale, crack density and depth, metal, rust, +oil, wetness, dirt, wear, and random seed. Run strict validation, render channel and sphere/plane +previews, then export verified Blender maps. Stop and report any unavailable capability or warning. +``` + +MaterialPilot will not infer visual success from graph validation alone. Renderer-backed previews +and exported artifacts remain separate evidence. + +## Flagship production workflow + +The repository includes a complete, executable **Abandoned Industrial Floor** workflow that +exercises the production path end to end: + +1. catalog inspection and structured planning; +2. named recovery snapshots; +3. dry-run and atomic graph patches; +4. semantic stages, comments, and exposed controls; +5. strict deterministic validation; +6. native editable `.ptex` save; +7. 2048 × 2048 Blender channel export; +8. sphere, plane, and channel previews; +9. three controlled variations; +10. dimensions, pixel variance, file size, and SHA-256 verification; and +11. a generated Markdown material guide and artifact manifest. + +Start Material Maker with the bridge enabled, choose a new artifact directory, and run: + +```powershell +$env:MATERIALPILOT_ARTIFACT_ROOT = 'D:\exports\abandoned-industrial-floor-run-01' +node .\scripts\create-abandoned-industrial-floor.mjs +``` + +Artifact directories must be new. The workflow intentionally denies silent overwrites. + +For a faster 512-resolution smoke run of the base material: + +```powershell +$env:MATERIALPILOT_SMOKE = '1' +$env:MATERIALPILOT_ARTIFACT_ROOT = 'D:\exports\abandoned-industrial-floor-smoke-01' +node .\scripts\create-abandoned-industrial-floor.mjs +``` + +## Development and testing + +### Standard repository checks + +```bash +pnpm format +pnpm typecheck +pnpm lint pnpm test -pnpm --filter @materialpilot/cli start -- doctor -pnpm --filter @materialpilot/mcp-server start +pnpm build +``` + +The combined typecheck, lint, and unit/integration gate is: + +```bash +pnpm check +``` + +The suite covers schemas, stable IDs, canonical hashing, catalog compatibility, deterministic +diagnostics, transaction rollback, revision conflicts, idempotency, undo/redo, path policy, +bridge handshake, PTEX/MMPP round trips, CLI behavior, and MCP conformance. + +### Godot bridge contract + +Set `GODOT_BIN` to an official Godot 4.7 executable: + +```powershell +$env:GODOT_BIN = 'C:\path\to\Godot_v4.7-stable.exe' +pnpm test:godot ``` -From a packaged release archive: +This validates GDScript parsing, authenticated bridge initialization, stable IDs, handshake, and +status behavior. + +### Full Material Maker contract + +Install the current addon into a Material Maker 1.7 source checkout, then run: + +```powershell +$env:GODOT_BIN = 'C:\path\to\Godot_v4.7-stable.exe' +$env:MATERIAL_MAKER_PROJECT = 'D:\path\to\material-maker-1.7' +pnpm test:material-maker +``` + +The headless contract verifies graph creation, native parameters, dry-run restoration, commit, +undo, redo, save, and safe rejection of renderer-dependent operations. + +Run the GPU-backed preview and export contract with: + +```powershell +$env:MATERIALPILOT_WINDOWED_TEST = '1' +pnpm test:material-maker +``` + +The windowed contract renders real node output, rejects empty or magenta missing-material previews, +tests sphere and plane capture, and verifies Blender export. The test window is positioned off-screen. + +To run every compatible contract available in the environment: ```bash -node cli/dist/main.js doctor -node server/dist/main.js +pnpm compatibility +``` + +See [Building and testing](docs/development/building.md) for details. + +## Repository structure + +```text +materialpilot/ +├── apps/ +│ ├── mcp-server/ MCP transports, tool schemas, resources, and translation +│ └── material-maker-bridge/ Native Godot/Material Maker addon and contracts +├── packages/ +│ ├── bridge-protocol/ Authenticated native JSON-RPC client and schemas +│ ├── cli/ Doctor, bridge health, lint, and normalization commands +│ ├── domain/ Stable public graph, patch, diagnostic, and capability model +│ ├── graph-engine/ Offline transactions, revisions, idempotency, undo, and redo +│ ├── mmpp/ Material Maker painting-project adapter +│ ├── node-catalog/ Generated and stable node descriptors +│ ├── ptex/ Procedural texture project parsing and round trips +│ ├── security/ Permission, audit, canonical path, and workspace policy +│ └── validator/ Deterministic structural and PBR validation +├── docs/ Architecture, ADRs, guides, security, and tool reference +├── fixtures/ Compatibility and serialization fixtures +├── schemas/ Published JSON-compatible schemas +└── scripts/ Compatibility, release, SBOM, and production workflows ``` -The MCP server uses STDIO by default. It writes diagnostics to stderr and never mixes logs with protocol output. +Architecture boundaries are intentional: + +- `apps/mcp-server` owns MCP transport and translation only. +- `apps/material-maker-bridge` owns Godot and Material Maker integration only. +- `packages/domain` never exposes Godot objects. +- `packages/graph-engine` owns transactions and revision semantics. +- `packages/validator` remains deterministic and LLM-independent. +- `packages/security` owns path and permission decisions. + +## Current limitations + +The following capabilities are intentionally unavailable or incomplete in `0.1.0`: + +- native project open and close mutations; +- export profiles beyond Blender and engine-specific packaging; +- arbitrary panel capture, histogram automation, and automated perceptual comparison; +- asset-library installation and community network operations; +- arbitrary subgraph mutation and generated custom shader writes; +- painting mutations and automated strokes; and +- remote or multi-user deployment beyond authenticated loopback HTTP. + +Painting data is inspection-only, and custom shader writes remain disabled. Renderer-backed +features are unavailable under Godot's headless/dummy renderer. These are explicit capability +results, not silent fallbacks. + +See the [implementation status](docs/development/implementation-status.md) and +[production implementation plan](materialpilot-production-implementation-plan.md) for the roadmap +and stable `1.0` definition. + +## Documentation + +- [Installation and run guide](docs/user/installation.md) +- [First material](docs/user/first-material.md) +- [Troubleshooting](docs/user/troubleshooting.md) +- [Tool reference](docs/tools/core-tools.md) +- [Implementation status](docs/development/implementation-status.md) +- [Architecture overview](docs/architecture/overview.md) +- [Transaction model](docs/architecture/transactions.md) +- [Material Maker 1.7 integration map](docs/architecture/upstream-1.7-map.md) +- [Building and testing](docs/development/building.md) +- [Security threat model](docs/security/threat-model.md) + +## Contributing + +Contributions are welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md), preserve the package +boundaries above, and include tests and documentation with behavior changes. + +Before opening a pull request: + +```bash +pnpm install --frozen-lockfile +pnpm format +pnpm check +pnpm build +``` + +Native bridge changes must also pass `pnpm test:godot` and the affected Material Maker contract. +Mutations require optimistic concurrency, an idempotency key, transaction/rollback support, a +schema, documentation, tests, and a threat classification. + +## Security + +Report vulnerabilities privately to the maintainers. Never attach private projects, bridge tokens, +HTTP bearer tokens, exported proprietary textures, or logs containing sensitive paths to a public +issue. See [SECURITY.md](SECURITY.md) for the supported disclosure process. + +## License + +MaterialPilot is licensed under the [Apache License 2.0](LICENSE). Material Maker remains an +independent upstream project distributed under its own license; retain upstream notices when +redistributing integrated builds. + +## Acknowledgements -See [Tool reference](docs/tools/core-tools.md), [Implementation status](docs/development/implementation-status.md), [Installation](docs/user/installation.md), [Architecture](docs/architecture/overview.md), and [Security](SECURITY.md). +MaterialPilot builds on [Material Maker](https://github.com/RodZill4/material-maker), +[Godot Engine](https://godotengine.org/), and the +[Model Context Protocol](https://modelcontextprotocol.io/). The project is designed to complement +Material Maker's artist-facing workflow while preserving editable graphs and human control.