diff --git a/CLAUDE.md b/CLAUDE.md index d95638a4f..815a255ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ from services.registry import mcp_for_unity_tool @mcp_for_unity_tool( description="Does something in Unity.", - group="core", # core (default), vfx, animation, ui, scripting_ext, testing, probuilder, profiling, docs + group="core", # core (default), vfx, animation, ui, scripting_ext, testing, probuilder, profiling, docs, asset_gen ) async def manage_something( ctx: Context, 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..c3af3662f --- /dev/null +++ b/MCPForUnity/Editor/Helpers/BlenderBridgePrefs.cs @@ -0,0 +1,93 @@ +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Services.Blender; +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. EditorPrefs is main-thread + /// only, so callers capture what they need (e.g. ) before going async. + /// + 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); + } + + /// Host and port as one value, safe to hand to a background thread. + public static BlenderEndpoint Endpoint => new BlenderEndpoint(Host, Port); + + /// 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; + + /// The override when set, otherwise the newest detected user addons folder; null if none. + 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)); + } + + /// Trims, converts backslashes to slashes and drops a trailing slash. + internal static string NormalizePath(string value) + { + return (value ?? string.Empty).Trim().Replace('\\', '/').TrimEnd('/'); + } + + /// Stores a string pref, deleting the key when the value is blank so defaults apply. + 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..efb888d18 --- /dev/null +++ b/MCPForUnity/Editor/MenuItems/BlenderBridgeMenu.cs @@ -0,0 +1,64 @@ +using System.Threading.Tasks; +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. + /// Actions are awaited so the editor stays responsive while Blender exports. + /// + public static class BlenderBridgeMenu + { + private const string Root = ProductInfo.MenuRoot + "/Blender Bridge/"; + + /// Exports Blender's current selection as GLB and places it in the open scene. + [MenuItem(Root + "Import Selection From Blender (GLB)", priority = 20)] + private static async void ImportSelection() + { + await RunAsync(new JObject { ["action"] = "import_model", ["selection_only"] = true, ["format"] = "glb" }); + } + + /// Exports Blender's whole scene as GLB and places it in the open scene. + [MenuItem(Root + "Import Whole Scene From Blender (GLB)", priority = 21)] + private static async void ImportScene() + { + await RunAsync(new JObject { ["action"] = "import_model", ["format"] = "glb", ["name"] = "BlenderScene" }); + } + + /// Captures Blender's viewport and reveals the PNG. + [MenuItem(Root + "Blender Viewport Screenshot", priority = 22)] + private static async void Screenshot() + { + JObject r = await RunAsync(new JObject { ["action"] = "screenshot" }); + string path = r?["data"]?["path"]?.ToString(); + if (!string.IsNullOrEmpty(path)) EditorUtility.RevealInFinder(path); + } + + /// Opens the MCP for Unity window; the Blender Bridge panel is in its Generative tab. + [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."); + } + + /// Runs one bridge action and logs its full result. + private static async Task RunAsync(JObject parameters) + { + object result = await 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..18caa245c --- /dev/null +++ b/MCPForUnity/Editor/Services/Blender/BlenderSocketClient.cs @@ -0,0 +1,171 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Services.Blender +{ + /// Where the BlenderMCP addon socket listens. Captured on the main thread so I/O can run elsewhere. + public readonly struct BlenderEndpoint + { + public string Host { get; } + public int Port { get; } + + public BlenderEndpoint(string host, int port) + { + Host = host; + Port = port; + } + + /// Formats the endpoint as host:port. + public override string ToString() => $"{Host}:{Port}"; + } + + /// + /// 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. The synchronous methods block and touch + /// no Unity API, so callers run them through the *Async wrappers off the editor thread. + /// + public static class BlenderSocketClient + { + private const int ConnectTimeoutSeconds = 3; + + /// Sends one command and blocks until the addon answers or the timeout elapses. + public static JToken Send(BlenderEndpoint endpoint, string type, JObject @params = null, int timeoutSeconds = 60) + { + 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(endpoint.Host, endpoint.Port, null, null); + if (!connect.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(ConnectTimeoutSeconds)) || !client.Connected) + { + throw new BlenderUnavailableException( + $"Blender addon not reachable at {endpoint}. 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 on the thread pool so the editor stays responsive. + public static Task SendAsync(BlenderEndpoint endpoint, string type, JObject @params = null, int timeoutSeconds = 60) + { + return Task.Run(() => Send(endpoint, type, @params, timeoutSeconds)); + } + + /// Runs Python inside Blender (blocking) and returns its captured stdout. + public static string RunPython(BlenderEndpoint endpoint, string code, int timeoutSeconds = 120) + { + JToken result = Send(endpoint, "execute_code", new JObject { ["code"] = code }, timeoutSeconds); + return result?["result"]?.ToString() ?? string.Empty; + } + + /// Runs on the thread pool. + public static Task RunPythonAsync(BlenderEndpoint endpoint, string code, int timeoutSeconds = 120) + { + return Task.Run(() => RunPython(endpoint, code, timeoutSeconds)); + } + + /// Probes the addon with get_scene_info; returns whether it answered and the error text if not. + public static (bool Ok, string Error) Probe(BlenderEndpoint endpoint, int timeoutSeconds = 10) + { + try + { + Send(endpoint, "get_scene_info", null, timeoutSeconds); + return (true, null); + } + catch (Exception e) + { + return (false, e.Message); + } + } + + /// Runs on the thread pool. + public static Task<(bool Ok, string Error)> ProbeAsync(BlenderEndpoint endpoint, int timeoutSeconds = 10) + { + return Task.Run(() => Probe(endpoint, timeoutSeconds)); + } + + /// 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 for a success response, throws + /// for an error response, and rejects anything else + /// so a malformed reply never surfaces as a successful command with a null payload. + /// + internal static JToken Unwrap(JObject response, string type) + { + string status = (string)response?["status"]; + if (string.Equals(status, "success", StringComparison.OrdinalIgnoreCase)) + return response["result"]; + if (string.Equals(status, "error", StringComparison.OrdinalIgnoreCase)) + throw new BlenderCommandException($"Blender '{type}' failed: {(string)response["message"] ?? "unknown error"}"); + throw new InvalidDataException($"Blender returned an invalid status '{status ?? "(none)"}' for '{type}'."); + } + } + + /// The addon socket could not be reached (Blender closed or the addon not connected). + public class BlenderUnavailableException : Exception + { + public BlenderUnavailableException(string message) : base(message) { } + } + + /// The addon accepted the command but reported an error while running it. + 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..d825a4593 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Blender/BlenderBridgeTool.cs @@ -0,0 +1,927 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Security; +using MCPForUnity.Editor.Services.Blender; +using MCPForUnity.Editor.Tools.AssetGen; +using MCPForUnity.Runtime.Helpers; +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. + /// Socket and git work runs on the thread pool; Unity API calls happen after the await, back on + /// the editor thread (the bridge awaits handlers on Unity's synchronization context). + /// + [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", "compare_screenshot", "setup_bloom", "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."; + + /// Entry point for the bridge: validates parameters, dispatches by action, and never throws. + public static async Task 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 await StatusAsync(); + case "scene_info": + return new SuccessResponse("Retrieved Blender scene info.", + await BlenderSocketClient.SendAsync(BlenderBridgePrefs.Endpoint, "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."); + JToken info = await BlenderSocketClient.SendAsync(BlenderBridgePrefs.Endpoint, "get_object_info", + new JObject { ["object_name"] = objectName }, timeout); + return new SuccessResponse($"Retrieved info for '{objectName}'.", info); + } + case "screenshot": return await ScreenshotAsync(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 = await BlenderSocketClient.RunPythonAsync(BlenderBridgePrefs.Endpoint, code, timeout); + return new SuccessResponse("Executed Python in Blender.", new { stdout }); + } + case "import_model": return await ImportModelAsync(p, timeout); + case "compare_screenshot": return await CompareScreenshotAsync(p, timeout); + case "setup_bloom": return ToBloomResponse(await SetupBloomAsync()); + case "check_updates": return await CheckUpdatesAsync(); + 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 + + /// Probes the addon socket and reports checkout / installed-addon state without failing. + private static async Task StatusAsync() + { + BlenderEndpoint endpoint = BlenderBridgePrefs.Endpoint; + string forkAddon = BlenderBridgePrefs.ForkAddonPath; + string installedAddon = BlenderBridgePrefs.InstalledAddonPath; + bool forkConfigured = BlenderBridgePrefs.IsForkConfigured; + string forkPath = BlenderBridgePrefs.ForkPath; + bool blenderInstalled = BlenderDetection.IsInstalled(); + + var (reachable, error) = await BlenderSocketClient.ProbeAsync(endpoint); + JToken scene = null; + if (reachable) + { + try { scene = await BlenderSocketClient.SendAsync(endpoint, "get_scene_info", null, 10); } + catch (Exception e) { error = e.Message; } + } + + 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"] = blenderInstalled, + ["endpoint"] = endpoint.ToString(), + ["error"] = reachable ? null : error, + ["scene_name"] = scene?["name"], + ["object_count"] = scene?["object_count"], + ["fork_configured"] = forkConfigured, + ["fork_path"] = 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 + + /// Asks Blender for an offscreen viewport render and optionally copies it under Assets/. + private static async Task ScreenshotAsync(ToolParams p, int timeout) + { + int maxSize = Math.Max(64, p.GetInt("max_size", 1000) ?? 1000); + string outputFolder = p.Get("output_folder"); + + string assetsRelativeFolder = null; + if (!string.IsNullOrWhiteSpace(outputFolder)) + { + // Canonicalizes and rejects anything that does not resolve under Assets/ (e.g. "Assets/../x"). + if (!AssetGenPaths.NormalizeOutputFolder(outputFolder, out assetsRelativeFolder, out string folderError)) + return new ErrorResponse(folderError); + } + + string dir = Path.Combine(ProjectRoot(), "Library", "BlenderBridge"); + Directory.CreateDirectory(dir); + string file = Path.Combine(dir, $"blender_viewport_{UniqueSuffix()}.png").Replace('\\', '/'); + + JToken result = await BlenderSocketClient.SendAsync(BlenderBridgePrefs.Endpoint, "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 (assetsRelativeFolder != null) + { + string absDir = AssetGenPaths.ToAbsolute(assetsRelativeFolder); + Directory.CreateDirectory(absDir); + assetPath = $"{assetsRelativeFolder}/{Path.GetFileName(file)}"; + File.Copy(file, Path.Combine(absDir, Path.GetFileName(file)), 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 + + /// Exports from Blender, imports through the shared pipeline, then places and normalizes the instance. + private static async Task ImportModelAsync(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 = Mathf.Max(0f, p.GetFloat("target_size", 0f) ?? 0f); + string outputFolder = p.Get("output_folder"); + string animationType = p.Get("animation_type"); + bool autoAnimate = p.GetBool("auto_animate", true); + bool savePrefab = p.GetBool("save_prefab", false); + bool ensureBloom = p.GetBool("ensure_bloom", false); + + 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 (off the editor thread). + string exportDir = Path.Combine(Path.GetTempPath(), "BlenderBridge"); + Directory.CreateDirectory(exportDir); + string exportPath = Path.Combine(exportDir, $"{name}_{UniqueSuffix()}.{fmt}").Replace('\\', '/'); + + string stdout = await ExportFromBlenderAsync(BlenderBridgePrefs.Endpoint, exportPath, names, selectionOnly, applyModifiers, fmt, 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). + // target_size 0 is passed through: the pipeline only rescales when it is > 0. + var importParams = new JObject + { + ["sourcePath"] = exportPath, + ["name"] = name, + ["targetSize"] = target, + }; + 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); + + if (fmt == "fbx" && autoAnimate) ConfigureFbxClipLooping(assetPath); + + // 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); + } + + // 4. Optional finishing touches: play imported clips, keep a prefab, make emission glow. + if (autoAnimate) + { + JObject animation = SetupAnimation(go, assetPath, name); + if (animation != null) data["animation"] = animation; + } + if (savePrefab) data["prefab_path"] = SavePrefab(go, assetPath, name); + if (ensureBloom && HasEmissiveMaterial(go)) data["bloom"] = await SetupBloomAsync(); + + return new SuccessResponse($"Imported {assetPath} and placed '{go.name}' in the scene.", data); + } + + /// + /// Asks the addon to export. Prefers its first-class export_scene command (validated + /// parameters, no code execution); addons that predate it answer "Unknown command type", in + /// which case the equivalent Python is sent through execute_code. + /// + private static async Task ExportFromBlenderAsync(BlenderEndpoint endpoint, string exportPath, string[] names, + bool selectionOnly, bool applyModifiers, string fmt, int timeout) + { + var args = new JObject + { + ["filepath"] = exportPath, + ["format"] = fmt, + ["object_names"] = names == null ? new JArray() : new JArray(names.Cast().ToArray()), + ["selection_only"] = selectionOnly, + ["apply_modifiers"] = applyModifiers, + }; + try + { + JToken result = await BlenderSocketClient.SendAsync(endpoint, "export_scene", args, timeout); + if (result?["error"] != null) throw new BlenderCommandException($"Blender export_scene failed: {result["error"]}"); + return result?.ToString(Formatting.None) ?? string.Empty; + } + catch (BlenderCommandException e) when (e.Message.Contains("Unknown command type")) + { + string script = BuildExportScript(exportPath, names, selectionOnly, applyModifiers, fmt); + return await BlenderSocketClient.RunPythonAsync(endpoint, script, timeout); + } + } + + /// + /// Builds the Python that Blender runs to export on addons without export_scene. All caller-controlled values travel inside one + /// JSON document embedded as a single Python string literal, so names with quotes or + /// placeholder-looking text can never alter the program. + /// + internal static string BuildExportScript(string outPath, string[] names, bool selectionOnly, bool applyModifiers, string fmt) + { + var config = new JObject + { + ["out"] = outPath, + ["names"] = names == null ? new JArray() : new JArray(names.Cast().ToArray()), + ["selection_only"] = selectionOnly, + ["apply_modifiers"] = applyModifiers, + ["format"] = fmt, + }; + // JSON string escaping is a subset of Python string-literal escaping, so the encoded + // document is a valid double-quoted Python literal. + string configLiteral = JsonConvert.ToString(config.ToString(Formatting.None)); + + const string template = @" +import bpy, os, json +cfg = json.loads(__CFG__) +out = cfg['out'] +names = cfg['names'] +selection_only = cfg['selection_only'] +apply_mods = cfg['apply_modifiers'] +fmt = cfg['format'] +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)]})) +"; + // Verbatim strings take the checkout's line endings; normalize so the script (and tests) are + // byte-identical whether the file was checked out with LF or CRLF. + string body = template.Replace("\r\n", "\n"); + int at = body.IndexOf("__CFG__", StringComparison.Ordinal); + return body.Substring(0, at) + configLiteral + body.Substring(at + "__CFG__".Length); + } + + // ---------------------------------------------------------- finishing touches + + /// Marks every clip in an FBX as looping so the imported animation cycles once a controller drives it. + private static void ConfigureFbxClipLooping(string assetPath) + { + if (!(AssetImporter.GetAtPath(assetPath) is ModelImporter importer)) return; + ModelImporterClipAnimation[] clips = importer.clipAnimations.Length > 0 ? importer.clipAnimations : importer.defaultClipAnimations; + if (clips.Length == 0) return; + foreach (ModelImporterClipAnimation clip in clips) clip.loopTime = true; + importer.clipAnimations = clips; + importer.SaveAndReimport(); + } + + /// + /// An imported clip does nothing until an AnimatorController drives it, so the placed model looks frozen. + /// Creates a controller next to the asset with one looping state per clip and assigns it to the instance. + /// + private static JObject SetupAnimation(GameObject go, string assetPath, string name) + { + AnimationClip[] clips = AssetDatabase.LoadAllAssetRepresentationsAtPath(assetPath) + .OfType() + .Where(c => !c.name.StartsWith("__preview__", StringComparison.Ordinal)) + .ToArray(); + if (clips.Length == 0) return null; + + Animator animator = go.GetComponentInChildren(true) ?? go.AddComponent(); + string controllerPath = AssetDatabase.GenerateUniqueAssetPath( + Path.Combine(Path.GetDirectoryName(assetPath) ?? "Assets", $"{name}_Controller.controller").Replace('\\', '/')); + var controller = UnityEditor.Animations.AnimatorController.CreateAnimatorControllerAtPath(controllerPath); + var stateMachine = controller.layers[0].stateMachine; + foreach (AnimationClip clip in clips) + { + AnimationClipSettings settings = AnimationUtility.GetAnimationClipSettings(clip); + if (!settings.loopTime) + { + settings.loopTime = true; + AnimationUtility.SetAnimationClipSettings(clip, settings); + } + var state = stateMachine.AddState(clip.name); + state.motion = clip; + if (stateMachine.defaultState == null) stateMachine.defaultState = state; + } + animator.runtimeAnimatorController = controller; + EditorUtility.SetDirty(controller); + EditorUtility.SetDirty(animator); + AssetDatabase.SaveAssets(); + return new JObject + { + ["clips"] = new JArray(clips.Select(c => c.name).Cast().ToArray()), + ["controller_path"] = controllerPath, + ["animator_on"] = animator.gameObject.name, + ["loops"] = true, + }; + } + + /// Saves the placed instance as a prefab under <asset folder>/Prefabs and connects the instance to it. + private static string SavePrefab(GameObject go, string assetPath, string name) + { + string parent = (Path.GetDirectoryName(assetPath) ?? "Assets").Replace('\\', '/'); + string dir = $"{parent}/Prefabs"; + if (!AssetDatabase.IsValidFolder(dir)) AssetDatabase.CreateFolder(parent, "Prefabs"); + string prefabPath = AssetDatabase.GenerateUniqueAssetPath($"{dir}/{name}.prefab"); + PrefabUtility.SaveAsPrefabAssetAndConnect(go, prefabPath, InteractionMode.AutomatedAction); + return prefabPath; + } + + /// True when any material on the hierarchy has a non-black emission (glTFast or URP Lit naming). + private static bool HasEmissiveMaterial(GameObject go) + { + foreach (Renderer r in go.GetComponentsInChildren(true)) + { + foreach (Material m in r.sharedMaterials) + { + if (m == null) continue; + if (m.HasProperty("emissiveFactor") && m.GetColor("emissiveFactor").maxColorComponent > 0.01f) return true; + if (m.HasProperty("_EmissionColor") && m.IsKeywordEnabled("_EMISSION") && m.GetColor("_EmissionColor").maxColorComponent > 0.01f) return true; + } + } + return false; + } + + /// + /// Makes emissive materials actually glow: enables post-processing on the main camera and ensures a + /// global Volume with a Bloom override exists (reusing the scene's global volume when it has one). + /// Goes through manage_graphics so the Volume/Bloom reflection lives in one place. + /// + private static async Task SetupBloomAsync() + { + var result = new JObject(); + Type volumeType = Type.GetType("UnityEngine.Rendering.Volume, Unity.RenderPipelines.Core.Runtime"); + if (volumeType == null) + { + result["message"] = "Volume system (URP/HDRP) not available; nothing to do."; + return result; + } + + Camera cam = Camera.main; + Type camDataType = Type.GetType("UnityEngine.Rendering.Universal.UniversalAdditionalCameraData, Unity.RenderPipelines.Universal.Runtime"); + if (cam != null && camDataType != null) + { + Component camData = cam.GetComponent(camDataType) ?? cam.gameObject.AddComponent(camDataType); + System.Reflection.PropertyInfo post = camDataType.GetProperty("renderPostProcessing"); + if (post != null && !(bool)post.GetValue(camData)) + { + post.SetValue(camData, true); + EditorUtility.SetDirty(camData); + result["camera_post_processing_enabled"] = cam.name; + } + } + + System.Reflection.PropertyInfo isGlobal = volumeType.GetProperty("isGlobal"); + System.Reflection.FieldInfo sharedProfile = volumeType.GetField("sharedProfile"); + Component globalVolume = null; + foreach (UnityEngine.Object v in UnityFindObjectsCompat.FindAll(volumeType)) + { + var comp = v as Component; + if (comp == null || (isGlobal != null && !(bool)isGlobal.GetValue(comp))) continue; + globalVolume ??= comp; + object profile = sharedProfile?.GetValue(comp); + var components = profile?.GetType().GetField("components")?.GetValue(profile) as System.Collections.IEnumerable; + if (components != null && components.Cast().Any(c => c != null && c.GetType().Name == "Bloom")) + { + result["volume"] = comp.gameObject.name; + result["message"] = $"Bloom already present on global volume '{comp.gameObject.name}'."; + return result; + } + } + + JObject graphics; + if (globalVolume != null) + { + graphics = JObject.FromObject(await CommandRegistry.InvokeCommandAsync("manage_graphics", new JObject + { + ["action"] = "volume_add_effect", ["target"] = globalVolume.gameObject.name, ["effect"] = "Bloom", + })); + if (graphics.Value("success") ?? false) + { + await CommandRegistry.InvokeCommandAsync("manage_graphics", new JObject + { + ["action"] = "volume_set_effect", ["target"] = globalVolume.gameObject.name, ["effect"] = "Bloom", + ["parameters"] = new JObject { ["intensity"] = 1.0f, ["threshold"] = 0.9f }, + }); + } + result["volume"] = globalVolume.gameObject.name; + } + else + { + graphics = JObject.FromObject(await CommandRegistry.InvokeCommandAsync("manage_graphics", new JObject + { + ["action"] = "volume_create", ["name"] = "Global Volume (Blender Bridge)", ["is_global"] = true, + ["effects"] = new JArray(new JObject { ["type"] = "Bloom", ["intensity"] = 1.0f, ["threshold"] = 0.9f }), + })); + result["volume"] = "Global Volume (Blender Bridge)"; + } + bool ok = graphics.Value("success") ?? false; + result["message"] = ok ? $"Bloom added to '{result["volume"]}'." : $"Could not add Bloom: {graphics["error"] ?? graphics["message"]}"; + result["success"] = ok; + return result; + } + + /// Maps the Bloom setup report to a tool response: an error when manage_graphics could not add Bloom. + internal static object ToBloomResponse(JObject bloom) + { + string message = bloom?["message"]?.ToString() ?? "Bloom configured."; + bool failed = bloom != null && bloom.Value("success") == false; + return failed ? new ErrorResponse(message) : new SuccessResponse(message, bloom); + } + + // -------------------------------------------------------- compare_screenshot + + /// Puts a Blender viewport capture and a Unity capture of a placed object side by side in one PNG. + private static async Task CompareScreenshotAsync(ToolParams p, int timeout) + { + string target = p.Get("game_object"); + if (string.IsNullOrWhiteSpace(target)) return new ErrorResponse("'game_object' is required for compare_screenshot."); + if (GameObject.Find(target) == null) return new ErrorResponse($"GameObject '{target}' not found in the open scene."); + int maxSize = Math.Max(128, p.GetInt("max_size", 800) ?? 800); + string outputFolder = p.Get("output_folder"); + string assetsRelativeFolder = null; + if (!string.IsNullOrWhiteSpace(outputFolder) + && !AssetGenPaths.NormalizeOutputFolder(outputFolder, out assetsRelativeFolder, out string folderError)) + return new ErrorResponse(folderError); + + string dir = Path.Combine(ProjectRoot(), "Library", "BlenderBridge"); + Directory.CreateDirectory(dir); + string blenderPng = Path.Combine(dir, $"compare_blender_{UniqueSuffix()}.png").Replace('\\', '/'); + await BlenderSocketClient.SendAsync(BlenderBridgePrefs.Endpoint, "get_viewport_screenshot", + new JObject { ["max_size"] = maxSize, ["filepath"] = blenderPng, ["format"] = "png" }, timeout); + if (!File.Exists(blenderPng)) return new ErrorResponse("Blender did not write a viewport screenshot."); + + JObject shot = JObject.FromObject(await CommandRegistry.InvokeCommandAsync("manage_scene", new JObject + { + ["action"] = "screenshot", ["view_target"] = target, ["max_resolution"] = maxSize, + ["screenshot_file_name"] = $"blender_bridge_compare_{UniqueSuffix()}", + })); + string unityPng = shot["data"]?["fullPath"]?.ToString(); + if (!(shot.Value("success") ?? false) || string.IsNullOrEmpty(unityPng) || !File.Exists(unityPng)) + return new ErrorResponse($"Unity screenshot failed: {shot["error"] ?? shot["message"]}"); + + string outPath = Path.Combine(dir, $"compare_{UniqueSuffix()}.png").Replace('\\', '/'); + (int width, int height) = CompositeSideBySide(blenderPng, unityPng, outPath); + + // The Unity capture is written under Assets/ by manage_scene; keep the project clean. + string unityAssetPath = shot["data"]?["path"]?.ToString(); + if (!string.IsNullOrEmpty(unityAssetPath) && unityAssetPath.StartsWith("Assets/", StringComparison.Ordinal)) + AssetDatabase.DeleteAsset(unityAssetPath); + + string assetPath = null; + if (assetsRelativeFolder != null) + { + string absDir = AssetGenPaths.ToAbsolute(assetsRelativeFolder); + Directory.CreateDirectory(absDir); + assetPath = $"{assetsRelativeFolder}/{Path.GetFileName(outPath)}"; + File.Copy(outPath, Path.Combine(absDir, Path.GetFileName(outPath)), true); + AssetDatabase.ImportAsset(assetPath); + } + + return new SuccessResponse("Composited Blender (left) and Unity (right).", new JObject + { + ["path"] = outPath, ["asset_path"] = assetPath, ["width"] = width, ["height"] = height, + ["blender_path"] = blenderPng, ["game_object"] = target, + }); + } + + /// Scales both images to the smaller height and writes them side by side. Returns the output size. + internal static (int Width, int Height) CompositeSideBySide(string leftPng, string rightPng, string outPath) + { + Texture2D left = LoadPng(leftPng), right = LoadPng(rightPng); + Texture2D l = null, r = null, outTex = null; + try + { + int h = Math.Min(left.height, right.height); + l = ScaleToHeight(left, h); + r = ScaleToHeight(right, h); + outTex = new Texture2D(l.width + r.width, h, TextureFormat.RGBA32, false); + outTex.SetPixels(0, 0, l.width, h, l.GetPixels()); + outTex.SetPixels(l.width, 0, r.width, h, r.GetPixels()); + outTex.Apply(); + File.WriteAllBytes(outPath, outTex.EncodeToPNG()); + return (outTex.width, h); + } + finally + { + foreach (Texture2D t in new[] { outTex, l == left ? null : l, r == right ? null : r, left, right }) + if (t != null) UnityEngine.Object.DestroyImmediate(t); + } + } + + /// Decodes a PNG from disk into a readable texture. + private static Texture2D LoadPng(string path) + { + var tex = new Texture2D(2, 2, TextureFormat.RGBA32, false); + if (!ImageConversion.LoadImage(tex, File.ReadAllBytes(path))) + { + UnityEngine.Object.DestroyImmediate(tex); + throw new IOException($"Could not decode PNG: {path}"); + } + return tex; + } + + /// Bilinear resample to a target height, keeping the aspect ratio. Returns the source when already right. + private static Texture2D ScaleToHeight(Texture2D src, int height) + { + if (src.height == height) return src; + int width = Mathf.Max(1, Mathf.RoundToInt(src.width * (float)height / src.height)); + var dst = new Texture2D(width, height, TextureFormat.RGBA32, false); + var pixels = new Color[width * height]; + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + pixels[y * width + x] = src.GetPixelBilinear((x + 0.5f) / width, (y + 0.5f) / height); + dst.SetPixels(pixels); + dst.Apply(); + return dst; + } + + // ----------------------------------------------------------- check_updates + + /// Fetches the checkout's remotes on the thread pool and reports how far behind it is. + private static async Task CheckUpdatesAsync() + { + if (!BlenderBridgePrefs.IsForkConfigured) return new ErrorResponse(NotConfiguredMessage); + string fork = BlenderBridgePrefs.ForkPath; + string forkAddon = BlenderBridgePrefs.ForkAddonPath; + string installedAddon = BlenderBridgePrefs.InstalledAddonPath; + if (!Directory.Exists(Path.Combine(fork, ".git"))) + return new ErrorResponse($"'{fork}' is not a git checkout (no .git folder); check_updates needs one."); + + return await Task.Run(() => CheckUpdatesBlocking(fork, forkAddon, installedAddon)); + } + + /// Git and file work for check_updates; touches no Unity API so it can run on any thread. + private static object CheckUpdatesBlocking(string fork, string forkAddon, string installedAddon) + { + 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"] = RedactRemoteUrl(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 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 + + /// Copies the checkout's addon.py over Blender's installed copy, keeping a .bak of the old file. + 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 + + /// Runs one git command with no prompts; false on non-zero exit, timeout, or missing git. + 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; + } + } + + /// + /// Timestamp plus a random tail for temp file names, so concurrent commands (menu + MCP) started + /// in the same second never share a screenshot or export path. + /// + private static string UniqueSuffix() + => $"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + + /// + /// Strips user info, query and fragment from a remote URL so credentials embedded as + /// https://user:token@host/..., ?token=... or #access_token=... never reach the MCP response + /// or the editor log. scp-style git@host:path is returned unchanged (no secret there). + /// + internal static string RedactRemoteUrl(string url) + { + if (string.IsNullOrEmpty(url)) return url ?? string.Empty; + if (Uri.TryCreate(url, UriKind.Absolute, out Uri uri) && !string.IsNullOrEmpty(uri.Host)) + { + bool clean = string.IsNullOrEmpty(uri.UserInfo) && string.IsNullOrEmpty(uri.Query) && string.IsNullOrEmpty(uri.Fragment); + if (clean) return url; + return new UriBuilder(uri) { UserName = string.Empty, Password = string.Empty, Query = string.Empty, Fragment = string.Empty } + .Uri.ToString(); + } + // Not a parseable absolute URI: drop anything after ? or # and mask a user:secret@ prefix. + int cut = url.IndexOfAny(new[] { '?', '#' }); + if (cut >= 0) url = url.Substring(0, cut); + int scheme = url.IndexOf("://", StringComparison.Ordinal); + int at = url.IndexOf('@'); + if (scheme >= 0 && at > scheme) + return url.Substring(0, scheme + 3) + "***@" + url.Substring(at + 1); + return url; + } + + /// Absolute project folder (parent of Assets/), forward slashes. + 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(); + } + + /// Makes a caller-supplied name safe to use as a file and GameObject name. + 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; + } + + /// Reads a [x, y, z] position from a JSON array or a stringified array; origin when absent. + 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()); + } + + /// World-space bounds over every renderer in the hierarchy; false when it has none. + 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; + } + + /// Trims and caps a string for inclusion in messages. + 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