From eeb4f9044d039a0a5368c39d0ffb67f68f404591 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Thu, 3 Sep 2026 03:15:49 +0900 Subject: [PATCH 01/12] feat: add Blender Bridge tool with a Generative-tab settings panel Unity talks straight to the BlenderMCP addon socket, so a Blender to Unity handoff is one blender_bridge call: export (GLB/FBX) from Blender, import through the shared model pipeline, place in the open scene and normalize the size from measured bounds. Other actions: status, scene/object info, viewport screenshot, run Python in Blender, check the blender-mcp checkout for updates, and sync its addon.py into Blender's addons folder. The informational "Blender -> Unity Handoff" row in the Asset Gen tab becomes a real panel: socket host/port with Test Connection, blender-mcp checkout and Blender addons dir (Select/Clear, resolved path and addon-in-sync state), and Sync Addon / Check Updates / Import Selection buttons. Settings live in EditorPrefs under MCPForUnity.Blender.* via BlenderBridgePrefs; no machine-specific defaults. BlenderDetection gains user addons dir discovery. Menu items under Window/MCP for Unity/Blender Bridge drive the same handler. --- .../Editor/Constants/EditorPrefKeys.cs | 7 + .../Editor/Helpers/BlenderBridgePrefs.cs | 85 +++ .../Editor/Helpers/BlenderBridgePrefs.cs.meta | 11 + .../Editor/Helpers/BlenderDetection.cs | 87 ++- .../Editor/MenuItems/BlenderBridgeMenu.cs | 57 ++ .../MenuItems/BlenderBridgeMenu.cs.meta | 11 + MCPForUnity/Editor/Services/Blender.meta | 8 + .../Services/Blender/BlenderSocketClient.cs | 130 +++++ .../Blender/BlenderSocketClient.cs.meta | 11 + MCPForUnity/Editor/Tools/Blender.meta | 8 + .../Editor/Tools/Blender/BlenderBridgeTool.cs | 527 ++++++++++++++++++ .../Tools/Blender/BlenderBridgeTool.cs.meta | 11 + .../Components/AssetGen/McpAssetGenSection.cs | 52 +- .../AssetGen/McpAssetGenSection.uxml | 40 ++ .../AssetGen/McpBlenderBridgePanel.cs | 269 +++++++++ .../AssetGen/McpBlenderBridgePanel.cs.meta | 11 + 16 files changed, 1282 insertions(+), 43 deletions(-) create mode 100644 MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs create mode 100644 MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs.meta create mode 100644 MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs create mode 100644 MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs.meta create mode 100644 MCPForUnity/Editor/Services/Blender.meta create mode 100644 MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs create mode 100644 MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs.meta create mode 100644 MCPForUnity/Editor/Tools/Blender.meta create mode 100644 MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs create mode 100644 MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs.meta create mode 100644 MCPForUnity/Editor/Windows/Components/AssetGen/McpBlenderBridgePanel.cs create mode 100644 MCPForUnity/Editor/Windows/Components/AssetGen/McpBlenderBridgePanel.cs.meta diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index 6f7ca9f04..01e72b18f 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -86,5 +86,12 @@ internal static class EditorPrefKeys internal const string AssetGenOutputRoot = "MCPForUnity.AssetGen.OutputRoot"; internal const string AssetGenAutoNormalize = "MCPForUnity.AssetGen.AutoNormalize"; internal const string AssetGenProviderEnabledPrefix = "MCPForUnity.AssetGen.Enabled."; + + // Blender Bridge (Asset Gen tab). Machine-specific, non-secret. An empty ForkPath means the + // addon sync / update-check features are not configured; import and screenshot still work. + internal const string BlenderHost = "MCPForUnity.Blender.Host"; + internal const string BlenderPort = "MCPForUnity.Blender.Port"; + internal const string BlenderForkPath = "MCPForUnity.Blender.ForkPath"; + internal const string BlenderAddonsDir = "MCPForUnity.Blender.AddonsDir"; } } diff --git a/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs b/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs new file mode 100644 index 000000000..c66648e24 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs @@ -0,0 +1,85 @@ +using System.IO; +using MCPForUnity.Editor.Constants; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Per-user, NON-SECRET configuration for the Blender Bridge (Asset Gen tab): where the + /// BlenderMCP addon socket listens, where the user's blender-mcp checkout lives, and where + /// Blender keeps its user addons. Nothing here is required for the bridge to talk to Blender; + /// the checkout path only unlocks addon sync and update checks. + /// + public static class BlenderBridgePrefs + { + public const string DefaultHost = "127.0.0.1"; + public const int DefaultPort = 9876; + public const string AddonFileName = "addon.py"; + + public static string Host + { + get => EditorPrefs.GetString(EditorPrefKeys.BlenderHost, DefaultHost); + set => SetOrDelete(EditorPrefKeys.BlenderHost, value); + } + + public static int Port + { + get => EditorPrefs.GetInt(EditorPrefKeys.BlenderPort, DefaultPort); + set => EditorPrefs.SetInt(EditorPrefKeys.BlenderPort, value > 0 && value <= 65535 ? value : DefaultPort); + } + + /// Local checkout of the blender-mcp repository (contains addon.py). Empty = not configured. + public static string ForkPath + { + get => NormalizePath(EditorPrefs.GetString(EditorPrefKeys.BlenderForkPath, string.Empty)); + set => SetOrDelete(EditorPrefKeys.BlenderForkPath, NormalizePath(value)); + } + + /// + /// Blender's user addons directory. Empty = auto-detect the newest + /// <user config>/Blender/<version>/scripts/addons that already has the addon installed. + /// + public static string AddonsDirOverride + { + get => NormalizePath(EditorPrefs.GetString(EditorPrefKeys.BlenderAddonsDir, string.Empty)); + set => SetOrDelete(EditorPrefKeys.BlenderAddonsDir, NormalizePath(value)); + } + + public static bool IsForkConfigured => !string.IsNullOrEmpty(ForkPath); + + public static string ForkAddonPath => IsForkConfigured ? ForkPath + "/" + AddonFileName : null; + + public static string ResolveAddonsDir() + { + string o = AddonsDirOverride; + return !string.IsNullOrEmpty(o) ? o : BlenderDetection.FindUserAddonsDir(AddonFileName); + } + + public static string InstalledAddonPath + { + get + { + string dir = ResolveAddonsDir(); + return string.IsNullOrEmpty(dir) ? null : dir + "/" + AddonFileName; + } + } + + /// True when the checkout path points at a folder that actually contains addon.py. + public static bool IsValidForkPath(string path) + { + string p = NormalizePath(path); + return !string.IsNullOrEmpty(p) && File.Exists(Path.Combine(p, AddonFileName)); + } + + internal static string NormalizePath(string value) + { + return (value ?? string.Empty).Trim().Replace('\\', '/').TrimEnd('/'); + } + + private static void SetOrDelete(string key, string value) + { + if (string.IsNullOrWhiteSpace(value)) EditorPrefs.DeleteKey(key); + else EditorPrefs.SetString(key, value.Trim()); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs.meta b/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs.meta new file mode 100644 index 000000000..5dafa8364 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 029b420c97f34c738e98b875fe56bf73 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/BlenderDetection.cs b/MCPForUnity/Editor/Helpers/BlenderDetection.cs index 6af74e344..9e11ee000 100644 --- a/MCPForUnity/Editor/Helpers/BlenderDetection.cs +++ b/MCPForUnity/Editor/Helpers/BlenderDetection.cs @@ -1,14 +1,16 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using UnityEngine; namespace MCPForUnity.Editor.Helpers { /// - /// Best-effort detection of a locally installed Blender application, for the Asset Gen tab's - /// "Blender → Unity handoff" hint. This finds the Blender APP only — it cannot tell whether the - /// BlenderMCP server is configured in the user's AI client (that lives outside Unity). + /// Best-effort detection of a locally installed Blender application and of Blender's per-user + /// addons folders, for the Asset Gen tab's Blender Bridge. This finds the Blender APP and its + /// config folders only — whether the BlenderMCP addon is running is a socket question answered + /// by the bridge itself. /// internal static class BlenderDetection { @@ -76,5 +78,84 @@ internal static IEnumerable CandidatePaths() } return list; } + + /// + /// Blender's per-user config roots (the folder that holds one subfolder per Blender version): + /// %APPDATA%/Blender Foundation/Blender on Windows, ~/Library/Application Support/Blender on + /// macOS, $XDG_CONFIG_HOME/blender or ~/.config/blender on Linux. + /// + internal static IEnumerable UserConfigRoots() + { + var list = new List(); + switch (Application.platform) + { + case RuntimePlatform.WindowsEditor: + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (!string.IsNullOrEmpty(appData)) + list.Add(Path.Combine(appData, "Blender Foundation", "Blender")); + break; + case RuntimePlatform.OSXEditor: + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(home)) + list.Add(Path.Combine(home, "Library", "Application Support", "Blender")); + break; + case RuntimePlatform.LinuxEditor: + string xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + string linuxHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(xdg)) list.Add(Path.Combine(xdg, "blender")); + else if (!string.IsNullOrEmpty(linuxHome)) list.Add(Path.Combine(linuxHome, ".config", "blender")); + break; + } + return list; + } + + /// <root>/<X.Y>/scripts/addons for every versioned config folder, newest version first. + internal static IEnumerable UserAddonsDirs() + { + var found = new List<(Version Ver, string Dir)>(); + foreach (string root in UserConfigRoots()) + { + try + { + if (!Directory.Exists(root)) continue; + foreach (string d in Directory.GetDirectories(root)) + { + Version v = ParseVersion(Path.GetFileName(d)); + if (v != null) found.Add((v, Path.Combine(d, "scripts", "addons"))); + } + } + catch { /* unreadable dir; ignore */ } + } + return found.OrderByDescending(x => x.Ver).Select(x => x.Dir.Replace('\\', '/')).ToList(); + } + + /// Newest user addons dir that already contains , else the newest one, else null. + internal static string FindUserAddonsDir(string fileName) + { + try { return PickAddonsDir(UserAddonsDirs(), File.Exists, fileName); } + catch { return null; } + } + + /// Pure core of . Testable. + internal static string PickAddonsDir(IEnumerable dirsNewestFirst, Func fileExists, string fileName) + { + if (dirsNewestFirst == null) return null; + string first = null; + foreach (string d in dirsNewestFirst) + { + if (string.IsNullOrEmpty(d)) continue; + first ??= d; + if (!string.IsNullOrEmpty(fileName) && fileExists != null && fileExists(d.TrimEnd('/') + "/" + fileName)) + return d; + } + return first; + } + + /// Parses a Blender version folder name ("4.2", "5.2") into a comparable Version; null if it is not one. + internal static Version ParseVersion(string name) + { + if (string.IsNullOrEmpty(name)) return null; + return Version.TryParse(name.Contains('.') ? name : name + ".0", out Version v) ? v : null; + } } } diff --git a/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs b/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs new file mode 100644 index 000000000..dcd111494 --- /dev/null +++ b/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs @@ -0,0 +1,57 @@ +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools.Blender; +using MCPForUnity.Editor.Windows; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.MenuItems +{ + /// + /// Menu entry points that drive the same code path as the blender_bridge tool, so the + /// Blender handoff works without any AI client attached. Settings live in the Generative tab. + /// + public static class BlenderBridgeMenu + { + private const string Root = ProductInfo.MenuRoot + "/Blender Bridge/"; + + [MenuItem(Root + "Import Selection From Blender (GLB)", priority = 20)] + private static void ImportSelection() + { + Run(new JObject { ["action"] = "import_model", ["selection_only"] = true, ["format"] = "glb" }); + } + + [MenuItem(Root + "Import Whole Scene From Blender (GLB)", priority = 21)] + private static void ImportScene() + { + Run(new JObject { ["action"] = "import_model", ["format"] = "glb", ["name"] = "BlenderScene" }); + } + + [MenuItem(Root + "Blender Viewport Screenshot", priority = 22)] + private static void Screenshot() + { + JObject r = Run(new JObject { ["action"] = "screenshot" }); + string path = r?["data"]?["path"]?.ToString(); + if (!string.IsNullOrEmpty(path)) EditorUtility.RevealInFinder(path); + } + + [MenuItem(Root + "Settings...", priority = 40)] + private static void OpenSettings() + { + MCPForUnityEditorWindow.ShowWindow(); + McpLog.Info("Blender Bridge settings are in the Generative tab of the MCP for Unity window."); + } + + private static JObject Run(JObject parameters) + { + object result = BlenderBridgeTool.HandleCommand(parameters); + JObject json = JObject.FromObject(result); + bool ok = json.Value("success") ?? false; + string text = json.ToString(Formatting.Indented); + if (ok) McpLog.Info($"[Blender Bridge] {parameters["action"]}: {json["message"]}\n{text}"); + else McpLog.Error($"[Blender Bridge] {parameters["action"]} failed: {json["error"]}\n{text}"); + return json; + } + } +} diff --git a/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs.meta b/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs.meta new file mode 100644 index 000000000..b490058fb --- /dev/null +++ b/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cf5a30eff9b34262b2e15cc23f196d12 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Blender.meta b/MCPForUnity/Editor/Services/Blender.meta new file mode 100644 index 000000000..c2029bf05 --- /dev/null +++ b/MCPForUnity/Editor/Services/Blender.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5171d93ea027470b96c8930306ca0c60 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs b/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs new file mode 100644 index 000000000..d3acfd36a --- /dev/null +++ b/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs @@ -0,0 +1,130 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Text; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.Blender +{ + /// + /// Minimal client for the BlenderMCP addon socket. Protocol: one JSON object + /// {"type": ..., "params": {...}} per request and one JSON object + /// {"status": "success"|"error", "result"|"message": ...} back, with no length framing — + /// the addon parses whenever the accumulated bytes form valid JSON, so this does the same. + /// A fresh connection is used per command so it never interleaves with the AI client's + /// own BlenderMCP server talking to the same addon. + /// + public static class BlenderSocketClient + { + private const int ConnectTimeoutSeconds = 3; + + public static JToken Send(string type, JObject @params = null, int timeoutSeconds = 60) + { + string host = BlenderBridgePrefs.Host; + int port = BlenderBridgePrefs.Port; + + var request = new JObject { ["type"] = type, ["params"] = @params ?? new JObject() }; + byte[] payload = Encoding.UTF8.GetBytes(request.ToString(Formatting.None)); + + using var client = new TcpClient(); + IAsyncResult connect = client.BeginConnect(host, port, null, null); + if (!connect.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(ConnectTimeoutSeconds)) || !client.Connected) + { + throw new BlenderUnavailableException( + $"Blender addon not reachable at {host}:{port}. Start Blender and press " + + "'Connect to MCP server' in the BlenderMCP sidebar (N panel)."); + } + client.EndConnect(connect); + + using NetworkStream stream = client.GetStream(); + stream.ReadTimeout = Math.Max(1, timeoutSeconds) * 1000; + stream.Write(payload, 0, payload.Length); + stream.Flush(); + + var buffer = new MemoryStream(); + var chunk = new byte[65536]; + DateTime deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + + while (true) + { + int n; + try + { + n = stream.Read(chunk, 0, chunk.Length); + } + catch (IOException e) + { + throw new TimeoutException( + $"Timed out after {timeoutSeconds}s waiting for Blender to answer '{type}'.", e); + } + + if (n <= 0) break; + buffer.Write(chunk, 0, n); + + if (TryParseResponse(buffer.GetBuffer(), (int)buffer.Length, out JObject parsed)) return Unwrap(parsed, type); + if (DateTime.UtcNow > deadline) + throw new TimeoutException($"Timed out after {timeoutSeconds}s waiting for Blender to answer '{type}'."); + } + + if (TryParseResponse(buffer.GetBuffer(), (int)buffer.Length, out JObject final)) return Unwrap(final, type); + throw new IOException($"Blender closed the connection before a complete response to '{type}' arrived."); + } + + /// Runs Python inside Blender and returns its captured stdout. + public static string RunPython(string code, int timeoutSeconds = 120) + { + JToken result = Send("execute_code", new JObject { ["code"] = code }, timeoutSeconds); + return result?["result"]?.ToString() ?? string.Empty; + } + + public static bool IsReachable(out string error) + { + try + { + Send("get_scene_info", null, 10); + error = null; + return true; + } + catch (Exception e) + { + error = e.Message; + return false; + } + } + + /// True once the bytes received so far form one complete JSON object. + internal static bool TryParseResponse(byte[] buffer, int length, out JObject obj) + { + try + { + obj = JObject.Parse(Encoding.UTF8.GetString(buffer, 0, length)); + return true; + } + catch + { + obj = null; + return false; + } + } + + /// Returns the addon's "result" payload, or throws when it reported an error. + internal static JToken Unwrap(JObject response, string type) + { + if (string.Equals((string)response["status"], "error", StringComparison.OrdinalIgnoreCase)) + throw new BlenderCommandException($"Blender '{type}' failed: {(string)response["message"] ?? "unknown error"}"); + return response["result"]; + } + } + + public class BlenderUnavailableException : Exception + { + public BlenderUnavailableException(string message) : base(message) { } + } + + public class BlenderCommandException : Exception + { + public BlenderCommandException(string message) : base(message) { } + } +} diff --git a/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs.meta b/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs.meta new file mode 100644 index 000000000..73c1144e0 --- /dev/null +++ b/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6c83ed8aaae34a829fc59df7c1a53948 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Blender.meta b/MCPForUnity/Editor/Tools/Blender.meta new file mode 100644 index 000000000..2f3497027 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Blender.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d61fccfc880e4bd38412f8705fc73104 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs b/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs new file mode 100644 index 000000000..3a9267fef --- /dev/null +++ b/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs @@ -0,0 +1,527 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.Blender; +using MCPForUnity.Editor.Tools.AssetGen; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Blender +{ + /// + /// Lets the Unity Editor talk straight to the BlenderMCP addon socket, so a Blender → Unity + /// handoff is one call (export → import through the shared model pipeline → place → normalize) + /// instead of an AI-orchestrated multi-step dance. Also drives the Blender Bridge panel in the + /// Asset Gen tab and the Window/MCP for Unity/Blender Bridge menu. Carries no API keys. + /// + [McpForUnityTool("blender_bridge", AutoRegister = false, Group = "asset_gen", + Description = "Bridge to a running Blender with the BlenderMCP addon: status, scene/object info, viewport " + + "screenshot, run Python in Blender, import a model (export → import → place → normalize), " + + "check the blender-mcp checkout for updates, and sync its addon into Blender.")] + public static class BlenderBridgeTool + { + private static readonly string[] ValidActions = + { "status", "scene_info", "object_info", "screenshot", "run_python", "import_model", "check_updates", "sync_addon" }; + + private const string NotConfiguredMessage = + "The blender-mcp checkout is not set. Open Window > MCP for Unity > Generative > Blender Bridge " + + "and pick the folder that contains addon.py."; + + public static object HandleCommand(JObject @params) + { + if (@params == null) return new ErrorResponse("Parameters cannot be null."); + var p = new ToolParams(@params); + string action = (p.Get("action") ?? "status").Trim().ToLowerInvariant(); + int timeout = Math.Max(5, p.GetInt("timeout_seconds", 180) ?? 180); + + try + { + switch (action) + { + case "status": return Status(); + case "scene_info": + return new SuccessResponse("Retrieved Blender scene info.", + BlenderSocketClient.Send("get_scene_info", null, timeout)); + case "object_info": + { + string objectName = p.Get("object_name"); + if (string.IsNullOrWhiteSpace(objectName)) + return new ErrorResponse("'object_name' is required for object_info."); + return new SuccessResponse($"Retrieved info for '{objectName}'.", + BlenderSocketClient.Send("get_object_info", new JObject { ["object_name"] = objectName }, timeout)); + } + case "screenshot": return Screenshot(p, timeout); + case "run_python": + { + string code = p.Get("code"); + if (string.IsNullOrWhiteSpace(code)) return new ErrorResponse("'code' is required for run_python."); + string stdout = BlenderSocketClient.RunPython(code, timeout); + return new SuccessResponse("Executed Python in Blender.", new { stdout }); + } + case "import_model": return ImportModel(p, timeout); + case "check_updates": return CheckUpdates(); + case "sync_addon": return SyncAddon(p.GetBool("force", false)); + default: + return new ErrorResponse($"Unknown action '{action}'. Valid: {string.Join(", ", ValidActions)}."); + } + } + catch (BlenderUnavailableException e) + { + return new ErrorResponse(e.Message); + } + catch (Exception e) + { + return new ErrorResponse(SecretRedactor.Scrub($"blender_bridge '{action}' failed: {e.Message}")); + } + } + + // ------------------------------------------------------------------ status + + private static object Status() + { + bool reachable = BlenderSocketClient.IsReachable(out string error); + JToken scene = null; + if (reachable) + { + try { scene = BlenderSocketClient.Send("get_scene_info", null, 10); } + catch (Exception e) { error = e.Message; } + } + + string forkAddon = BlenderBridgePrefs.ForkAddonPath; + string installedAddon = BlenderBridgePrefs.InstalledAddonPath; + string forkMd5 = forkAddon != null && File.Exists(forkAddon) ? FileMd5(forkAddon) : null; + string installedMd5 = installedAddon != null && File.Exists(installedAddon) ? FileMd5(installedAddon) : null; + + var data = new JObject + { + ["blender_reachable"] = reachable, + ["blender_installed"] = BlenderDetection.IsInstalled(), + ["endpoint"] = $"{BlenderBridgePrefs.Host}:{BlenderBridgePrefs.Port}", + ["error"] = reachable ? null : error, + ["scene_name"] = scene?["name"], + ["object_count"] = scene?["object_count"], + ["fork_configured"] = BlenderBridgePrefs.IsForkConfigured, + ["fork_path"] = BlenderBridgePrefs.ForkPath, + ["fork_addon_found"] = forkMd5 != null, + ["installed_addon_path"] = installedAddon, + ["installed_addon_found"] = installedMd5 != null, + ["addon_in_sync"] = forkMd5 != null && installedMd5 != null && forkMd5 == installedMd5, + }; + string msg = reachable + ? $"Blender reachable. Scene '{scene?["name"]}' with {scene?["object_count"]} objects." + : "Blender not reachable."; + return new SuccessResponse(msg, data); + } + + // -------------------------------------------------------------- screenshot + + private static object Screenshot(ToolParams p, int timeout) + { + int maxSize = Math.Max(64, p.GetInt("max_size", 1000) ?? 1000); + string outputFolder = p.Get("output_folder"); + + string dir = Path.Combine(ProjectRoot(), "Library", "BlenderBridge"); + Directory.CreateDirectory(dir); + string file = Path.Combine(dir, $"blender_viewport_{DateTime.Now:yyyyMMdd_HHmmss}.png").Replace('\\', '/'); + + JToken result = BlenderSocketClient.Send("get_viewport_screenshot", + new JObject { ["max_size"] = maxSize, ["filepath"] = file, ["format"] = "png" }, timeout); + + if (!File.Exists(file)) + return new ErrorResponse($"Blender did not write a screenshot to {file}. Response: {result}"); + + string assetPath = null; + if (!string.IsNullOrWhiteSpace(outputFolder)) + { + string rel = outputFolder.Replace('\\', '/').TrimEnd('/'); + if (!rel.StartsWith("Assets/") && rel != "Assets") + return new ErrorResponse("'output_folder' must be under Assets/."); + Directory.CreateDirectory(Path.Combine(ProjectRoot(), rel)); + assetPath = $"{rel}/{Path.GetFileName(file)}"; + File.Copy(file, Path.Combine(ProjectRoot(), assetPath), true); + AssetDatabase.ImportAsset(assetPath); + } + + return new SuccessResponse("Captured Blender viewport.", new JObject + { + ["path"] = file, + ["asset_path"] = assetPath, + ["width"] = result?["width"], + ["height"] = result?["height"], + ["method"] = result?["method"], + }); + } + + // ------------------------------------------------------------ import_model + + private static object ImportModel(ToolParams p, int timeout) + { + string fmt = (p.Get("format") ?? "glb").Trim().ToLowerInvariant(); + if (fmt != "glb" && fmt != "fbx") return new ErrorResponse("'format' must be glb or fbx."); + + string[] names = p.GetStringArray("object_names")?.Where(n => !string.IsNullOrWhiteSpace(n)).ToArray(); + bool selectionOnly = p.GetBool("selection_only", false); + bool applyModifiers = p.GetBool("apply_modifiers", true); + bool place = p.GetBool("place_in_scene", true); + float target = p.GetFloat("target_size", 0f) ?? 0f; + string outputFolder = p.Get("output_folder"); + string animationType = p.Get("animation_type"); + + string name = p.Get("name"); + if (string.IsNullOrWhiteSpace(name)) + name = names != null && names.Length == 1 ? names[0] : "BlenderModel"; + name = SanitizeName(name); + + // 1. Export from Blender to a temp file. + string exportDir = Path.Combine(Path.GetTempPath(), "BlenderBridge"); + Directory.CreateDirectory(exportDir); + string exportPath = Path.Combine(exportDir, $"{name}_{DateTime.Now:yyyyMMdd_HHmmss}.{fmt}").Replace('\\', '/'); + + string script = BuildExportScript(exportPath, names, selectionOnly, applyModifiers, fmt); + string stdout = BlenderSocketClient.RunPython(script, timeout); + + if (!File.Exists(exportPath)) + return new ErrorResponse($"Blender did not produce {exportPath}. Blender output: {Truncate(stdout, 800)}"); + + // 2. Import through the shared pipeline (staging under Assets/, glTFast/FBX, material setup). + var importParams = new JObject + { + ["sourcePath"] = exportPath, + ["name"] = name, + ["targetSize"] = target > 0f ? target : 1f, + }; + if (!string.IsNullOrWhiteSpace(outputFolder)) importParams["outputFolder"] = outputFolder; + if (!string.IsNullOrWhiteSpace(animationType)) importParams["animationType"] = animationType; + + JObject importResult = JObject.FromObject(ImportModelFile.HandleCommand(importParams)); + if (!(importResult.Value("success") ?? false)) + return new ErrorResponse($"Import failed: {importResult["error"] ?? importResult["message"]}"); + + string assetPath = importResult["data"]?["asset_path"]?.ToString(); + var data = new JObject + { + ["asset_path"] = assetPath, + ["asset_guid"] = importResult["data"]?["asset_guid"], + ["export_path"] = exportPath, + ["format"] = fmt, + ["blender_output"] = Truncate(stdout, 400), + ["placed"] = false, + }; + + if (!place || string.IsNullOrEmpty(assetPath)) + return new SuccessResponse($"Imported {assetPath} (not placed).", data); + + // 3. Place in the open scene and normalize size from measured bounds. Blender exports + // commonly land far off scale, so measuring the placed instance beats trusting the importer. + GameObject prefab = AssetDatabase.LoadAssetAtPath(assetPath); + if (prefab == null) + { + data["note"] = "Asset has no GameObject root to instantiate."; + return new SuccessResponse($"Imported {assetPath} but could not instantiate it.", data); + } + + GameObject go = PrefabUtility.InstantiatePrefab(prefab) as GameObject ?? UnityEngine.Object.Instantiate(prefab); + go.name = name; + Undo.RegisterCreatedObjectUndo(go, "Import from Blender"); + go.transform.position = ParsePosition(p.GetRaw("position")); + + float scaleFactor = 1f; + if (target > 0f && TryGetWorldBounds(go, out Bounds b0)) + { + float maxDim = Mathf.Max(b0.size.x, Mathf.Max(b0.size.y, b0.size.z)); + if (maxDim > 1e-4f) + { + scaleFactor = target / maxDim; + go.transform.localScale *= scaleFactor; + } + } + + EditorSceneManager.MarkSceneDirty(go.scene); + Selection.activeGameObject = go; + + data["placed"] = true; + data["game_object"] = go.name; + data["scene"] = go.scene.name; + data["scale_factor_applied"] = scaleFactor; + if (TryGetWorldBounds(go, out Bounds b1)) + { + data["bounds_size"] = new JArray(b1.size.x, b1.size.y, b1.size.z); + data["bounds_center"] = new JArray(b1.center.x, b1.center.y, b1.center.z); + } + return new SuccessResponse($"Imported {assetPath} and placed '{go.name}' in the scene.", data); + } + + private static string BuildExportScript(string outPath, string[] names, bool selectionOnly, bool applyModifiers, string fmt) + { + string namesLiteral = names == null || names.Length == 0 + ? "[]" + : new JArray(names.Cast().ToArray()).ToString(Formatting.None); + + const string template = @" +import bpy, os, json +out = r'__OUT__' +names = __NAMES__ +selection_only = __SELONLY__ +apply_mods = __APPLY__ +fmt = '__FMT__' +os.makedirs(os.path.dirname(out), exist_ok=True) +try: + if bpy.context.object and bpy.context.object.mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') +except Exception: + pass +use_sel = False +if names: + missing = [n for n in names if bpy.data.objects.get(n) is None] + if missing: + raise Exception('Objects not found in Blender: ' + ', '.join(missing)) + bpy.ops.object.select_all(action='DESELECT') + for n in names: + o = bpy.data.objects[n] + o.select_set(True) + for c in o.children_recursive: + c.select_set(True) + bpy.context.view_layer.objects.active = bpy.data.objects[names[0]] + use_sel = True +elif selection_only: + if not bpy.context.selected_objects: + raise Exception('Nothing is selected in Blender and no object_names were given.') + use_sel = True +if fmt == 'glb': + bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', use_selection=use_sel, + use_active_scene=True, export_apply=apply_mods, + export_animations=True, export_skins=True, export_morph=True, + export_yup=True) +else: + bpy.ops.export_scene.fbx(filepath=out, use_selection=use_sel, apply_unit_scale=True, + bake_space_transform=apply_mods, use_mesh_modifiers=apply_mods, + path_mode='COPY', embed_textures=True) +print(json.dumps({'path': out, 'bytes': os.path.getsize(out), 'selection_only': use_sel, + 'exported': [o.name for o in (bpy.context.selected_objects if use_sel else bpy.context.scene.objects)]})) +"; + return template + .Replace("__OUT__", outPath) + .Replace("__NAMES__", namesLiteral) + .Replace("__SELONLY__", selectionOnly ? "True" : "False") + .Replace("__APPLY__", applyModifiers ? "True" : "False") + .Replace("__FMT__", fmt); + } + + // ----------------------------------------------------------- check_updates + + private static object CheckUpdates() + { + if (!BlenderBridgePrefs.IsForkConfigured) return new ErrorResponse(NotConfiguredMessage); + string fork = BlenderBridgePrefs.ForkPath; + if (!Directory.Exists(Path.Combine(fork, ".git"))) + return new ErrorResponse($"'{fork}' is not a git checkout (no .git folder); check_updates needs one."); + + if (!TryGit(fork, "--version", out _, out string gitErr, 10000)) + return new ErrorResponse($"git is not available: {gitErr}"); + + TryGit(fork, "log -1 --format=%h%x09%ad%x09%s --date=short", out string head, out _); + TryGit(fork, "status --porcelain", out string porcelain, out _); + TryGit(fork, "remote", out string remotesRaw, out _); + var remotes = remotesRaw.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) + .Select(r => r.Trim()).Where(r => r.Length > 0).ToList(); + + var perRemote = new JArray(); + int totalBehind = 0; + foreach (string remote in remotes.OrderBy(r => r == "upstream" ? 0 : r == "origin" ? 1 : 2)) + { + var entry = new JObject { ["remote"] = remote }; + TryGit(fork, $"remote get-url {remote}", out string url, out _); + entry["url"] = url.Trim(); + + bool fetched = TryGit(fork, $"fetch --quiet {remote}", out _, out string fetchErr, 90000); + entry["fetched"] = fetched; + if (!fetched) entry["fetch_error"] = Truncate(fetchErr, 300); + + string branch = "main"; + if (TryGit(fork, $"symbolic-ref --short refs/remotes/{remote}/HEAD", out string sym, out _) && sym.Trim().Contains('/')) + branch = sym.Trim().Substring(sym.Trim().IndexOf('/') + 1); + else if (!TryGit(fork, $"rev-parse --verify --quiet refs/remotes/{remote}/main", out _, out _) + && TryGit(fork, $"rev-parse --verify --quiet refs/remotes/{remote}/master", out _, out _)) + branch = "master"; + entry["branch"] = branch; + + if (TryGit(fork, $"rev-list --left-right --count HEAD...{remote}/{branch}", out string counts, out string cErr)) + { + var parts = counts.Trim().Split('\t', ' '); + int ahead = parts.Length > 0 && int.TryParse(parts[0], out int a) ? a : 0; + int behind = parts.Length > 1 && int.TryParse(parts[1], out int bb) ? bb : 0; + entry["local_ahead"] = ahead; + entry["behind"] = behind; + if (remote == "upstream" || remotes.Count == 1) totalBehind += behind; + + TryGit(fork, $"log --format=%h%x20%ad%x20%s --date=short -n 20 HEAD..{remote}/{branch}", out string log, out _); + entry["new_commits"] = new JArray(log.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)); + } + else + { + entry["error"] = Truncate(cErr, 300); + } + perRemote.Add(entry); + } + + string forkAddon = BlenderBridgePrefs.ForkAddonPath; + string installedAddon = BlenderBridgePrefs.InstalledAddonPath; + string forkMd5 = File.Exists(forkAddon) ? FileMd5(forkAddon) : null; + string installedMd5 = installedAddon != null && File.Exists(installedAddon) ? FileMd5(installedAddon) : null; + bool addonInSync = forkMd5 != null && forkMd5 == installedMd5; + + int dirty = porcelain.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).Length; + var recommendations = new List(); + if (totalBehind > 0) recommendations.Add($"Checkout is {totalBehind} commit(s) behind upstream: merge upstream into it."); + if (!addonInSync) recommendations.Add("Installed Blender addon differs from the checkout's addon.py: run sync_addon, then restart Blender."); + if (dirty > 0) recommendations.Add($"Checkout has {dirty} uncommitted change(s)."); + if (recommendations.Count == 0) recommendations.Add("Everything is up to date."); + + var data = new JObject + { + ["fork_path"] = fork, + ["head"] = head.Trim(), + ["uncommitted_changes"] = dirty, + ["remotes"] = perRemote, + ["fork_addon_md5"] = forkMd5, + ["installed_addon_path"] = installedAddon, + ["installed_addon_md5"] = installedMd5, + ["addon_in_sync"] = addonInSync, + ["recommendations"] = new JArray(recommendations), + }; + return new SuccessResponse(string.Join(" ", recommendations), data); + } + + // -------------------------------------------------------------- sync_addon + + private static object SyncAddon(bool force) + { + if (!BlenderBridgePrefs.IsForkConfigured) return new ErrorResponse(NotConfiguredMessage); + string src = BlenderBridgePrefs.ForkAddonPath; + string dst = BlenderBridgePrefs.InstalledAddonPath; + if (!File.Exists(src)) return new ErrorResponse($"addon.py not found in the checkout at {src}."); + if (dst == null) + return new ErrorResponse("Could not locate Blender's user addons directory. Set it in Window > MCP for Unity > Generative > Blender Bridge."); + + string srcMd5 = FileMd5(src); + string dstMd5 = File.Exists(dst) ? FileMd5(dst) : null; + if (!force && srcMd5 == dstMd5) + return new SuccessResponse("Installed addon already matches the checkout; nothing copied.", + new { source = src, destination = dst, md5 = srcMd5, copied = false }); + + Directory.CreateDirectory(Path.GetDirectoryName(dst)); + string backup = null; + if (File.Exists(dst)) + { + backup = dst + ".bak"; + File.Copy(dst, backup, true); + } + File.Copy(src, dst, true); + + return new SuccessResponse( + "Copied addon.py into Blender. Restart Blender (or Reload Scripts) and press 'Connect to MCP server' again.", + new { source = src, destination = dst, backup, previous_md5 = dstMd5, new_md5 = srcMd5, copied = true }); + } + + // ------------------------------------------------------------------ helpers + + private static bool TryGit(string workingDir, string args, out string stdout, out string stderr, int timeoutMs = 30000) + { + stdout = string.Empty; + stderr = string.Empty; + try + { + var psi = new ProcessStartInfo("git", args) + { + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + psi.EnvironmentVariables["GIT_TERMINAL_PROMPT"] = "0"; + using var proc = Process.Start(psi); + if (proc == null) { stderr = "failed to start git"; return false; } + var outTask = proc.StandardOutput.ReadToEndAsync(); + var errTask = proc.StandardError.ReadToEndAsync(); + if (!proc.WaitForExit(timeoutMs)) + { + try { proc.Kill(); } catch { /* already gone */ } + stderr = $"git {args} timed out after {timeoutMs} ms"; + return false; + } + stdout = outTask.Result; + stderr = errTask.Result; + return proc.ExitCode == 0; + } + catch (Exception e) + { + stderr = e.Message; + return false; + } + } + + private static string ProjectRoot() => Path.GetDirectoryName(Application.dataPath).Replace('\\', '/'); + + /// Lower-case hex MD5 of a file; shared with the Asset Gen panel's addon-sync indicator. + internal static string FileMd5(string path) + { + using var md5 = MD5.Create(); + using var fs = File.OpenRead(path); + return BitConverter.ToString(md5.ComputeHash(fs)).Replace("-", "").ToLowerInvariant(); + } + + private static string SanitizeName(string raw) + { + var invalid = Path.GetInvalidFileNameChars(); + var sb = new StringBuilder(); + foreach (char c in raw.Trim()) + sb.Append(invalid.Contains(c) || c == '/' || c == '\\' ? '_' : c); + string s = sb.ToString().Trim('_', '.', ' '); + return string.IsNullOrEmpty(s) ? "BlenderModel" : s; + } + + private static Vector3 ParsePosition(JToken token) + { + if (token == null || token.Type == JTokenType.Null) return Vector3.zero; + JArray arr = token as JArray; + if (arr == null && token.Type == JTokenType.String) + { + try { arr = JArray.Parse(token.ToString()); } catch { return Vector3.zero; } + } + if (arr == null || arr.Count < 3) return Vector3.zero; + return new Vector3(arr[0].Value(), arr[1].Value(), arr[2].Value()); + } + + private static bool TryGetWorldBounds(GameObject go, out Bounds bounds) + { + var renderers = go.GetComponentsInChildren(true); + if (renderers.Length == 0) + { + bounds = default; + return false; + } + bounds = renderers[0].bounds; + for (int i = 1; i < renderers.Length; i++) bounds.Encapsulate(renderers[i].bounds); + return true; + } + + private static string Truncate(string s, int max) + { + if (string.IsNullOrEmpty(s)) return s ?? string.Empty; + s = s.Trim(); + return s.Length <= max ? s : s.Substring(0, max) + "…"; + } + } +} diff --git a/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs.meta b/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs.meta new file mode 100644 index 000000000..34c3f064f --- /dev/null +++ b/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b800c597408744668544a1b31f757ddd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs index 6b0e6d113..331949303 100644 --- a/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs +++ b/MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.cs @@ -13,7 +13,9 @@ namespace MCPForUnity.Editor.Windows.Components.AssetGen /// Controller for the AI Asset Generation settings tab. This tab is CONFIG ONLY: /// it lets users enter/clear per-provider API keys, toggle providers on/off, /// presence-check a key, and set non-secret generation preferences. - /// Generation itself is never triggered here — only via MCP tools / CLI. + /// Generation itself is never triggered here — only via MCP tools / CLI. The one exception is + /// the Blender Bridge block (): its buttons run local socket + /// and file operations against a Blender on this machine, never a paid provider call. /// /// Keys are written to the OS secure store (), never to /// EditorPrefs or the project. The stored key is never read back into the field; only @@ -44,6 +46,7 @@ private static readonly (string Id, string Label)[] ImageProviders = private Toggle autoNormalizeToggle; private Button refreshButton; private Label refreshStatusLabel; + private McpBlenderBridgePanel blenderPanel; // Per-provider enable toggles for the GLB-capable (model) providers, used to // recompute the glTFast notice when a toggle changes. @@ -68,6 +71,9 @@ private void CacheUIElements() autoNormalizeToggle = Root.Q("assetgen-auto-normalize"); refreshButton = Root.Q