From c85cbfe104266858d53c5e9939d90280b580a04f Mon Sep 17 00:00:00 2001 From: jfjia Date: Thu, 12 Mar 2026 11:54:11 +0800 Subject: [PATCH 1/9] feat(camera): add scene view screenshot capture support --- .../Helpers/EditorWindowScreenshotUtility.cs | 345 ++++++++++++++++++ .../EditorWindowScreenshotUtility.cs.meta | 11 + MCPForUnity/Editor/Tools/ManageScene.cs | 259 +++++++++++-- Server/src/cli/commands/camera.py | 12 +- Server/src/services/tools/manage_camera.py | 10 +- Server/src/services/tools/utils.py | 6 + Server/tests/test_cli.py | 14 + Server/tests/test_manage_camera.py | 16 + .../Tools/ManageSceneHierarchyPagingTests.cs | 15 + 9 files changed, 656 insertions(+), 32 deletions(-) create mode 100644 MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs create mode 100644 MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs.meta diff --git a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs new file mode 100644 index 000000000..46e948e75 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs @@ -0,0 +1,345 @@ +using System; +using System.IO; +using System.Reflection; +using System.Threading; +using MCPForUnity.Runtime.Helpers; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Captures the pixels currently displayed in an editor window viewport. + /// Uses the editor view's own pixel grab path instead of re-rendering through a Camera. + /// + internal static class EditorWindowScreenshotUtility + { + private const string ScreenshotsFolderName = "Screenshots"; + private const int RepaintSettlingDelayMs = 75; + + public static ScreenshotCaptureResult CaptureSceneViewViewportToAssets( + SceneView sceneView, + string fileName, + int superSize, + bool ensureUniqueFileName, + bool includeImage, + int maxResolution, + out int viewportWidth, + out int viewportHeight) + { + if (sceneView == null) + throw new ArgumentNullException(nameof(sceneView)); + + FocusAndRepaint(sceneView); + + Rect viewportRectPixels = GetSceneViewViewportPixelRect(sceneView); + viewportWidth = Mathf.RoundToInt(viewportRectPixels.width); + viewportHeight = Mathf.RoundToInt(viewportRectPixels.height); + + if (viewportWidth <= 0 || viewportHeight <= 0) + throw new InvalidOperationException("Captured Scene view viewport is empty."); + + Texture2D captured = null; + Texture2D downscaled = null; + try + { + captured = CaptureViewRect(sceneView, viewportRectPixels); + + var result = PrepareCaptureResult(fileName, superSize, ensureUniqueFileName); + byte[] png = captured.EncodeToPNG(); + File.WriteAllBytes(result.FullPath, png); + + if (includeImage) + { + int targetMax = maxResolution > 0 ? maxResolution : 640; + string imageBase64; + int imageWidth; + int imageHeight; + + if (captured.width > targetMax || captured.height > targetMax) + { + downscaled = ScreenshotUtility.DownscaleTexture(captured, targetMax); + imageBase64 = Convert.ToBase64String(downscaled.EncodeToPNG()); + imageWidth = downscaled.width; + imageHeight = downscaled.height; + } + else + { + imageBase64 = Convert.ToBase64String(png); + imageWidth = captured.width; + imageHeight = captured.height; + } + + return new ScreenshotCaptureResult( + result.FullPath, + result.AssetsRelativePath, + result.SuperSize, + false, + imageBase64, + imageWidth, + imageHeight); + } + + return result; + } + finally + { + DestroyTexture(captured); + DestroyTexture(downscaled); + } + } + + private static void FocusAndRepaint(SceneView sceneView) + { + try + { + sceneView.Focus(); + } + catch (Exception ex) + { + McpLog.Debug($"[EditorWindowScreenshotUtility] SceneView focus failed: {ex.Message}"); + } + + try + { + sceneView.Repaint(); + InvokeMethodIfExists(sceneView, "RepaintImmediately"); + SceneView.RepaintAll(); + UnityEditorInternal.InternalEditorUtility.RepaintAllViews(); + EditorApplication.QueuePlayerLoopUpdate(); + Thread.Sleep(RepaintSettlingDelayMs); + } + catch (Exception ex) + { + McpLog.Debug($"[EditorWindowScreenshotUtility] SceneView repaint failed: {ex.Message}"); + } + } + + private static Rect GetSceneViewViewportPixelRect(SceneView sceneView) + { + float pixelsPerPoint = EditorGUIUtility.pixelsPerPoint; + Rect viewportLocalPoints = GetViewportLocalRectPoints(sceneView, pixelsPerPoint); + if (viewportLocalPoints.width <= 0f || viewportLocalPoints.height <= 0f) + throw new InvalidOperationException("Failed to resolve Scene view viewport rect."); + + return new Rect( + Mathf.Round(viewportLocalPoints.x * pixelsPerPoint), + Mathf.Round(viewportLocalPoints.y * pixelsPerPoint), + Mathf.Round(viewportLocalPoints.width * pixelsPerPoint), + Mathf.Round(viewportLocalPoints.height * pixelsPerPoint)); + } + + private static Rect GetViewportLocalRectPoints(SceneView sceneView, float pixelsPerPoint) + { + Rect? cameraViewport = GetRectProperty(sceneView, "cameraViewport"); + if (cameraViewport.HasValue && cameraViewport.Value.width > 0f && cameraViewport.Value.height > 0f) + { + return cameraViewport.Value; + } + + Camera camera = sceneView.camera; + if (camera == null) + throw new InvalidOperationException("Active Scene View has no camera to derive viewport size from."); + + float viewportWidth = camera.pixelWidth / Mathf.Max(0.0001f, pixelsPerPoint); + float viewportHeight = camera.pixelHeight / Mathf.Max(0.0001f, pixelsPerPoint); + Rect windowRect = sceneView.position; + + return new Rect( + 0f, + Mathf.Max(0f, windowRect.height - viewportHeight), + Mathf.Min(windowRect.width, viewportWidth), + Mathf.Min(windowRect.height, viewportHeight)); + } + + private static Texture2D CaptureViewRect(SceneView sceneView, Rect viewportRectPixels) + { + object hostView = GetHostView(sceneView); + if (hostView == null) + throw new InvalidOperationException("Failed to resolve Scene view host view."); + + MethodInfo grabPixels = hostView.GetType().GetMethod( + "GrabPixels", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + new[] { typeof(RenderTexture), typeof(Rect) }, + null); + + if (grabPixels == null) + throw new MissingMethodException($"{hostView.GetType().FullName}.GrabPixels(RenderTexture, Rect)"); + + int width = Mathf.RoundToInt(viewportRectPixels.width); + int height = Mathf.RoundToInt(viewportRectPixels.height); + + RenderTexture rt = null; + RenderTexture previousActive = RenderTexture.active; + try + { + rt = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32) + { + antiAliasing = 1, + filterMode = FilterMode.Bilinear, + hideFlags = HideFlags.HideAndDontSave, + }; + rt.Create(); + + grabPixels.Invoke(hostView, new object[] { rt, viewportRectPixels }); + + RenderTexture.active = rt; + var texture = new Texture2D(width, height, TextureFormat.RGBA32, false); + texture.ReadPixels(new Rect(0, 0, width, height), 0, 0); + texture.Apply(); + FlipTextureVertically(texture); + return texture; + } + catch (TargetInvocationException ex) + { + throw ex.InnerException ?? ex; + } + finally + { + RenderTexture.active = previousActive; + if (rt != null) + { + rt.Release(); + UnityEngine.Object.DestroyImmediate(rt); + } + } + } + + private static object GetHostView(EditorWindow window) + { + if (window == null) + return null; + + Type windowType = typeof(EditorWindow); + FieldInfo parentField = windowType.GetField("m_Parent", BindingFlags.Instance | BindingFlags.NonPublic); + if (parentField != null) + { + object parent = parentField.GetValue(window); + if (parent != null) + return parent; + } + + PropertyInfo hostViewProperty = windowType.GetProperty("hostView", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return hostViewProperty?.GetValue(window, null); + } + + private static Rect? GetRectProperty(object instance, string propertyName) + { + if (instance == null) + return null; + + Type type = instance.GetType(); + PropertyInfo property = type.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (property == null || property.PropertyType != typeof(Rect)) + return null; + + try + { + return (Rect)property.GetValue(instance, null); + } + catch + { + return null; + } + } + + private static void InvokeMethodIfExists(object instance, string methodName) + { + if (instance == null) + return; + + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (method == null || method.GetParameters().Length != 0) + return; + + try + { + method.Invoke(instance, null); + } + catch + { + // Best-effort only. + } + } + + private static void FlipTextureVertically(Texture2D texture) + { + if (texture == null) + return; + + int width = texture.width; + int height = texture.height; + Color32[] source = texture.GetPixels32(); + Color32[] flipped = new Color32[source.Length]; + + for (int y = 0; y < height; y++) + { + int srcRow = y * width; + int dstRow = (height - 1 - y) * width; + Array.Copy(source, srcRow, flipped, dstRow, width); + } + + texture.SetPixels32(flipped); + texture.Apply(); + } + + private static ScreenshotCaptureResult PrepareCaptureResult(string fileName, int superSize, bool ensureUniqueFileName) + { + int size = Mathf.Max(1, superSize); + string resolvedName = BuildFileName(fileName); + string folder = Path.Combine(Application.dataPath, ScreenshotsFolderName); + Directory.CreateDirectory(folder); + + string fullPath = Path.Combine(folder, resolvedName); + if (ensureUniqueFileName) + { + fullPath = EnsureUnique(fullPath); + } + + string normalizedFullPath = fullPath.Replace('\\', '/'); + string assetsRelativePath = "Assets/" + normalizedFullPath.Substring(Application.dataPath.Length).TrimStart('/'); + return new ScreenshotCaptureResult(normalizedFullPath, assetsRelativePath, size, false); + } + + private static string BuildFileName(string fileName) + { + string baseName = string.IsNullOrWhiteSpace(fileName) + ? $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png" + : fileName.Trim(); + + if (!baseName.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) + baseName += ".png"; + + return baseName; + } + + private static string EnsureUnique(string fullPath) + { + if (!File.Exists(fullPath)) + return fullPath; + + string directory = Path.GetDirectoryName(fullPath) ?? string.Empty; + string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fullPath); + string extension = Path.GetExtension(fullPath); + + for (int i = 1; i < 10000; i++) + { + string candidate = Path.Combine(directory, $"{fileNameWithoutExtension}-{i}{extension}"); + if (!File.Exists(candidate)) + return candidate; + } + + throw new IOException($"Could not generate a unique screenshot filename for '{fullPath}'."); + } + + private static void DestroyTexture(Texture2D texture) + { + if (texture == null) + return; + + UnityEngine.Object.DestroyImmediate(texture); + } + } +} diff --git a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs.meta b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs.meta new file mode 100644 index 000000000..d120907fe --- /dev/null +++ b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b73350febfd6534436726d19b4d270fd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index b1810df84..d6176cae8 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -29,6 +29,7 @@ private sealed class SceneCommand // screenshot: camera selection, inline image, batch, view positioning public string camera { get; set; } + public string captureSource { get; set; } // "game_view" (default) or "scene_view" public bool? includeImage { get; set; } public int? maxResolution { get; set; } public string batch { get; set; } // "surround" or "orbit" for multi-angle batch capture @@ -95,6 +96,7 @@ private static SceneCommand ToSceneCommand(JObject p) // screenshot: camera selection, inline image, batch, view positioning camera = (p["camera"])?.ToString(), + captureSource = (p["captureSource"] ?? p["capture_source"])?.ToString(), includeImage = ParamCoercion.CoerceBoolNullable(p["includeImage"] ?? p["include_image"]), maxResolution = ParamCoercion.CoerceIntNullable(p["maxResolution"] ?? p["max_resolution"]), batch = (p["batch"])?.ToString(), @@ -433,6 +435,46 @@ private static object CaptureScreenshot(SceneCommand cmd) { try { + string fileName = cmd.fileName; + int resolvedSuperSize = (cmd.superSize.HasValue && cmd.superSize.Value > 0) ? cmd.superSize.Value : 1; + bool includeImage = cmd.includeImage ?? false; + int maxResolution = cmd.maxResolution ?? 0; // 0 = let ScreenshotUtility default to 640 + string cameraRef = cmd.camera; + string captureSource = string.IsNullOrWhiteSpace(cmd.captureSource) + ? "game_view" + : cmd.captureSource.Trim().ToLowerInvariant(); + + if (captureSource != "game_view" && captureSource != "scene_view") + { + return new ErrorResponse( + $"Invalid capture_source '{cmd.captureSource}'. Valid values: 'game_view', 'scene_view'."); + } + + if (captureSource == "scene_view") + { + if (resolvedSuperSize > 1) + { + return new ErrorResponse( + "capture_source='scene_view' does not support super_size above 1. Remove 'super_size' or use capture_source='game_view'."); + } + if (!string.IsNullOrEmpty(cmd.batch)) + { + return new ErrorResponse( + "capture_source='scene_view' does not support batch modes. Use capture_source='game_view' for batch capture."); + } + if ((cmd.lookAt != null && cmd.lookAt.Type != JTokenType.Null) || cmd.viewPosition.HasValue || cmd.viewRotation.HasValue) + { + return new ErrorResponse( + "capture_source='scene_view' does not support look_at/view_position/view_rotation. Use scene_view_target to frame a Scene View object."); + } + if (!string.IsNullOrEmpty(cameraRef)) + { + return new ErrorResponse( + "capture_source='scene_view' does not support camera selection. Remove 'camera' or use capture_source='game_view'."); + } + return CaptureSceneViewScreenshot(cmd, fileName, resolvedSuperSize, includeImage, maxResolution); + } + // Batch capture (e.g., "surround" for 6 angles around the scene) if (!string.IsNullOrEmpty(cmd.batch)) { @@ -449,12 +491,6 @@ private static object CaptureScreenshot(SceneCommand cmd) return CapturePositionedScreenshot(cmd); } - string fileName = cmd.fileName; - int resolvedSuperSize = (cmd.superSize.HasValue && cmd.superSize.Value > 0) ? cmd.superSize.Value : 1; - bool includeImage = cmd.includeImage ?? false; - int maxResolution = cmd.maxResolution ?? 0; // 0 = let ScreenshotUtility default to 640 - string cameraRef = cmd.camera; - // Batch mode warning if (Application.isBatchMode) { @@ -510,6 +546,7 @@ private static object CaptureScreenshot(SceneCommand cmd) { "superSize", result.SuperSize }, { "isAsync", false }, { "camera", targetCamera.name }, + { "captureSource", "game_view" }, }; if (includeImage && result.ImageBase64 != null) { @@ -568,6 +605,7 @@ private static object CaptureScreenshot(SceneCommand cmd) fullPath = defaultResult.FullPath, superSize = defaultResult.SuperSize, isAsync = defaultResult.IsAsync, + captureSource = "game_view", } ); } @@ -577,6 +615,85 @@ private static object CaptureScreenshot(SceneCommand cmd) } } + private static object CaptureSceneViewScreenshot( + SceneCommand cmd, + string fileName, + int resolvedSuperSize, + bool includeImage, + int maxResolution) + { + if (Application.isBatchMode) + { + return new ErrorResponse("capture_source='scene_view' is not supported in batch mode."); + } + + var sceneView = SceneView.lastActiveSceneView; + if (sceneView == null) + { + return new ErrorResponse( + "No active Scene View found. Open a Scene View window first, then retry screenshot with capture_source='scene_view'."); + } + + if (cmd.sceneViewTarget != null && cmd.sceneViewTarget.Type != JTokenType.Null) + { + var frameResult = FrameSceneView(new SceneCommand { sceneViewTarget = cmd.sceneViewTarget }); + if (frameResult is ErrorResponse) + { + return frameResult; + } + } + + try + { + ScreenshotCaptureResult result = EditorWindowScreenshotUtility.CaptureSceneViewViewportToAssets( + sceneView, + fileName, + resolvedSuperSize, + ensureUniqueFileName: true, + includeImage: includeImage, + maxResolution: maxResolution, + out int viewportWidth, + out int viewportHeight); + + AssetDatabase.ImportAsset(result.AssetsRelativePath, ImportAssetOptions.ForceSynchronousImport); + string sceneViewName = sceneView.titleContent?.text ?? "Scene"; + + var data = new Dictionary + { + { "path", result.AssetsRelativePath }, + { "fullPath", result.FullPath }, + { "superSize", result.SuperSize }, + { "isAsync", false }, + { "camera", sceneView.camera != null ? sceneView.camera.name : "SceneCamera" }, + { "captureSource", "scene_view" }, + { "captureMode", "scene_view_viewport" }, + { "sceneViewName", sceneViewName }, + { "viewportWidth", viewportWidth }, + { "viewportHeight", viewportHeight }, + }; + + if (cmd.sceneViewTarget != null && cmd.sceneViewTarget.Type != JTokenType.Null) + { + data["sceneViewTarget"] = cmd.sceneViewTarget; + } + + if (includeImage && result.ImageBase64 != null) + { + data["imageBase64"] = result.ImageBase64; + data["imageWidth"] = result.ImageWidth; + data["imageHeight"] = result.ImageHeight; + } + + return new SuccessResponse( + $"Scene View screenshot captured to '{result.AssetsRelativePath}' (scene view: {sceneViewName}).", + data); + } + catch (Exception e) + { + return new ErrorResponse($"Error capturing Scene View screenshot: {e.Message}"); + } + } + /// /// Captures screenshots from 6 angles around scene bounds (or a look_at target) for AI scene understanding. /// Does NOT save to disk — returns all images as inline base64 PNGs. Always uses camera-based capture. @@ -1065,30 +1182,7 @@ private static object FrameSceneView(SceneCommand cmd) return new ErrorResponse($"Target GameObject '{cmd.sceneViewTarget}' not found for scene_view_frame."); } - // Calculate bounds from renderers, colliders, or transform - Bounds bounds = new Bounds(target.transform.position, Vector3.zero); - var renderers = target.GetComponentsInChildren(); - if (renderers.Length > 0) - { - bounds = renderers[0].bounds; - for (int i = 1; i < renderers.Length; i++) - bounds.Encapsulate(renderers[i].bounds); - } - else - { - var colliders = target.GetComponentsInChildren(); - if (colliders.Length > 0) - { - bounds = colliders[0].bounds; - for (int i = 1; i < colliders.Length; i++) - bounds.Encapsulate(colliders[i].bounds); - } - else - { - bounds = new Bounds(target.transform.position, Vector3.one); - } - } - + Bounds bounds = CalculateFrameBounds(target); sceneView.Frame(bounds, false); return new SuccessResponse($"Scene View framed on '{target.name}'.", new { target = target.name }); } @@ -1118,6 +1212,111 @@ private static object FrameSceneView(SceneCommand cmd) } } + private static Bounds CalculateFrameBounds(GameObject target) + { + if (target == null) + return new Bounds(Vector3.zero, Vector3.one); + + if (TryGetRectTransformBounds(target, out Bounds rectBounds)) + return rectBounds; + + if (TryGetRendererBounds(target, out Bounds rendererBounds)) + return rendererBounds; + + if (TryGetColliderBounds(target, out Bounds colliderBounds)) + return colliderBounds; + + return new Bounds(target.transform.position, Vector3.one); + } + + private static bool TryGetRectTransformBounds(GameObject target, out Bounds bounds) + { + bounds = default(Bounds); + var rectTransforms = target.GetComponentsInChildren(true); + bool hasBounds = false; + var corners = new Vector3[4]; + + foreach (var rectTransform in rectTransforms) + { + if (rectTransform == null || !rectTransform.gameObject.activeInHierarchy) + continue; + + rectTransform.GetWorldCorners(corners); + for (int i = 0; i < corners.Length; i++) + { + if (!hasBounds) + { + bounds = new Bounds(corners[i], Vector3.zero); + hasBounds = true; + } + else + { + bounds.Encapsulate(corners[i]); + } + } + } + + if (!hasBounds) + return false; + + if (bounds.size.sqrMagnitude < 0.0001f) + bounds.Expand(1f); + + return true; + } + + private static bool TryGetRendererBounds(GameObject target, out Bounds bounds) + { + bounds = default(Bounds); +#if UNITY_2022_2_OR_NEWER + var renderers = target.GetComponentsInChildren(true); +#else + var renderers = target.GetComponentsInChildren(true); +#endif + bool hasBounds = false; + foreach (var renderer in renderers) + { + if (renderer == null || !renderer.gameObject.activeInHierarchy) + continue; + + if (!hasBounds) + { + bounds = renderer.bounds; + hasBounds = true; + } + else + { + bounds.Encapsulate(renderer.bounds); + } + } + + return hasBounds; + } + + private static bool TryGetColliderBounds(GameObject target, out Bounds bounds) + { + bounds = default(Bounds); + var colliders = target.GetComponentsInChildren(true); + bool hasBounds = false; + foreach (var collider in colliders) + { + if (collider == null || !collider.gameObject.activeInHierarchy) + continue; + + if (!hasBounds) + { + bounds = collider.bounds; + hasBounds = true; + } + else + { + bounds.Encapsulate(collider.bounds); + } + } + + return hasBounds; + } + private static void EnsureGameView() { try diff --git a/Server/src/cli/commands/camera.py b/Server/src/cli/commands/camera.py index 2e1cad659..ca383b6a5 100644 --- a/Server/src/cli/commands/camera.py +++ b/Server/src/cli/commands/camera.py @@ -481,17 +481,23 @@ def release_override(): @click.option("--super-size", type=int, default=None, help="Supersize multiplier.") @click.option("--include-image/--no-include-image", default=None, help="Return inline base64 PNG.") @click.option("--max-resolution", type=int, default=None, help="Max resolution for inline image.") +@click.option("--capture-source", default=None, + type=click.Choice(["game_view", "scene_view"], case_sensitive=False), + help="Capture source: game_view (default) or scene_view.") @click.option("--batch", default=None, type=click.Choice(["surround", "orbit"]), help="Batch capture mode.") @click.option("--look-at", default=None, help="Target to aim at (name/path/ID or [x,y,z]).") +@click.option("--scene-view-target", default=None, + help="Target to frame before capture when using --capture-source scene_view.") @handle_unity_errors -def screenshot(camera_ref, file_name, super_size, include_image, max_resolution, batch, look_at): +def screenshot(camera_ref, file_name, super_size, include_image, max_resolution, capture_source, batch, look_at, scene_view_target): """Capture a screenshot from a camera. \b Examples: unity-mcp camera screenshot unity-mcp camera screenshot --camera-ref "CM FollowCam" --include-image --max-resolution 512 + unity-mcp camera screenshot --capture-source scene_view --scene-view-target Canvas --include-image unity-mcp camera screenshot --batch surround --look-at Player """ config = get_config() @@ -506,10 +512,14 @@ def screenshot(camera_ref, file_name, super_size, include_image, max_resolution, params["includeImage"] = include_image if max_resolution is not None: params["maxResolution"] = max_resolution + if capture_source: + params["captureSource"] = capture_source if batch: params["batch"] = batch if look_at: params["lookAt"] = look_at + if scene_view_target: + params["sceneViewTarget"] = scene_view_target result = run_command(config, "manage_camera", params) format_output(result, config) diff --git a/Server/src/services/tools/manage_camera.py b/Server/src/services/tools/manage_camera.py index 035fd2a86..978f58f66 100644 --- a/Server/src/services/tools/manage_camera.py +++ b/Server/src/services/tools/manage_camera.py @@ -63,7 +63,8 @@ "CAPTURE:\n" "- screenshot: Capture from a camera. Supports include_image=true for inline base64 PNG, " "batch='surround' for 6-angle contact sheet, batch='orbit' for configurable grid, " - "look_at/view_position for positioned capture.\n" + "look_at/view_position for positioned capture, and capture_source='scene_view' to capture " + "the active Unity Scene View viewport.\n" "- screenshot_multiview: Shorthand for screenshot with batch='surround' and include_image=true." ), annotations=ToolAnnotations( @@ -94,6 +95,9 @@ async def manage_camera( "If true, return screenshot as inline base64 PNG. Default false."] = None, max_resolution: Annotated[int | str | None, "Max resolution (longest edge px) for inline image. Default 640."] = None, + capture_source: Annotated[Literal["game_view", "scene_view"] | None, + "Screenshot source. 'game_view' (default) captures the game/camera path; " + "'scene_view' captures the active Unity Scene View viewport."] = None, batch: Annotated[str | None, "Batch capture mode: 'surround' (6 angles) or 'orbit' (configurable grid)."] = None, look_at: Annotated[str | int | list[float] | None, @@ -102,6 +106,8 @@ async def manage_camera( "World position [x,y,z] to place camera for positioned capture."] = None, view_rotation: Annotated[list[float] | str | None, "Euler rotation [x,y,z] for camera. Overrides look_at if both provided."] = None, + scene_view_target: Annotated[str | int | None, + "Optional GameObject reference to frame in the Scene View before capture when capture_source='scene_view'."] = None, orbit_angles: Annotated[int | str | None, "Number of azimuth samples for batch='orbit' (default 8, max 36)."] = None, orbit_elevations: Annotated[list[float] | str | None, @@ -154,6 +160,7 @@ async def manage_camera( camera=camera, include_image=include_image, max_resolution=max_resolution, + capture_source=capture_source, batch=batch, look_at=look_at, orbit_angles=orbit_angles, @@ -162,6 +169,7 @@ async def manage_camera( orbit_fov=orbit_fov, view_position=view_position, view_rotation=view_rotation, + scene_view_target=scene_view_target, ) if err is not None: return err diff --git a/Server/src/services/tools/utils.py b/Server/src/services/tools/utils.py index aea7323b4..3911bd4d4 100644 --- a/Server/src/services/tools/utils.py +++ b/Server/src/services/tools/utils.py @@ -464,6 +464,7 @@ def build_screenshot_params( camera: str | None = None, include_image: bool | str | None = None, max_resolution: int | str | None = None, + capture_source: str | None = None, batch: str | None = None, look_at: str | int | list[float] | None = None, orbit_angles: int | str | None = None, @@ -472,6 +473,7 @@ def build_screenshot_params( orbit_fov: float | str | None = None, view_position: list[float] | str | None = None, view_rotation: list[float] | str | None = None, + scene_view_target: str | int | None = None, ) -> dict[str, Any] | None: """Populate screenshot-related keys in *params* dict. Returns an error dict if validation fails, or None on success. @@ -493,6 +495,8 @@ def build_screenshot_params( if coerced_max_resolution <= 0: return {"success": False, "message": "max_resolution must be a positive integer."} params["maxResolution"] = coerced_max_resolution + if capture_source is not None: + params["captureSource"] = capture_source if batch: params["batch"] = batch if look_at is not None: @@ -533,5 +537,7 @@ def build_screenshot_params( if err: return {"success": False, "message": err} params["viewRotation"] = vec + if scene_view_target is not None: + params["sceneViewTarget"] = scene_view_target return None diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index baaf8e4d4..8808debf0 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -457,6 +457,20 @@ def test_scene_create(self, runner, mock_unity_response): assert result.exit_code == 0 +class TestCameraCommands: + """Tests for Camera CLI commands.""" + + def test_camera_screenshot_scene_view(self, runner, mock_unity_response): + with patch("cli.commands.camera.run_command", return_value=mock_unity_response): + result = runner.invoke(cli, [ + "camera", "screenshot", + "--capture-source", "scene_view", + "--scene-view-target", "Canvas", + "--include-image", + ]) + assert result.exit_code == 0 + + # ============================================================================= # Asset Command Tests diff --git a/Server/tests/test_manage_camera.py b/Server/tests/test_manage_camera.py index 80af04a46..bfd9f23fa 100644 --- a/Server/tests/test_manage_camera.py +++ b/Server/tests/test_manage_camera.py @@ -398,6 +398,22 @@ def test_screenshot_positioned(mock_unity): assert mock_unity["params"]["lookAt"] == "Player" +def test_screenshot_scene_view_capture_params(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="screenshot", + capture_source="scene_view", + scene_view_target="Canvas", + include_image=True, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["captureSource"] == "scene_view" + assert mock_unity["params"]["sceneViewTarget"] == "Canvas" + assert mock_unity["params"]["includeImage"] is True + + def test_screenshot_multiview_sends_action(mock_unity): result = asyncio.run( manage_camera(SimpleNamespace(), action="screenshot_multiview") diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs index c0066a89f..338537ccf 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs @@ -101,5 +101,20 @@ public void GetHierarchy_PaginatesRoots_AndSupportsChildrenPaging() Assert.IsNotNull(childItems); Assert.AreEqual(7, childItems.Count); } + + [Test] + public void Screenshot_SceneViewRejectsSupersizeAboveOne() + { + var raw = ManageScene.HandleCommand(new JObject + { + ["action"] = "screenshot", + ["captureSource"] = "scene_view", + ["superSize"] = 2, + }); + var response = raw as JObject ?? JObject.FromObject(raw); + + Assert.IsFalse(response.Value("success"), response.ToString()); + StringAssert.Contains("does not support super_size above 1", response.Value("message")); + } } } From 5d72e54667d086eb658774deb2e1b60555f8ea6f Mon Sep 17 00:00:00 2001 From: jfjia Date: Thu, 12 Mar 2026 12:00:47 +0800 Subject: [PATCH 2/9] fix(camera): validate scene view screenshot params --- MCPForUnity/Editor/Tools/ManageScene.cs | 4 ---- Server/src/services/tools/utils.py | 30 ++++++++++++++++++++++++- Server/tests/test_manage_camera.py | 27 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index d6176cae8..9d19d392e 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -1268,11 +1268,7 @@ private static bool TryGetRectTransformBounds(GameObject target, out Bounds boun private static bool TryGetRendererBounds(GameObject target, out Bounds bounds) { bounds = default(Bounds); -#if UNITY_2022_2_OR_NEWER - var renderers = target.GetComponentsInChildren(true); -#else var renderers = target.GetComponentsInChildren(true); -#endif bool hasBounds = false; foreach (var renderer in renderers) { diff --git a/Server/src/services/tools/utils.py b/Server/src/services/tools/utils.py index 3911bd4d4..d6b362773 100644 --- a/Server/src/services/tools/utils.py +++ b/Server/src/services/tools/utils.py @@ -496,7 +496,13 @@ def build_screenshot_params( return {"success": False, "message": "max_resolution must be a positive integer."} params["maxResolution"] = coerced_max_resolution if capture_source is not None: - params["captureSource"] = capture_source + normalized_capture_source = str(capture_source).strip().lower() + if normalized_capture_source not in {"game_view", "scene_view"}: + return { + "success": False, + "message": "capture_source must be either 'game_view' or 'scene_view'.", + } + params["captureSource"] = normalized_capture_source if batch: params["batch"] = batch if look_at is not None: @@ -540,4 +546,26 @@ def build_screenshot_params( if scene_view_target is not None: params["sceneViewTarget"] = scene_view_target + if params.get("captureSource") == "scene_view": + if coerced_super_size is not None and coerced_super_size > 1: + return { + "success": False, + "message": "capture_source='scene_view' does not support super_size above 1.", + } + if batch: + return { + "success": False, + "message": "capture_source='scene_view' does not support batch modes.", + } + if look_at is not None or view_position is not None or view_rotation is not None: + return { + "success": False, + "message": "capture_source='scene_view' does not support look_at/view_position/view_rotation.", + } + if camera: + return { + "success": False, + "message": "capture_source='scene_view' does not support camera selection.", + } + return None diff --git a/Server/tests/test_manage_camera.py b/Server/tests/test_manage_camera.py index bfd9f23fa..2a44ae939 100644 --- a/Server/tests/test_manage_camera.py +++ b/Server/tests/test_manage_camera.py @@ -414,6 +414,33 @@ def test_screenshot_scene_view_capture_params(mock_unity): assert mock_unity["params"]["includeImage"] is True +def test_screenshot_invalid_capture_source(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="screenshot", + capture_source="editor_view", + ) + ) + assert result["success"] is False + assert "capture_source must be either" in result["message"] + assert "params" not in mock_unity + + +def test_screenshot_scene_view_rejects_batch_in_python(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="screenshot", + capture_source="scene_view", + batch="surround", + ) + ) + assert result["success"] is False + assert "does not support batch modes" in result["message"] + assert "params" not in mock_unity + + def test_screenshot_multiview_sends_action(mock_unity): result = asyncio.run( manage_camera(SimpleNamespace(), action="screenshot_multiview") From c3b62a88c06a1a6a323cd1152485f82c23166376 Mon Sep 17 00:00:00 2001 From: jfjia Date: Thu, 12 Mar 2026 12:08:42 +0800 Subject: [PATCH 3/9] chore(camera): clarify scene view capture constraints --- .../Helpers/EditorWindowScreenshotUtility.cs | 29 +++++++++++++++++-- Server/tests/test_cli.py | 7 ++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs index 46e948e75..6e0fd42bc 100644 --- a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs +++ b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs @@ -15,8 +15,23 @@ namespace MCPForUnity.Editor.Helpers internal static class EditorWindowScreenshotUtility { private const string ScreenshotsFolderName = "Screenshots"; + // Keep capture synchronous so callers can immediately return the screenshot payload. + // The short sleep gives Unity a chance to flush repaint work before GrabPixels reads the viewport. private const int RepaintSettlingDelayMs = 75; + /// + /// Captures the active Scene View viewport to a PNG asset. + /// + /// Scene View window to capture. + /// Optional file name, defaulting to a timestamped PNG. + /// + /// Preserved in the result for API consistency, but Scene View capture always uses the current viewport resolution. + /// + /// If true, appends a suffix instead of overwriting an existing file. + /// If true, includes a base64 PNG in the returned result. + /// Maximum edge length for the inline image payload. + /// Captured viewport width in pixels. + /// Captured viewport height in pixels. public static ScreenshotCaptureResult CaptureSceneViewViewportToAssets( SceneView sceneView, string fileName, @@ -30,6 +45,11 @@ public static ScreenshotCaptureResult CaptureSceneViewViewportToAssets( if (sceneView == null) throw new ArgumentNullException(nameof(sceneView)); + if (superSize > 1) + { + McpLog.Warn("[EditorWindowScreenshotUtility] Scene View capture ignores superSize and uses the displayed viewport resolution."); + } + FocusAndRepaint(sceneView); Rect viewportRectPixels = GetSceneViewViewportPixelRect(sceneView); @@ -158,6 +178,8 @@ private static Texture2D CaptureViewRect(SceneView sceneView, Rect viewportRectP if (hostView == null) throw new InvalidOperationException("Failed to resolve Scene view host view."); + // GrabPixels is an internal editor API accessed reflectively. If Unity changes this surface, + // the MissingMethodException below keeps the failure explicit instead of silently degrading. MethodInfo grabPixels = hostView.GetType().GetMethod( "GrabPixels", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, @@ -239,8 +261,9 @@ private static object GetHostView(EditorWindow window) { return (Rect)property.GetValue(instance, null); } - catch + catch (Exception ex) { + McpLog.Debug($"[EditorWindowScreenshotUtility] Failed to read rect property '{propertyName}': {ex.Message}"); return null; } } @@ -258,9 +281,9 @@ private static void InvokeMethodIfExists(object instance, string methodName) { method.Invoke(instance, null); } - catch + catch (Exception ex) { - // Best-effort only. + McpLog.Debug($"[EditorWindowScreenshotUtility] Best-effort invoke of '{methodName}' failed: {ex.Message}"); } } diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index 8808debf0..c67886e26 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -461,7 +461,7 @@ class TestCameraCommands: """Tests for Camera CLI commands.""" def test_camera_screenshot_scene_view(self, runner, mock_unity_response): - with patch("cli.commands.camera.run_command", return_value=mock_unity_response): + with patch("cli.commands.camera.run_command", return_value=mock_unity_response) as mock_run: result = runner.invoke(cli, [ "camera", "screenshot", "--capture-source", "scene_view", @@ -469,6 +469,11 @@ def test_camera_screenshot_scene_view(self, runner, mock_unity_response): "--include-image", ]) assert result.exit_code == 0 + mock_run.assert_called_once() + params = mock_run.call_args[0][2] + assert params["captureSource"] == "scene_view" + assert params["sceneViewTarget"] == "Canvas" + assert params["includeImage"] is True From 3a3ad7d90f75c69ecf7e4dfd36564f2ef1997988 Mon Sep 17 00:00:00 2001 From: jfjia Date: Fri, 13 Mar 2026 15:51:51 +0800 Subject: [PATCH 4/9] fix(camera): harden scene view screenshot filenames --- .../Helpers/EditorWindowScreenshotUtility.cs | 44 ++++++++++++++++--- .../Tools/ManageSceneHierarchyPagingTests.cs | 33 ++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs index 6e0fd42bc..bc24ce458 100644 --- a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs +++ b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs @@ -45,10 +45,7 @@ public static ScreenshotCaptureResult CaptureSceneViewViewportToAssets( if (sceneView == null) throw new ArgumentNullException(nameof(sceneView)); - if (superSize > 1) - { - McpLog.Warn("[EditorWindowScreenshotUtility] Scene View capture ignores superSize and uses the displayed viewport resolution."); - } + int effectiveSuperSize = NormalizeSceneViewSuperSize(superSize); FocusAndRepaint(sceneView); @@ -65,7 +62,7 @@ public static ScreenshotCaptureResult CaptureSceneViewViewportToAssets( { captured = CaptureViewRect(sceneView, viewportRectPixels); - var result = PrepareCaptureResult(fileName, superSize, ensureUniqueFileName); + var result = PrepareCaptureResult(fileName, effectiveSuperSize, ensureUniqueFileName); byte[] png = captured.EncodeToPNG(); File.WriteAllBytes(result.FullPath, png); @@ -330,7 +327,7 @@ private static string BuildFileName(string fileName) { string baseName = string.IsNullOrWhiteSpace(fileName) ? $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png" - : fileName.Trim(); + : SanitizeFileName(fileName); if (!baseName.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) baseName += ".png"; @@ -338,6 +335,41 @@ private static string BuildFileName(string fileName) return baseName; } + private static int NormalizeSceneViewSuperSize(int superSize) + { + if (superSize > 1) + { + McpLog.Warn("[EditorWindowScreenshotUtility] Scene View capture ignores superSize and uses the displayed viewport resolution."); + return 1; + } + + return Mathf.Max(1, superSize); + } + + private static string SanitizeFileName(string fileName) + { + string trimmed = (fileName ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(trimmed)) + return $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png"; + + string candidate = trimmed; + if (Path.IsPathRooted(candidate) || candidate.Contains("/") || candidate.Contains("\\") || candidate.Contains("..")) + { + candidate = Path.GetFileName(candidate); + } + + if (string.IsNullOrWhiteSpace(candidate) || candidate == "." || candidate == "..") + candidate = $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png"; + + char[] invalidChars = Path.GetInvalidFileNameChars(); + foreach (char invalidChar in invalidChars) + { + candidate = candidate.Replace(invalidChar, '_'); + } + + return candidate; + } + private static string EnsureUnique(string fullPath) { if (!File.Exists(fullPath)) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs index 338537ccf..4b37c8980 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs @@ -1,4 +1,5 @@ using NUnit.Framework; +using System.Reflection; using UnityEngine; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Tools; @@ -116,5 +117,37 @@ public void Screenshot_SceneViewRejectsSupersizeAboveOne() Assert.IsFalse(response.Value("success"), response.ToString()); StringAssert.Contains("does not support super_size above 1", response.Value("message")); } + + [Test] + public void EditorWindowScreenshotUtility_SanitizesFileName() + { + var helperType = typeof(ManageScene).Assembly.GetType("MCPForUnity.Editor.Helpers.EditorWindowScreenshotUtility"); + Assert.IsNotNull(helperType, "Expected EditorWindowScreenshotUtility type."); + + var sanitizeMethod = helperType.GetMethod("SanitizeFileName", BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(sanitizeMethod, "Expected SanitizeFileName helper."); + + string sanitized = (string)sanitizeMethod.Invoke(null, new object[] { "../evil/path/shot" }); + Assert.AreEqual("shot", sanitized); + Assert.IsFalse(sanitized.Contains("/")); + Assert.IsFalse(sanitized.Contains("\\")); + Assert.IsFalse(sanitized.Contains("..")); + } + + [Test] + public void EditorWindowScreenshotUtility_ClampsSceneViewSupersizeToOne() + { + var helperType = typeof(ManageScene).Assembly.GetType("MCPForUnity.Editor.Helpers.EditorWindowScreenshotUtility"); + Assert.IsNotNull(helperType, "Expected EditorWindowScreenshotUtility type."); + + var normalizeMethod = helperType.GetMethod("NormalizeSceneViewSuperSize", BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(normalizeMethod, "Expected NormalizeSceneViewSuperSize helper."); + + int normalized = (int)normalizeMethod.Invoke(null, new object[] { 4 }); + Assert.AreEqual(1, normalized); + + normalized = (int)normalizeMethod.Invoke(null, new object[] { 0 }); + Assert.AreEqual(1, normalized); + } } } From 0b135bee15e064d912b0b566ed28900665727889 Mon Sep 17 00:00:00 2001 From: jfjia Date: Fri, 13 Mar 2026 17:15:08 +0800 Subject: [PATCH 5/9] fix(camera): sanitize reserved screenshot filenames --- .../Helpers/EditorWindowScreenshotUtility.cs | 14 ++++++++++++++ .../Tools/ManageSceneHierarchyPagingTests.cs | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs index bc24ce458..28f3571cc 100644 --- a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs +++ b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Reflection; using System.Threading; @@ -18,6 +19,12 @@ internal static class EditorWindowScreenshotUtility // Keep capture synchronous so callers can immediately return the screenshot payload. // The short sleep gives Unity a chance to flush repaint work before GrabPixels reads the viewport. private const int RepaintSettlingDelayMs = 75; + private static readonly HashSet WindowsReservedNames = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + }; /// /// Captures the active Scene View viewport to a PNG asset. @@ -367,6 +374,13 @@ private static string SanitizeFileName(string fileName) candidate = candidate.Replace(invalidChar, '_'); } + string extension = Path.GetExtension(candidate); + string stem = Path.GetFileNameWithoutExtension(candidate); + if (WindowsReservedNames.Contains(stem)) + { + candidate = $"_{stem}{extension}"; + } + return candidate; } diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs index 4b37c8980..922a3cca2 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs @@ -132,6 +132,24 @@ public void EditorWindowScreenshotUtility_SanitizesFileName() Assert.IsFalse(sanitized.Contains("/")); Assert.IsFalse(sanitized.Contains("\\")); Assert.IsFalse(sanitized.Contains("..")); + + string[] reservedInputs = { "CON", "NUL", "PRN", "AUX", "../CON.txt", "folder/COM1.log", "nested\\LPT9" }; + foreach (string input in reservedInputs) + { + sanitized = (string)sanitizeMethod.Invoke(null, new object[] { input }); + string sanitizedStem = System.IO.Path.GetFileNameWithoutExtension(sanitized); + Assert.IsFalse( + string.Equals(sanitizedStem, "CON", System.StringComparison.OrdinalIgnoreCase) || + string.Equals(sanitizedStem, "NUL", System.StringComparison.OrdinalIgnoreCase) || + string.Equals(sanitizedStem, "PRN", System.StringComparison.OrdinalIgnoreCase) || + string.Equals(sanitizedStem, "AUX", System.StringComparison.OrdinalIgnoreCase) || + string.Equals(sanitizedStem, "COM1", System.StringComparison.OrdinalIgnoreCase) || + string.Equals(sanitizedStem, "LPT9", System.StringComparison.OrdinalIgnoreCase), + $"Expected reserved device name to be sanitized for input '{input}', got '{sanitized}'."); + Assert.IsFalse(sanitized.Contains("/")); + Assert.IsFalse(sanitized.Contains("\\")); + Assert.IsFalse(sanitized.Contains("..")); + } } [Test] From 12993541fc78bf30f604c47a081051c7116ed72c Mon Sep 17 00:00:00 2001 From: jfjia Date: Fri, 13 Mar 2026 17:17:44 +0800 Subject: [PATCH 6/9] fix(camera): normalize screenshot filename paths --- MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs index 28f3571cc..777080ce4 100644 --- a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs +++ b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs @@ -360,9 +360,11 @@ private static string SanitizeFileName(string fileName) return $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png"; string candidate = trimmed; - if (Path.IsPathRooted(candidate) || candidate.Contains("/") || candidate.Contains("\\") || candidate.Contains("..")) + string normalizedSeparators = candidate.Replace('\\', '/'); + if (Path.IsPathRooted(candidate) || normalizedSeparators.Contains("/") || normalizedSeparators.Contains("..")) { - candidate = Path.GetFileName(candidate); + string[] pathParts = normalizedSeparators.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + candidate = pathParts.Length > 0 ? pathParts[pathParts.Length - 1] : string.Empty; } if (string.IsNullOrWhiteSpace(candidate) || candidate == "." || candidate == "..") From 49e4d6ce23e6af44840c2ff403c61cf70d588136 Mon Sep 17 00:00:00 2001 From: jfjia Date: Fri, 13 Mar 2026 17:38:31 +0800 Subject: [PATCH 7/9] fix(scene): tighten scene view screenshot validation --- .../Helpers/EditorWindowScreenshotUtility.cs | 6 +++- MCPForUnity/Editor/Tools/ManageScene.cs | 28 ++++++++++++++-- Server/src/services/tools/utils.py | 5 +++ Server/tests/test_manage_camera.py | 13 ++++++++ .../Tools/ManageSceneHierarchyPagingTests.cs | 33 ++++++++++++++++++- 5 files changed, 81 insertions(+), 4 deletions(-) diff --git a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs index 777080ce4..1adb3165c 100644 --- a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs +++ b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Reflection; +using System.Runtime.ExceptionServices; using System.Threading; using MCPForUnity.Runtime.Helpers; using UnityEditor; @@ -220,7 +221,8 @@ private static Texture2D CaptureViewRect(SceneView sceneView, Rect viewportRectP } catch (TargetInvocationException ex) { - throw ex.InnerException ?? ex; + ExceptionDispatchInfo.Capture(ex.InnerException ?? ex).Throw(); + throw; } finally { @@ -378,6 +380,8 @@ private static string SanitizeFileName(string fileName) string extension = Path.GetExtension(candidate); string stem = Path.GetFileNameWithoutExtension(candidate); + extension = extension.TrimEnd(' ', '.'); + stem = stem.TrimEnd(' ', '.'); if (WindowsReservedNames.Contains(stem)) { candidate = $"_{stem}{extension}"; diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index 9d19d392e..9656ce17a 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -85,6 +85,7 @@ private static float[] ParseFloatArray(JToken token) private static SceneCommand ToSceneCommand(JObject p) { if (p == null) return new SceneCommand(); + var toolParams = new ToolParams(p); return new SceneCommand { action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(), @@ -96,7 +97,7 @@ private static SceneCommand ToSceneCommand(JObject p) // screenshot: camera selection, inline image, batch, view positioning camera = (p["camera"])?.ToString(), - captureSource = (p["captureSource"] ?? p["capture_source"])?.ToString(), + captureSource = toolParams.Get("capture_source"), includeImage = ParamCoercion.CoerceBoolNullable(p["includeImage"] ?? p["include_image"]), maxResolution = ParamCoercion.CoerceIntNullable(p["maxResolution"] ?? p["max_resolution"]), batch = (p["batch"])?.ToString(), @@ -111,7 +112,7 @@ private static SceneCommand ToSceneCommand(JObject p) orbitFov = ParamCoercion.CoerceFloatNullable(p["orbitFov"] ?? p["orbit_fov"]), // scene_view_frame - sceneViewTarget = p["sceneViewTarget"] ?? p["scene_view_target"], + sceneViewTarget = toolParams.GetRaw("scene_view_target"), // get_hierarchy paging + safety parent = p["parent"], @@ -475,6 +476,12 @@ private static object CaptureScreenshot(SceneCommand cmd) return CaptureSceneViewScreenshot(cmd, fileName, resolvedSuperSize, includeImage, maxResolution); } + if (cmd.sceneViewTarget != null && cmd.sceneViewTarget.Type != JTokenType.Null) + { + return new ErrorResponse( + "scene_view_target is only valid with capture_source='scene_view'. Use capture_source='scene_view' or remove scene_view_target."); + } + // Batch capture (e.g., "surround" for 6 angles around the scene) if (!string.IsNullOrEmpty(cmd.batch)) { @@ -1310,6 +1317,23 @@ private static bool TryGetColliderBounds(GameObject target, out Bounds bounds) } } + var colliders2D = target.GetComponentsInChildren(true); + foreach (var collider in colliders2D) + { + if (collider == null || !collider.gameObject.activeInHierarchy) + continue; + + if (!hasBounds) + { + bounds = collider.bounds; + hasBounds = true; + } + else + { + bounds.Encapsulate(collider.bounds); + } + } + return hasBounds; } diff --git a/Server/src/services/tools/utils.py b/Server/src/services/tools/utils.py index d6b362773..7091a4f10 100644 --- a/Server/src/services/tools/utils.py +++ b/Server/src/services/tools/utils.py @@ -544,6 +544,11 @@ def build_screenshot_params( return {"success": False, "message": err} params["viewRotation"] = vec if scene_view_target is not None: + if params.get("captureSource") != "scene_view": + return { + "success": False, + "message": "scene_view_target is only valid with capture_source='scene_view'.", + } params["sceneViewTarget"] = scene_view_target if params.get("captureSource") == "scene_view": diff --git a/Server/tests/test_manage_camera.py b/Server/tests/test_manage_camera.py index 2a44ae939..1bd832d6f 100644 --- a/Server/tests/test_manage_camera.py +++ b/Server/tests/test_manage_camera.py @@ -441,6 +441,19 @@ def test_screenshot_scene_view_rejects_batch_in_python(mock_unity): assert "params" not in mock_unity +def test_screenshot_scene_view_target_requires_scene_view_capture(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="screenshot", + scene_view_target="Canvas", + ) + ) + assert result["success"] is False + assert "scene_view_target is only valid" in result["message"] + assert "params" not in mock_unity + + def test_screenshot_multiview_sends_action(mock_unity): result = asyncio.run( manage_camera(SimpleNamespace(), action="screenshot_multiview") diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs index 922a3cca2..97aff5135 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs @@ -133,7 +133,7 @@ public void EditorWindowScreenshotUtility_SanitizesFileName() Assert.IsFalse(sanitized.Contains("\\")); Assert.IsFalse(sanitized.Contains("..")); - string[] reservedInputs = { "CON", "NUL", "PRN", "AUX", "../CON.txt", "folder/COM1.log", "nested\\LPT9" }; + string[] reservedInputs = { "CON", "NUL", "PRN", "AUX", "../CON.txt", "folder/COM1.log", "nested\\LPT9", "CON ", "NUL." }; foreach (string input in reservedInputs) { sanitized = (string)sanitizeMethod.Invoke(null, new object[] { input }); @@ -167,5 +167,36 @@ public void EditorWindowScreenshotUtility_ClampsSceneViewSupersizeToOne() normalized = (int)normalizeMethod.Invoke(null, new object[] { 0 }); Assert.AreEqual(1, normalized); } + + [Test] + public void Screenshot_GameViewRejectsSceneViewTarget() + { + var raw = ManageScene.HandleCommand(new JObject + { + ["action"] = "screenshot", + ["sceneViewTarget"] = "Canvas", + }); + var response = raw as JObject ?? JObject.FromObject(raw); + + Assert.IsFalse(response.Value("success"), response.ToString()); + StringAssert.Contains("scene_view_target is only valid", response.Value("message")); + } + + [Test] + public void CalculateFrameBounds_UsesCollider2D() + { + var helperType = typeof(ManageScene).GetMethod("CalculateFrameBounds", BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(helperType, "Expected CalculateFrameBounds helper."); + + var root = new GameObject("HS_2D"); + _created.Add(root); + var collider = root.AddComponent(); + collider.size = new Vector2(4f, 2f); + collider.offset = new Vector2(1f, -1f); + + Bounds bounds = (Bounds)helperType.Invoke(null, new object[] { root }); + Assert.Greater(bounds.size.x, 0.1f); + Assert.Greater(bounds.size.y, 0.1f); + } } } From c9f88d03fa59724e4e4d20065de31a2497bb17c6 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:34:15 -0400 Subject: [PATCH 8/9] Update 1. Intergrate some parameters 2. Update the docs --- .claude/skills/unity-mcp-skill/SKILL.md | 32 ++++--- .../references/tools-reference.md | 29 +++--- .../unity-mcp-skill/references/workflows.md | 20 ++++ .../Helpers/EditorWindowScreenshotUtility.cs | 21 +++-- MCPForUnity/Editor/Tools/ManageScene.cs | 92 +++++++++---------- Server/src/cli/CLI_USAGE_GUIDE.md | 5 +- Server/src/cli/commands/camera.py | 27 +++--- Server/src/services/tools/manage_camera.py | 14 ++- Server/src/services/tools/utils.py | 19 +--- Server/tests/test_cli.py | 4 +- Server/tests/test_manage_camera.py | 22 ++--- Server/uv.lock | 2 +- .../Tools/ManageSceneHierarchyPagingTests.cs | 9 +- docs/guides/CLI_EXAMPLE.md | 5 +- docs/guides/CLI_USAGE.md | 17 ++-- unity-mcp-skill/SKILL.md | 22 +++-- unity-mcp-skill/references/tools-reference.md | 29 +++--- unity-mcp-skill/references/workflows.md | 20 ++++ 18 files changed, 225 insertions(+), 164 deletions(-) diff --git a/.claude/skills/unity-mcp-skill/SKILL.md b/.claude/skills/unity-mcp-skill/SKILL.md index a48deee70..8ec0c4fa3 100644 --- a/.claude/skills/unity-mcp-skill/SKILL.md +++ b/.claude/skills/unity-mcp-skill/SKILL.md @@ -59,40 +59,48 @@ batch_execute( ```python # Basic screenshot (saves to Assets/, returns file path only) -manage_scene(action="screenshot") +manage_camera(action="screenshot") # Inline screenshot (returns base64 PNG directly to the AI) -manage_scene(action="screenshot", include_image=True) +manage_camera(action="screenshot", include_image=True) # Use a specific camera and cap resolution for smaller payloads -manage_scene(action="screenshot", camera="MainCamera", include_image=True, max_resolution=512) +manage_camera(action="screenshot", camera="MainCamera", include_image=True, max_resolution=512) # Batch surround: captures front/back/left/right/top/bird_eye around the scene -manage_scene(action="screenshot", batch="surround", max_resolution=256) +manage_camera(action="screenshot", batch="surround", max_resolution=256) # Batch surround centered on a specific object -manage_scene(action="screenshot", batch="surround", look_at="Player", max_resolution=256) +manage_camera(action="screenshot", batch="surround", view_target="Player", max_resolution=256) # Positioned screenshot: place a temp camera and capture in one call -manage_scene(action="screenshot", look_at="Player", view_position=[0, 10, -10], max_resolution=512) +manage_camera(action="screenshot", view_target="Player", view_position=[0, 10, -10], max_resolution=512) + +# Scene View screenshot: capture what the developer sees in the editor +manage_camera(action="screenshot", capture_source="scene_view", include_image=True) + +# Scene View framed on a specific object +manage_camera(action="screenshot", capture_source="scene_view", view_target="Canvas", include_image=True) ``` **Best practices for AI scene understanding:** - Use `include_image=True` when you need to *see* the scene, not just save a file. - Use `batch="surround"` for a comprehensive overview (6 angles, one command). -- Use `look_at`/`view_position` to capture from a specific viewpoint without needing a scene camera. +- Use `view_target`/`view_position` to capture from a specific viewpoint without needing a scene camera. +- Use `capture_source="scene_view"` to see the editor viewport (gizmos, wireframes, grid). - Keep `max_resolution` at 256–512 to balance quality vs. token cost. -- Combine with `look_at` on `manage_gameobject` to orient a game camera before capturing. ```python # Agentic camera loop: point, shoot, analyze manage_gameobject(action="look_at", target="MainCamera", look_at_target="Player") -manage_scene(action="screenshot", camera="MainCamera", include_image=True, max_resolution=512) +manage_camera(action="screenshot", camera="MainCamera", include_image=True, max_resolution=512) # → Analyze image, decide next action -# Alternative: use manage_camera for screenshot (same underlying infrastructure) -manage_camera(action="screenshot", camera="FollowCam", include_image=True, max_resolution=512) -manage_camera(action="screenshot_multiview", max_resolution=480) # 6-angle contact sheet +# Multi-view screenshot (6-angle contact sheet) +manage_camera(action="screenshot_multiview", max_resolution=480) + +# Scene View for editor-level inspection (shows gizmos, debug overlays, etc.) +manage_camera(action="screenshot", capture_source="scene_view", view_target="Player", include_image=True) ``` ### 4. Check Console After Major Changes diff --git a/.claude/skills/unity-mcp-skill/references/tools-reference.md b/.claude/skills/unity-mcp-skill/references/tools-reference.md index 16de62132..e9894fff2 100644 --- a/.claude/skills/unity-mcp-skill/references/tools-reference.md +++ b/.claude/skills/unity-mcp-skill/references/tools-reference.md @@ -134,7 +134,7 @@ manage_scene( manage_scene( action="screenshot", batch="surround", - look_at="Player", # str|int|list[float] - center surround on this target + view_target="Player", # str|int|list[float] - center surround on this target max_resolution=256 ) @@ -142,7 +142,7 @@ manage_scene( manage_scene( action="screenshot", batch="orbit", # str - "orbit" for configurable angle grid - look_at="Player", # str|int|list[float] - target to orbit around + view_target="Player", # str|int|list[float] - target to orbit around orbit_angles=8, # int, default 8 - number of azimuth steps orbit_elevations=[0, 30], # list[float], default [0, 30, -15] - vertical angles in degrees orbit_distance=10, # float, optional - camera distance (auto-fit if omitted) @@ -154,9 +154,9 @@ manage_scene( # Positioned screenshot (temp camera at viewpoint, no file saved) manage_scene( action="screenshot", - look_at="Enemy", # str|int|list[float] - target to aim at + view_target="Enemy", # str|int|list[float] - target to aim at view_position=[0, 10, -10], # list[float], optional - camera position - view_rotation=[45, 0, 0], # list[float], optional - euler angles (overrides look_at aim) + view_rotation=[45, 0, 0], # list[float], optional - euler angles (overrides view_target aim) max_resolution=512 ) @@ -813,13 +813,14 @@ Unified camera management (Unity Camera + Cinemachine). Works without Cinemachin | Parameter | Type | Description | |-----------|------|-------------| -| `camera` | string | Camera to capture from (defaults to Camera.main) | +| `capture_source` | string | `"game_view"` (default) or `"scene_view"` (editor viewport) | +| `view_target` | string\|int\|list | Target to focus on (GO name/path/ID or [x,y,z]). game_view: aims camera; scene_view: frames viewport | +| `camera` | string | Camera to capture from (defaults to Camera.main). game_view only | | `include_image` | bool | Return base64 PNG inline (default false) | | `max_resolution` | int | Downscale cap in px (default 640) | -| `batch` | string | `"surround"` (6 angles) or `"orbit"` (configurable grid) | -| `look_at` | string\|int\|list | Target to aim at (GO name/path/ID or [x,y,z]) | -| `view_position` | list[float] | World position [x,y,z] to place camera | -| `view_rotation` | list[float] | Euler rotation [x,y,z] (overrides look_at) | +| `batch` | string | `"surround"` (6 angles) or `"orbit"` (configurable grid). game_view only | +| `view_position` | list[float] | World position [x,y,z] to place camera. game_view only | +| `view_rotation` | list[float] | Euler rotation [x,y,z] (overrides view_target). game_view only | **Actions by category:** @@ -850,7 +851,7 @@ Unified camera management (Unity Camera + Cinemachine). Works without Cinemachin - `release_override` — Release camera override **Capture:** -- `screenshot` — Capture from a camera. Supports inline base64, batch surround/orbit, positioned capture. +- `screenshot` — Capture screenshot. Supports `capture_source="game_view"` (default, camera-based) or `"scene_view"` (editor viewport). game_view supports inline base64, batch surround/orbit, positioned capture. scene_view supports `view_target` for framing. - `screenshot_multiview` — Shorthand for screenshot with batch='surround' and include_image=true. **Examples:** @@ -901,9 +902,15 @@ manage_camera(action="add_extension", target="FollowCam", properties={ "extensionType": "CinemachineDeoccluder" }) -# Screenshot from a specific camera +# Screenshot from a specific camera (game_view, default) manage_camera(action="screenshot", camera="FollowCam", include_image=True, max_resolution=512) +# Scene View screenshot (captures editor viewport — gizmos, wireframes, grid) +manage_camera(action="screenshot", capture_source="scene_view", include_image=True) + +# Scene View screenshot framed on a specific object +manage_camera(action="screenshot", capture_source="scene_view", view_target="Canvas", include_image=True) + # Multi-view screenshot (6-angle contact sheet) manage_camera(action="screenshot_multiview", max_resolution=480) diff --git a/.claude/skills/unity-mcp-skill/references/workflows.md b/.claude/skills/unity-mcp-skill/references/workflows.md index d5add9ccf..ac67da20a 100644 --- a/.claude/skills/unity-mcp-skill/references/workflows.md +++ b/.claude/skills/unity-mcp-skill/references/workflows.md @@ -1526,6 +1526,26 @@ manage_camera(action="list_cameras") manage_camera(action="screenshot_multiview", max_resolution=480) ``` +### Scene View Screenshot Workflow + +Use `capture_source="scene_view"` to capture the editor's Scene View viewport — useful for seeing gizmos, wireframes, grid, debug overlays, and objects without cameras. + +```python +# 1. Capture the Scene View as-is +manage_camera(action="screenshot", capture_source="scene_view", include_image=True) + +# 2. Frame on a specific object first, then capture +manage_camera(action="screenshot", capture_source="scene_view", + view_target="Player", include_image=True, max_resolution=512) + +# 3. Frame on UI Canvas (RectTransform bounds are supported) +manage_camera(action="screenshot", capture_source="scene_view", + view_target="Canvas", include_image=True) + +# Limitations: scene_view does not support batch, view_position, view_rotation, or camera selection. +# Use capture_source="game_view" (default) for those features. +``` + --- ## ProBuilder Workflows diff --git a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs index 1adb3165c..229b9336c 100644 --- a/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs +++ b/MCPForUnity/Editor/Helpers/EditorWindowScreenshotUtility.cs @@ -183,8 +183,9 @@ private static Texture2D CaptureViewRect(SceneView sceneView, Rect viewportRectP if (hostView == null) throw new InvalidOperationException("Failed to resolve Scene view host view."); - // GrabPixels is an internal editor API accessed reflectively. If Unity changes this surface, - // the MissingMethodException below keeps the failure explicit instead of silently degrading. + // GrabPixels is an internal extern on GUIView (parent of HostView), present since at least Unity 2021.1. + // See: UnityCsReference/Editor/Mono/GUIView.bindings.cs — `internal extern void GrabPixels(RenderTexture, Rect)` + // If Unity removes this, the MissingMethodException below keeps the failure explicit. MethodInfo grabPixels = hostView.GetType().GetMethod( "GrabPixels", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, @@ -300,17 +301,19 @@ private static void FlipTextureVertically(Texture2D texture) int width = texture.width; int height = texture.height; - Color32[] source = texture.GetPixels32(); - Color32[] flipped = new Color32[source.Length]; + Color32[] pixels = texture.GetPixels32(); + var temp = new Color32[width]; - for (int y = 0; y < height; y++) + for (int y = 0; y < height / 2; y++) { - int srcRow = y * width; - int dstRow = (height - 1 - y) * width; - Array.Copy(source, srcRow, flipped, dstRow, width); + int topRow = y * width; + int bottomRow = (height - 1 - y) * width; + Array.Copy(pixels, topRow, temp, 0, width); + Array.Copy(pixels, bottomRow, pixels, topRow, width); + Array.Copy(temp, 0, pixels, bottomRow, width); } - texture.SetPixels32(flipped); + texture.SetPixels32(pixels); texture.Apply(); } diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index 9656ce17a..d999ec0d4 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -33,7 +33,7 @@ private sealed class SceneCommand public bool? includeImage { get; set; } public int? maxResolution { get; set; } public string batch { get; set; } // "surround" or "orbit" for multi-angle batch capture - public JToken lookAt { get; set; } // GO reference or [x,y,z] to aim at before capture + public JToken viewTarget { get; set; } // GO reference or [x,y,z] to focus on before capture public Vector3? viewPosition { get; set; } // camera position for view-based capture public Vector3? viewRotation { get; set; } // euler rotation for view-based capture @@ -101,7 +101,7 @@ private static SceneCommand ToSceneCommand(JObject p) includeImage = ParamCoercion.CoerceBoolNullable(p["includeImage"] ?? p["include_image"]), maxResolution = ParamCoercion.CoerceIntNullable(p["maxResolution"] ?? p["max_resolution"]), batch = (p["batch"])?.ToString(), - lookAt = p["lookAt"] ?? p["look_at"], + viewTarget = p["viewTarget"] ?? p["view_target"], viewPosition = VectorParsing.ParseVector3(p["viewPosition"] ?? p["view_position"]), viewRotation = VectorParsing.ParseVector3(p["viewRotation"] ?? p["view_rotation"]), @@ -463,10 +463,10 @@ private static object CaptureScreenshot(SceneCommand cmd) return new ErrorResponse( "capture_source='scene_view' does not support batch modes. Use capture_source='game_view' for batch capture."); } - if ((cmd.lookAt != null && cmd.lookAt.Type != JTokenType.Null) || cmd.viewPosition.HasValue || cmd.viewRotation.HasValue) + if (cmd.viewPosition.HasValue || cmd.viewRotation.HasValue) { return new ErrorResponse( - "capture_source='scene_view' does not support look_at/view_position/view_rotation. Use scene_view_target to frame a Scene View object."); + "capture_source='scene_view' does not support view_position/view_rotation. Use view_target to frame a Scene View object."); } if (!string.IsNullOrEmpty(cameraRef)) { @@ -476,12 +476,6 @@ private static object CaptureScreenshot(SceneCommand cmd) return CaptureSceneViewScreenshot(cmd, fileName, resolvedSuperSize, includeImage, maxResolution); } - if (cmd.sceneViewTarget != null && cmd.sceneViewTarget.Type != JTokenType.Null) - { - return new ErrorResponse( - "scene_view_target is only valid with capture_source='scene_view'. Use capture_source='scene_view' or remove scene_view_target."); - } - // Batch capture (e.g., "surround" for 6 angles around the scene) if (!string.IsNullOrEmpty(cmd.batch)) { @@ -492,8 +486,8 @@ private static object CaptureScreenshot(SceneCommand cmd) return new ErrorResponse($"Unknown batch mode: '{cmd.batch}'. Valid modes: 'surround', 'orbit'."); } - // Positioned view-based capture (creates temp camera at view_position, aimed at look_at) - if ((cmd.lookAt != null && cmd.lookAt.Type != JTokenType.Null) || cmd.viewPosition.HasValue) + // Positioned view-based capture (creates temp camera at view_position, aimed at view_target) + if ((cmd.viewTarget != null && cmd.viewTarget.Type != JTokenType.Null) || cmd.viewPosition.HasValue) { return CapturePositionedScreenshot(cmd); } @@ -641,9 +635,9 @@ private static object CaptureSceneViewScreenshot( "No active Scene View found. Open a Scene View window first, then retry screenshot with capture_source='scene_view'."); } - if (cmd.sceneViewTarget != null && cmd.sceneViewTarget.Type != JTokenType.Null) + if (cmd.viewTarget != null && cmd.viewTarget.Type != JTokenType.Null) { - var frameResult = FrameSceneView(new SceneCommand { sceneViewTarget = cmd.sceneViewTarget }); + var frameResult = FrameSceneView(new SceneCommand { sceneViewTarget = cmd.viewTarget }); if (frameResult is ErrorResponse) { return frameResult; @@ -679,9 +673,9 @@ private static object CaptureSceneViewScreenshot( { "viewportHeight", viewportHeight }, }; - if (cmd.sceneViewTarget != null && cmd.sceneViewTarget.Type != JTokenType.Null) + if (cmd.viewTarget != null && cmd.viewTarget.Type != JTokenType.Null) { - data["sceneViewTarget"] = cmd.sceneViewTarget; + data["viewTarget"] = cmd.viewTarget; } if (includeImage && result.ImageBase64 != null) @@ -702,7 +696,7 @@ private static object CaptureSceneViewScreenshot( } /// - /// Captures screenshots from 6 angles around scene bounds (or a look_at target) for AI scene understanding. + /// Captures screenshots from 6 angles around scene bounds (or a view_target) for AI scene understanding. /// Does NOT save to disk — returns all images as inline base64 PNGs. Always uses camera-based capture. /// private static object CaptureSurroundBatch(SceneCommand cmd) @@ -714,24 +708,24 @@ private static object CaptureSurroundBatch(SceneCommand cmd) Vector3 center; float radius; - // If look_at is provided, center on that target instead of scene bounds - if (cmd.lookAt != null && cmd.lookAt.Type != JTokenType.Null) + // If view_target is provided, center on that target instead of scene bounds + if (cmd.viewTarget != null && cmd.viewTarget.Type != JTokenType.Null) { - var lookAtPos = VectorParsing.ParseVector3(cmd.lookAt); - if (lookAtPos.HasValue) + var targetPos3 = VectorParsing.ParseVector3(cmd.viewTarget); + if (targetPos3.HasValue) { - center = lookAtPos.Value; + center = targetPos3.Value; radius = 5f; } else { - Scene lookAtScene = EditorSceneManager.GetActiveScene(); - var lookAtGo = ResolveGameObject(cmd.lookAt, lookAtScene); - if (lookAtGo == null) - return new ErrorResponse($"look_at target '{cmd.lookAt}' not found for batch capture."); + Scene targetScene = EditorSceneManager.GetActiveScene(); + var targetGo = ResolveGameObject(cmd.viewTarget, targetScene); + if (targetGo == null) + return new ErrorResponse($"view_target '{cmd.viewTarget}' not found for batch capture."); - Bounds targetBounds = new Bounds(lookAtGo.transform.position, Vector3.zero); - foreach (var r in lookAtGo.GetComponentsInChildren()) + Bounds targetBounds = new Bounds(targetGo.transform.position, Vector3.zero); + foreach (var r in targetGo.GetComponentsInChildren()) { if (r != null && r.gameObject.activeInHierarchy) targetBounds.Encapsulate(r.bounds); } @@ -860,24 +854,24 @@ private static object CaptureOrbitBatch(SceneCommand cmd) Vector3 center; float radius; - // Resolve center and radius from look_at target or scene bounds - if (cmd.lookAt != null && cmd.lookAt.Type != JTokenType.Null) + // Resolve center and radius from view_target or scene bounds + if (cmd.viewTarget != null && cmd.viewTarget.Type != JTokenType.Null) { - var lookAtPos = VectorParsing.ParseVector3(cmd.lookAt); - if (lookAtPos.HasValue) + var targetPos3 = VectorParsing.ParseVector3(cmd.viewTarget); + if (targetPos3.HasValue) { - center = lookAtPos.Value; + center = targetPos3.Value; radius = cmd.orbitDistance ?? 5f; } else { - Scene lookAtScene = EditorSceneManager.GetActiveScene(); - var lookAtGo = ResolveGameObject(cmd.lookAt, lookAtScene); - if (lookAtGo == null) - return new ErrorResponse($"look_at target '{cmd.lookAt}' not found for orbit capture."); + Scene targetScene = EditorSceneManager.GetActiveScene(); + var targetGo = ResolveGameObject(cmd.viewTarget, targetScene); + if (targetGo == null) + return new ErrorResponse($"view_target '{cmd.viewTarget}' not found for orbit capture."); - Bounds targetBounds = new Bounds(lookAtGo.transform.position, Vector3.zero); - foreach (var r in lookAtGo.GetComponentsInChildren()) + Bounds targetBounds = new Bounds(targetGo.transform.position, Vector3.zero); + foreach (var r in targetGo.GetComponentsInChildren()) { if (r != null && r.gameObject.activeInHierarchy) targetBounds.Encapsulate(r.bounds); } @@ -998,7 +992,7 @@ private static object CaptureOrbitBatch(SceneCommand cmd) } /// - /// Captures a single screenshot from a temporary camera placed at view_position and aimed at look_at. + /// Captures a single screenshot from a temporary camera placed at view_position and aimed at view_target. /// Returns inline base64 PNG and also saves the image to Assets/Screenshots/. /// private static object CapturePositionedScreenshot(SceneCommand cmd) @@ -1009,9 +1003,9 @@ private static object CapturePositionedScreenshot(SceneCommand cmd) // Resolve where to aim Vector3? targetPos = null; - if (cmd.lookAt != null && cmd.lookAt.Type != JTokenType.Null) + if (cmd.viewTarget != null && cmd.viewTarget.Type != JTokenType.Null) { - var parsedPos = VectorParsing.ParseVector3(cmd.lookAt); + var parsedPos = VectorParsing.ParseVector3(cmd.viewTarget); if (parsedPos.HasValue) { targetPos = parsedPos.Value; @@ -1019,10 +1013,10 @@ private static object CapturePositionedScreenshot(SceneCommand cmd) else { Scene activeScene = EditorSceneManager.GetActiveScene(); - var lookAtGo = ResolveGameObject(cmd.lookAt, activeScene); - if (lookAtGo == null) - return new ErrorResponse($"look_at target '{cmd.lookAt}' not found."); - targetPos = lookAtGo.transform.position; + var resolvedGo = ResolveGameObject(cmd.viewTarget, activeScene); + if (resolvedGo == null) + return new ErrorResponse($"view_target '{cmd.viewTarget}' not found."); + targetPos = resolvedGo.transform.position; } } @@ -1034,12 +1028,12 @@ private static object CapturePositionedScreenshot(SceneCommand cmd) } else if (targetPos.HasValue) { - // Default: offset from look_at target + // Default: offset from view_target camPos = targetPos.Value + new Vector3(0, 2, -5); } else { - return new ErrorResponse("Provide 'look_at' or 'view_position' for a positioned screenshot."); + return new ErrorResponse("Provide 'view_target' or 'view_position' for a positioned screenshot."); } // Create temporary camera @@ -1095,7 +1089,7 @@ private static object CapturePositionedScreenshot(SceneCommand cmd) { "path", assetsRelativePath }, }; if (targetPos.HasValue) - data["lookAt"] = new[] { targetPos.Value.x, targetPos.Value.y, targetPos.Value.z }; + data["viewTarget"] = new[] { targetPos.Value.x, targetPos.Value.y, targetPos.Value.z }; return new SuccessResponse( $"Positioned screenshot captured (max {maxRes}px) and saved to '{assetsRelativePath}'.", diff --git a/Server/src/cli/CLI_USAGE_GUIDE.md b/Server/src/cli/CLI_USAGE_GUIDE.md index 75d1cb8f6..4c6413eae 100644 --- a/Server/src/cli/CLI_USAGE_GUIDE.md +++ b/Server/src/cli/CLI_USAGE_GUIDE.md @@ -658,8 +658,9 @@ unity-mcp camera screenshot --file-name "my_capture" --super-size 2 unity-mcp camera screenshot --camera-ref "SecondCamera" --include-image unity-mcp camera screenshot --max-resolution 256 unity-mcp camera screenshot --batch surround --max-resolution 256 -unity-mcp camera screenshot --batch orbit --look-at "Player" -unity-mcp camera screenshot-multiview --look-at "Player" --max-resolution 480 +unity-mcp camera screenshot --batch orbit --view-target "Player" +unity-mcp camera screenshot --capture-source scene_view --view-target "Canvas" --include-image +unity-mcp camera screenshot-multiview --view-target "Player" --max-resolution 480 ``` ### Graphics Commands diff --git a/Server/src/cli/commands/camera.py b/Server/src/cli/commands/camera.py index ca383b6a5..b97a07700 100644 --- a/Server/src/cli/commands/camera.py +++ b/Server/src/cli/commands/camera.py @@ -486,19 +486,18 @@ def release_override(): help="Capture source: game_view (default) or scene_view.") @click.option("--batch", default=None, type=click.Choice(["surround", "orbit"]), help="Batch capture mode.") -@click.option("--look-at", default=None, help="Target to aim at (name/path/ID or [x,y,z]).") -@click.option("--scene-view-target", default=None, - help="Target to frame before capture when using --capture-source scene_view.") +@click.option("--view-target", default=None, + help="Target to focus on (name/path/ID or [x,y,z]). Aims camera (game_view) or frames Scene View (scene_view).") @handle_unity_errors -def screenshot(camera_ref, file_name, super_size, include_image, max_resolution, capture_source, batch, look_at, scene_view_target): +def screenshot(camera_ref, file_name, super_size, include_image, max_resolution, capture_source, batch, view_target): """Capture a screenshot from a camera. \b Examples: unity-mcp camera screenshot unity-mcp camera screenshot --camera-ref "CM FollowCam" --include-image --max-resolution 512 - unity-mcp camera screenshot --capture-source scene_view --scene-view-target Canvas --include-image - unity-mcp camera screenshot --batch surround --look-at Player + unity-mcp camera screenshot --capture-source scene_view --view-target Canvas --include-image + unity-mcp camera screenshot --batch surround --view-target Player """ config = get_config() params: dict[str, Any] = {"action": "screenshot"} @@ -516,31 +515,29 @@ def screenshot(camera_ref, file_name, super_size, include_image, max_resolution, params["captureSource"] = capture_source if batch: params["batch"] = batch - if look_at: - params["lookAt"] = look_at - if scene_view_target: - params["sceneViewTarget"] = scene_view_target + if view_target: + params["viewTarget"] = view_target result = run_command(config, "manage_camera", params) format_output(result, config) @camera.command("screenshot-multiview") @click.option("--max-resolution", type=int, default=None, help="Max resolution per tile.") -@click.option("--look-at", default=None, help="Center target for the multiview capture.") +@click.option("--view-target", default=None, help="Center target for the multiview capture.") @handle_unity_errors -def screenshot_multiview(max_resolution, look_at): +def screenshot_multiview(max_resolution, view_target): """Capture a 6-angle contact sheet around the scene. \b Examples: unity-mcp camera screenshot-multiview - unity-mcp camera screenshot-multiview --look-at Player --max-resolution 480 + unity-mcp camera screenshot-multiview --view-target Player --max-resolution 480 """ config = get_config() params: dict[str, Any] = {"action": "screenshot_multiview"} if max_resolution is not None: params["maxResolution"] = max_resolution - if look_at: - params["lookAt"] = look_at + if view_target: + params["viewTarget"] = view_target result = run_command(config, "manage_camera", params) format_output(result, config) diff --git a/Server/src/services/tools/manage_camera.py b/Server/src/services/tools/manage_camera.py index 978f58f66..41004f05e 100644 --- a/Server/src/services/tools/manage_camera.py +++ b/Server/src/services/tools/manage_camera.py @@ -63,7 +63,7 @@ "CAPTURE:\n" "- screenshot: Capture from a camera. Supports include_image=true for inline base64 PNG, " "batch='surround' for 6-angle contact sheet, batch='orbit' for configurable grid, " - "look_at/view_position for positioned capture, and capture_source='scene_view' to capture " + "view_target/view_position for positioned capture, and capture_source='scene_view' to capture " "the active Unity Scene View viewport.\n" "- screenshot_multiview: Shorthand for screenshot with batch='surround' and include_image=true." ), @@ -100,14 +100,13 @@ async def manage_camera( "'scene_view' captures the active Unity Scene View viewport."] = None, batch: Annotated[str | None, "Batch capture mode: 'surround' (6 angles) or 'orbit' (configurable grid)."] = None, - look_at: Annotated[str | int | list[float] | None, - "Target to aim camera at. GameObject name/path/ID or [x,y,z]."] = None, + view_target: Annotated[str | int | list[float] | None, + "Target to focus on. GameObject name/path/ID or [x,y,z]. " + "For game_view: aims camera at target. For scene_view: frames the Scene View on the target."] = None, view_position: Annotated[list[float] | str | None, "World position [x,y,z] to place camera for positioned capture."] = None, view_rotation: Annotated[list[float] | str | None, - "Euler rotation [x,y,z] for camera. Overrides look_at if both provided."] = None, - scene_view_target: Annotated[str | int | None, - "Optional GameObject reference to frame in the Scene View before capture when capture_source='scene_view'."] = None, + "Euler rotation [x,y,z] for camera. Overrides view_target if both provided."] = None, orbit_angles: Annotated[int | str | None, "Number of azimuth samples for batch='orbit' (default 8, max 36)."] = None, orbit_elevations: Annotated[list[float] | str | None, @@ -162,14 +161,13 @@ async def manage_camera( max_resolution=max_resolution, capture_source=capture_source, batch=batch, - look_at=look_at, + view_target=view_target, orbit_angles=orbit_angles, orbit_elevations=orbit_elevations, orbit_distance=orbit_distance, orbit_fov=orbit_fov, view_position=view_position, view_rotation=view_rotation, - scene_view_target=scene_view_target, ) if err is not None: return err diff --git a/Server/src/services/tools/utils.py b/Server/src/services/tools/utils.py index 7091a4f10..86e483519 100644 --- a/Server/src/services/tools/utils.py +++ b/Server/src/services/tools/utils.py @@ -466,14 +466,13 @@ def build_screenshot_params( max_resolution: int | str | None = None, capture_source: str | None = None, batch: str | None = None, - look_at: str | int | list[float] | None = None, + view_target: str | int | list[float] | None = None, orbit_angles: int | str | None = None, orbit_elevations: list[float] | str | None = None, orbit_distance: float | str | None = None, orbit_fov: float | str | None = None, view_position: list[float] | str | None = None, view_rotation: list[float] | str | None = None, - scene_view_target: str | int | None = None, ) -> dict[str, Any] | None: """Populate screenshot-related keys in *params* dict. Returns an error dict if validation fails, or None on success. @@ -505,8 +504,8 @@ def build_screenshot_params( params["captureSource"] = normalized_capture_source if batch: params["batch"] = batch - if look_at is not None: - params["lookAt"] = look_at + if view_target is not None: + params["viewTarget"] = view_target # Orbit params coerced_orbit_angles = coerce_int(orbit_angles, default=None) @@ -543,14 +542,6 @@ def build_screenshot_params( if err: return {"success": False, "message": err} params["viewRotation"] = vec - if scene_view_target is not None: - if params.get("captureSource") != "scene_view": - return { - "success": False, - "message": "scene_view_target is only valid with capture_source='scene_view'.", - } - params["sceneViewTarget"] = scene_view_target - if params.get("captureSource") == "scene_view": if coerced_super_size is not None and coerced_super_size > 1: return { @@ -562,10 +553,10 @@ def build_screenshot_params( "success": False, "message": "capture_source='scene_view' does not support batch modes.", } - if look_at is not None or view_position is not None or view_rotation is not None: + if view_position is not None or view_rotation is not None: return { "success": False, - "message": "capture_source='scene_view' does not support look_at/view_position/view_rotation.", + "message": "capture_source='scene_view' does not support view_position/view_rotation.", } if camera: return { diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index c67886e26..976b03cad 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -465,14 +465,14 @@ def test_camera_screenshot_scene_view(self, runner, mock_unity_response): result = runner.invoke(cli, [ "camera", "screenshot", "--capture-source", "scene_view", - "--scene-view-target", "Canvas", + "--view-target", "Canvas", "--include-image", ]) assert result.exit_code == 0 mock_run.assert_called_once() params = mock_run.call_args[0][2] assert params["captureSource"] == "scene_view" - assert params["sceneViewTarget"] == "Canvas" + assert params["viewTarget"] == "Canvas" assert params["includeImage"] is True diff --git a/Server/tests/test_manage_camera.py b/Server/tests/test_manage_camera.py index 1bd832d6f..4919a520c 100644 --- a/Server/tests/test_manage_camera.py +++ b/Server/tests/test_manage_camera.py @@ -354,13 +354,13 @@ def test_screenshot_batch_surround(mock_unity): SimpleNamespace(), action="screenshot", batch="surround", - look_at="Player", + view_target="Player", include_image=True, ) ) assert result["success"] is True assert mock_unity["params"]["batch"] == "surround" - assert mock_unity["params"]["lookAt"] == "Player" + assert mock_unity["params"]["viewTarget"] == "Player" def test_screenshot_batch_orbit(mock_unity): @@ -389,13 +389,13 @@ def test_screenshot_positioned(mock_unity): SimpleNamespace(), action="screenshot", view_position=[5.0, 3.0, -10.0], - look_at="Player", + view_target="Player", include_image=True, ) ) assert result["success"] is True assert mock_unity["params"]["viewPosition"] == [5.0, 3.0, -10.0] - assert mock_unity["params"]["lookAt"] == "Player" + assert mock_unity["params"]["viewTarget"] == "Player" def test_screenshot_scene_view_capture_params(mock_unity): @@ -404,13 +404,13 @@ def test_screenshot_scene_view_capture_params(mock_unity): SimpleNamespace(), action="screenshot", capture_source="scene_view", - scene_view_target="Canvas", + view_target="Canvas", include_image=True, ) ) assert result["success"] is True assert mock_unity["params"]["captureSource"] == "scene_view" - assert mock_unity["params"]["sceneViewTarget"] == "Canvas" + assert mock_unity["params"]["viewTarget"] == "Canvas" assert mock_unity["params"]["includeImage"] is True @@ -441,17 +441,17 @@ def test_screenshot_scene_view_rejects_batch_in_python(mock_unity): assert "params" not in mock_unity -def test_screenshot_scene_view_target_requires_scene_view_capture(mock_unity): +def test_screenshot_view_target_works_without_capture_source(mock_unity): + """view_target should work for both game_view and scene_view (no rejection).""" result = asyncio.run( manage_camera( SimpleNamespace(), action="screenshot", - scene_view_target="Canvas", + view_target="Player", ) ) - assert result["success"] is False - assert "scene_view_target is only valid" in result["message"] - assert "params" not in mock_unity + assert result["success"] is True + assert mock_unity["params"]["viewTarget"] == "Player" def test_screenshot_multiview_sends_action(mock_unity): diff --git a/Server/uv.lock b/Server/uv.lock index 8afc08289..210dfaa1f 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -858,7 +858,7 @@ wheels = [ [[package]] name = "mcpforunityserver" -version = "9.5.2" +version = "9.5.3" source = { editable = "." } dependencies = [ { name = "click" }, diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs index 97aff5135..412c52a7c 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageSceneHierarchyPagingTests.cs @@ -169,17 +169,20 @@ public void EditorWindowScreenshotUtility_ClampsSceneViewSupersizeToOne() } [Test] - public void Screenshot_GameViewRejectsSceneViewTarget() + public void Screenshot_ViewTargetAcceptedForGameView() { + // view_target should be accepted for game_view (positioned capture path). + // It will fail to resolve a non-existent GO, but should NOT reject the parameter itself. var raw = ManageScene.HandleCommand(new JObject { ["action"] = "screenshot", - ["sceneViewTarget"] = "Canvas", + ["viewTarget"] = "NonExistentObject", }); var response = raw as JObject ?? JObject.FromObject(raw); + // Should attempt positioned capture and fail to resolve the GO — not reject the param Assert.IsFalse(response.Value("success"), response.ToString()); - StringAssert.Contains("scene_view_target is only valid", response.Value("message")); + StringAssert.Contains("not found", response.Value("message")); } [Test] diff --git a/docs/guides/CLI_EXAMPLE.md b/docs/guides/CLI_EXAMPLE.md index 6de104ed7..dbddaee81 100644 --- a/docs/guides/CLI_EXAMPLE.md +++ b/docs/guides/CLI_EXAMPLE.md @@ -77,8 +77,9 @@ unity-mcp --format json scene hierarchy unity-mcp camera screenshot --file-name "capture" unity-mcp camera screenshot --camera-ref "MainCam" --include-image --max-resolution 512 unity-mcp camera screenshot --batch surround --max-resolution 256 -unity-mcp camera screenshot --batch orbit --look-at "Player" -unity-mcp camera screenshot-multiview --look-at "Player" --max-resolution 480 +unity-mcp camera screenshot --batch orbit --view-target "Player" +unity-mcp camera screenshot --capture-source scene_view --view-target "Canvas" --include-image +unity-mcp camera screenshot-multiview --view-target "Player" --max-resolution 480 ``` **GameObject Operations** diff --git a/docs/guides/CLI_USAGE.md b/docs/guides/CLI_USAGE.md index 1c9601363..0c610503f 100644 --- a/docs/guides/CLI_USAGE.md +++ b/docs/guides/CLI_USAGE.md @@ -73,8 +73,9 @@ unity-mcp camera screenshot unity-mcp camera screenshot --file-name "level_preview" unity-mcp camera screenshot --camera-ref "SecondCamera" --include-image unity-mcp camera screenshot --batch surround --max-resolution 256 -unity-mcp camera screenshot --batch orbit --look-at "Player" -unity-mcp camera screenshot-multiview --look-at "Player" --max-resolution 480 +unity-mcp camera screenshot --batch orbit --view-target "Player" +unity-mcp camera screenshot --capture-source scene_view --view-target "Canvas" --include-image +unity-mcp camera screenshot-multiview --view-target "Player" --max-resolution 480 ``` ### GameObject Operations @@ -189,9 +190,10 @@ unity-mcp custom_tool list | `--include-image` | flag | Return base64 PNG inline in the response | | `--max-resolution, -r` | int | Max longest-edge pixels (default 640) | | `--batch, -b` | string | `surround` (6 angles) or `orbit` (configurable grid) | -| `--look-at` | string | Target: GameObject name/path/ID, or `x,y,z` world position | -| `--view-position` | string | Camera position as `x,y,z` (positioned screenshot) | -| `--view-rotation` | string | Camera euler rotation as `x,y,z` (positioned screenshot) | +| `--capture-source` | string | `game_view` (default) or `scene_view` (editor viewport) | +| `--view-target` | string | Target to focus on: GO name/path/ID, or `x,y,z`. Aims camera (game_view) or frames viewport (scene_view) | +| `--view-position` | string | Camera position as `x,y,z` (positioned screenshot, game_view only) | +| `--view-rotation` | string | Camera euler rotation as `x,y,z` (positioned screenshot, game_view only) | | `--orbit-angles` | int | Number of azimuth steps around target (default 8) | | `--orbit-elevations` | string | Vertical angles as JSON array, e.g. `[0,30,-15]` (default `[0, 30, -15]`) | | `--orbit-distance` | float | Camera distance from target in world units (auto-fit if omitted) | @@ -380,8 +382,9 @@ unity-mcp camera brain-status unity-mcp camera force "Cam" # Force Brain to use camera unity-mcp camera release # Release override unity-mcp camera screenshot --file-name "capture" --super-size 2 -unity-mcp camera screenshot --batch orbit --look-at "Player" --max-resolution 256 -unity-mcp camera screenshot-multiview --look-at "Player" --max-resolution 480 +unity-mcp camera screenshot --batch orbit --view-target "Player" --max-resolution 256 +unity-mcp camera screenshot --capture-source scene_view --view-target "Canvas" --include-image +unity-mcp camera screenshot-multiview --view-target "Player" --max-resolution 480 ``` ### Graphics Operations diff --git a/unity-mcp-skill/SKILL.md b/unity-mcp-skill/SKILL.md index 83a8824a6..6531b163a 100644 --- a/unity-mcp-skill/SKILL.md +++ b/unity-mcp-skill/SKILL.md @@ -71,18 +71,24 @@ manage_camera(action="screenshot", camera="MainCamera", include_image=True, max_ manage_camera(action="screenshot", batch="surround", max_resolution=256) # Batch surround centered on a specific object -manage_camera(action="screenshot", batch="surround", look_at="Player", max_resolution=256) +manage_camera(action="screenshot", batch="surround", view_target="Player", max_resolution=256) # Positioned screenshot: place a temp camera and capture in one call -manage_camera(action="screenshot", look_at="Player", view_position=[0, 10, -10], max_resolution=512) +manage_camera(action="screenshot", view_target="Player", view_position=[0, 10, -10], max_resolution=512) + +# Scene View screenshot: capture what the developer sees in the editor +manage_camera(action="screenshot", capture_source="scene_view", include_image=True) + +# Scene View framed on a specific object +manage_camera(action="screenshot", capture_source="scene_view", view_target="Canvas", include_image=True) ``` **Best practices for AI scene understanding:** - Use `include_image=True` when you need to *see* the scene, not just save a file. - Use `batch="surround"` for a comprehensive overview (6 angles, one command). -- Use `look_at`/`view_position` to capture from a specific viewpoint without needing a scene camera. +- Use `view_target`/`view_position` to capture from a specific viewpoint without needing a scene camera. +- Use `capture_source="scene_view"` to see the editor viewport (gizmos, wireframes, grid). - Keep `max_resolution` at 256–512 to balance quality vs. token cost. -- Combine with `look_at` on `manage_gameobject` to orient a game camera before capturing. ```python # Agentic camera loop: point, shoot, analyze @@ -90,9 +96,11 @@ manage_gameobject(action="look_at", target="MainCamera", look_at_target="Player" manage_camera(action="screenshot", camera="MainCamera", include_image=True, max_resolution=512) # → Analyze image, decide next action -# Screenshot from a different camera -manage_camera(action="screenshot", camera="FollowCam", include_image=True, max_resolution=512) -manage_camera(action="screenshot_multiview", max_resolution=480) # 6-angle contact sheet +# Multi-view screenshot (6-angle contact sheet) +manage_camera(action="screenshot_multiview", max_resolution=480) + +# Scene View for editor-level inspection (shows gizmos, debug overlays, etc.) +manage_camera(action="screenshot", capture_source="scene_view", view_target="Player", include_image=True) ``` ### 4. Check Console After Major Changes diff --git a/unity-mcp-skill/references/tools-reference.md b/unity-mcp-skill/references/tools-reference.md index e6d594195..f8ec00982 100644 --- a/unity-mcp-skill/references/tools-reference.md +++ b/unity-mcp-skill/references/tools-reference.md @@ -136,7 +136,7 @@ manage_scene( manage_scene( action="screenshot", batch="surround", - look_at="Player", # str|int|list[float] - center surround on this target + view_target="Player", # str|int|list[float] - center surround on this target max_resolution=256 ) @@ -144,7 +144,7 @@ manage_scene( manage_scene( action="screenshot", batch="orbit", # str - "orbit" for configurable angle grid - look_at="Player", # str|int|list[float] - target to orbit around + view_target="Player", # str|int|list[float] - target to orbit around orbit_angles=8, # int, default 8 - number of azimuth steps orbit_elevations=[0, 30], # list[float], default [0, 30, -15] - vertical angles in degrees orbit_distance=10, # float, optional - camera distance (auto-fit if omitted) @@ -156,9 +156,9 @@ manage_scene( # Positioned screenshot (temp camera at viewpoint, no file saved) manage_scene( action="screenshot", - look_at="Enemy", # str|int|list[float] - target to aim at + view_target="Enemy", # str|int|list[float] - target to aim at view_position=[0, 10, -10], # list[float], optional - camera position - view_rotation=[45, 0, 0], # list[float], optional - euler angles (overrides look_at aim) + view_rotation=[45, 0, 0], # list[float], optional - euler angles (overrides view_target aim) max_resolution=512 ) @@ -821,13 +821,14 @@ Unified camera management (Unity Camera + Cinemachine). Works without Cinemachin | Parameter | Type | Description | |-----------|------|-------------| -| `camera` | string | Camera to capture from (defaults to Camera.main) | +| `capture_source` | string | `"game_view"` (default) or `"scene_view"` (editor viewport) | +| `view_target` | string\|int\|list | Target to focus on (GO name/path/ID or [x,y,z]). game_view: aims camera; scene_view: frames viewport | +| `camera` | string | Camera to capture from (defaults to Camera.main). game_view only | | `include_image` | bool | Return base64 PNG inline (default false) | | `max_resolution` | int | Downscale cap in px (default 640) | -| `batch` | string | `"surround"` (6 angles) or `"orbit"` (configurable grid) | -| `look_at` | string\|int\|list | Target to aim at (GO name/path/ID or [x,y,z]) | -| `view_position` | list[float] | World position [x,y,z] to place camera | -| `view_rotation` | list[float] | Euler rotation [x,y,z] (overrides look_at) | +| `batch` | string | `"surround"` (6 angles) or `"orbit"` (configurable grid). game_view only | +| `view_position` | list[float] | World position [x,y,z] to place camera. game_view only | +| `view_rotation` | list[float] | Euler rotation [x,y,z] (overrides view_target). game_view only | **Actions by category:** @@ -858,7 +859,7 @@ Unified camera management (Unity Camera + Cinemachine). Works without Cinemachin - `release_override` — Release camera override **Capture:** -- `screenshot` — Capture from a camera. Supports inline base64, batch surround/orbit, positioned capture. +- `screenshot` — Capture screenshot. Supports `capture_source="game_view"` (default, camera-based) or `"scene_view"` (editor viewport). game_view supports inline base64, batch surround/orbit, positioned capture. scene_view supports `view_target` for framing. - `screenshot_multiview` — Shorthand for screenshot with batch='surround' and include_image=true. **Examples:** @@ -909,9 +910,15 @@ manage_camera(action="add_extension", target="FollowCam", properties={ "extensionType": "CinemachineDeoccluder" }) -# Screenshot from a specific camera +# Screenshot from a specific camera (game_view, default) manage_camera(action="screenshot", camera="FollowCam", include_image=True, max_resolution=512) +# Scene View screenshot (captures editor viewport — gizmos, wireframes, grid) +manage_camera(action="screenshot", capture_source="scene_view", include_image=True) + +# Scene View screenshot framed on a specific object +manage_camera(action="screenshot", capture_source="scene_view", view_target="Canvas", include_image=True) + # Multi-view screenshot (6-angle contact sheet) manage_camera(action="screenshot_multiview", max_resolution=480) diff --git a/unity-mcp-skill/references/workflows.md b/unity-mcp-skill/references/workflows.md index 171f6fb18..8648eec32 100644 --- a/unity-mcp-skill/references/workflows.md +++ b/unity-mcp-skill/references/workflows.md @@ -1529,6 +1529,26 @@ manage_camera(action="list_cameras") manage_camera(action="screenshot_multiview", max_resolution=480) ``` +### Scene View Screenshot Workflow + +Use `capture_source="scene_view"` to capture the editor's Scene View viewport — useful for seeing gizmos, wireframes, grid, debug overlays, and objects without cameras. + +```python +# 1. Capture the Scene View as-is +manage_camera(action="screenshot", capture_source="scene_view", include_image=True) + +# 2. Frame on a specific object first, then capture +manage_camera(action="screenshot", capture_source="scene_view", + view_target="Player", include_image=True, max_resolution=512) + +# 3. Frame on UI Canvas (RectTransform bounds are supported) +manage_camera(action="screenshot", capture_source="scene_view", + view_target="Canvas", include_image=True) + +# Limitations: scene_view does not support batch, view_position, view_rotation, or camera selection. +# Use capture_source="game_view" (default) for those features. +``` + --- ## ProBuilder Workflows From 38a4daeca0d3e1251ed9ce8da0deae404f45958c Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:50:35 -0400 Subject: [PATCH 9/9] Update on GUI --- MCPForUnity/Editor/Tools/ManageScene.cs | 10 ++++ .../Editor/Windows/Components/Common.uss | 4 ++ .../Components/Tools/McpToolsSection.cs | 54 ++++++++++++++++--- .../Editor/Windows/MCPForUnityEditorWindow.cs | 3 +- .../Windows/MCPForUnityEditorWindow.uss | 10 +++- 5 files changed, 72 insertions(+), 9 deletions(-) diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index d999ec0d4..cdae1f22d 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -247,6 +247,16 @@ public static object ExecuteScreenshot(string fileName = null, int? superSize = /// Captures a 6-angle contact-sheet around the scene bounds centre. /// Public so the tools UI can reuse the same logic. /// + /// + /// Captures the active Scene View viewport to a PNG asset. + /// Public so the tools UI can reuse the same logic. + /// + public static object ExecuteSceneViewScreenshot(string fileName = null) + { + var cmd = new SceneCommand { fileName = fileName ?? string.Empty }; + return CaptureSceneViewScreenshot(cmd, cmd.fileName, 1, false, 0); + } + public static object ExecuteMultiviewScreenshot(int maxResolution = 480) { var cmd = new SceneCommand { maxResolution = maxResolution }; diff --git a/MCPForUnity/Editor/Windows/Components/Common.uss b/MCPForUnity/Editor/Windows/Components/Common.uss index 8c63aa0d7..24a69d01c 100644 --- a/MCPForUnity/Editor/Windows/Components/Common.uss +++ b/MCPForUnity/Editor/Windows/Components/Common.uss @@ -335,10 +335,14 @@ .tool-action-button { flex-grow: 1; + flex-shrink: 1; flex-basis: 0; min-width: 0; height: 26px; margin-bottom: 4px; + overflow: hidden; + -unity-text-overflow-position: end; + text-overflow: ellipsis; } .tool-category-container { diff --git a/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs b/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs index 1f9651447..07dfd3ea7 100644 --- a/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs @@ -590,28 +590,44 @@ private VisualElement CreateManageSceneActions() var actions = new VisualElement(); actions.AddToClassList("tool-item-actions"); - var screenshotButton = new Button(OnManageSceneScreenshotClicked) + var gameViewButton = new Button(OnManageSceneScreenshotClicked) { - text = "Capture Screenshot" + text = "Game View" }; - screenshotButton.AddToClassList("tool-action-button"); - screenshotButton.style.marginTop = 4; - screenshotButton.tooltip = "Capture a screenshot to Assets/Screenshots via manage_camera."; + gameViewButton.AddToClassList("tool-action-button"); + gameViewButton.style.marginTop = 4; + gameViewButton.tooltip = "Capture a game camera screenshot to Assets/Screenshots."; + + var sceneViewButton = new Button(OnSceneViewScreenshotClicked) + { + text = "Scene View" + }; + sceneViewButton.AddToClassList("tool-action-button"); + sceneViewButton.style.marginTop = 4; + sceneViewButton.style.marginLeft = 4; + sceneViewButton.tooltip = "Capture the active Scene View viewport to Assets/Screenshots."; var multiviewButton = new Button(OnManageSceneMultiviewClicked) { - text = "Capture Multiview" + text = "Multiview" }; multiviewButton.AddToClassList("tool-action-button"); multiviewButton.style.marginTop = 4; multiviewButton.style.marginLeft = 4; multiviewButton.tooltip = "Capture a 6-angle contact sheet around the scene centre and save to Assets/Screenshots."; + var captureLabel = new Label("Capture:"); + captureLabel.style.marginTop = 6; + captureLabel.style.unityFontStyleAndWeight = UnityEngine.FontStyle.Normal; + var row = new VisualElement(); row.style.flexDirection = FlexDirection.Row; - row.Add(screenshotButton); + row.style.flexWrap = Wrap.Wrap; + row.Add(gameViewButton); + row.Add(sceneViewButton); row.Add(multiviewButton); + actions.Add(captureLabel); actions.Add(row); return actions; } @@ -686,6 +702,30 @@ private void OnManageSceneScreenshotClicked() } } + private void OnSceneViewScreenshotClicked() + { + try + { + var response = ManageScene.ExecuteSceneViewScreenshot(); + if (response is SuccessResponse success && !string.IsNullOrWhiteSpace(success.Message)) + { + McpLog.Info(success.Message); + } + else if (response is ErrorResponse error && !string.IsNullOrWhiteSpace(error.Error)) + { + McpLog.Error(error.Error); + } + else + { + McpLog.Info("Scene View screenshot capture requested."); + } + } + catch (Exception ex) + { + McpLog.Error($"Failed to capture Scene View screenshot: {ex.Message}"); + } + } + private void OnManageSceneMultiviewClicked() { try diff --git a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs index ef7624b8c..bf41353a6 100644 --- a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs @@ -382,7 +382,8 @@ private void CheckForPackageUpdates() var result = MCPServiceLocator.Updates.CheckForUpdate(currentVersion); if (result.CheckSucceeded && result.UpdateAvailable && !string.IsNullOrEmpty(result.LatestVersion)) { - updateNotificationText.text = $"Newer version available: v{result.LatestVersion} (current v{currentVersion})"; + updateNotificationText.text = $"Update available: v{result.LatestVersion} (current: v{currentVersion})"; + updateNotificationText.tooltip = $"Latest version: v{result.LatestVersion}\nCurrent version: v{currentVersion}"; updateNotification.AddToClassList("visible"); } else diff --git a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.uss b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.uss index a374a6331..212d943aa 100644 --- a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.uss +++ b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.uss @@ -40,12 +40,17 @@ /* Update Notification */ .update-notification { display: none; - padding: 8px 16px; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: center; + padding: 6px 12px; margin: 0px 12px; background-color: rgba(100, 200, 100, 0.15); border-radius: 4px; border-width: 1px; border-color: rgba(100, 200, 100, 0.3); + flex-shrink: 0; } .update-notification.visible { @@ -56,6 +61,9 @@ font-size: 11px; color: rgba(100, 200, 100, 1); white-space: normal; + flex-shrink: 1; + overflow: hidden; + -unity-text-align: middle-center; } /* Tabs */