diff --git a/.claude/skills/unity-mcp-skill/SKILL.md b/.claude/skills/unity-mcp-skill/SKILL.md index c03f8a655..c683e3e9e 100644 --- a/.claude/skills/unity-mcp-skill/SKILL.md +++ b/.claude/skills/unity-mcp-skill/SKILL.md @@ -159,10 +159,11 @@ uri="file:///full/path/to/file.cs" | **Objects** | `manage_gameobject`, `manage_components` | Creating/modifying GameObjects | | **Scripts** | `create_script`, `script_apply_edits`, `refresh_unity` | C# code management | | **Assets** | `manage_asset`, `manage_prefabs` | Asset operations | -| **Editor** | `manage_editor`, `execute_menu_item`, `read_console` | Editor control | +| **Editor** | `manage_editor`, `execute_menu_item`, `read_console` | Editor control, package deployment (`deploy_package`/`restore_package` actions) | | **Testing** | `run_tests`, `get_test_job` | Unity Test Framework | | **Batch** | `batch_execute` | Parallel/bulk operations | | **Camera** | `manage_camera` | Camera management (Unity Camera + Cinemachine). **Tier 1** (always available): create, target, lens, priority, list, screenshot. **Tier 2** (requires `com.unity.cinemachine`): brain, body/aim/noise pipeline, extensions, blending, force/release. 7 presets: follow, third_person, freelook, dolly, static, top_down, side_scroller. Resource: `mcpforunity://scene/cameras`. Use `ping` to check Cinemachine availability. See [tools-reference.md](references/tools-reference.md#camera-tools). | +| **Graphics** | `manage_graphics` | Rendering and post-processing management. 33 actions across 5 groups: **Volume** (create/configure volumes and effects, URP/HDRP), **Bake** (lightmaps, light probes, reflection probes, Edit mode only), **Stats** (draw calls, batches, memory), **Pipeline** (quality levels, pipeline settings), **Features** (URP renderer features: add, remove, toggle, reorder). Resources: `mcpforunity://scene/volumes`, `mcpforunity://rendering/stats`, `mcpforunity://pipeline/renderer-features`. Use `ping` to check pipeline status. See [tools-reference.md](references/tools-reference.md#graphics-tools). | | **ProBuilder** | `manage_probuilder` | 3D modeling, mesh editing, complex geometry. **When `com.unity.probuilder` is installed, prefer ProBuilder shapes over primitive GameObjects** for editable geometry, multi-material faces, or complex shapes. Supports 12 shape types, face/edge/vertex editing, smoothing, and per-face materials. See [ProBuilder Guide](references/probuilder-guide.md). | | **UI** | `manage_ui`, `batch_execute` with `manage_gameobject` + `manage_components` | **UI Toolkit**: Use `manage_ui` to create UXML/USS files, attach UIDocument, inspect visual trees. **uGUI (Canvas)**: Use `batch_execute` for Canvas, Panel, Button, Text, Slider, Toggle, Input Field. **Read `mcpforunity://project/info` first** to detect uGUI/TMP/Input System/UI Toolkit availability. (see [UI workflows](references/workflows.md#ui-creation-workflows)) | diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index c648c19e4..53610d965 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -47,6 +47,10 @@ public static string SanitizeAssetPath(string path) } // Ensure path starts with Assets/ + if (string.Equals(path, "Assets", StringComparison.OrdinalIgnoreCase)) + { + return "Assets"; + } if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) { return "Assets/" + path.TrimStart('/'); diff --git a/MCPForUnity/Editor/Helpers/ComponentOps.cs b/MCPForUnity/Editor/Helpers/ComponentOps.cs index e4e456ac4..97e8a2b76 100644 --- a/MCPForUnity/Editor/Helpers/ComponentOps.cs +++ b/MCPForUnity/Editor/Helpers/ComponentOps.cs @@ -564,7 +564,7 @@ private static bool SetObjectReference(SerializedProperty prop, JToken value, ou if (value.Type == JTokenType.Integer) { int id = value.Value(); - var resolved = EditorUtility.InstanceIDToObject(id); + var resolved = GameObjectLookup.ResolveInstanceID(id); if (resolved == null) { error = $"No object found with instanceID {id}."; @@ -580,7 +580,7 @@ private static bool SetObjectReference(SerializedProperty prop, JToken value, ou if (idToken != null) { int id = ParamCoercion.CoerceInt(idToken, 0); - var resolved = EditorUtility.InstanceIDToObject(id); + var resolved = GameObjectLookup.ResolveInstanceID(id); if (resolved == null) { error = $"No object found with instanceID {id}."; diff --git a/MCPForUnity/Editor/Helpers/GameObjectLookup.cs b/MCPForUnity/Editor/Helpers/GameObjectLookup.cs index bd23bbd7a..a5e480f2d 100644 --- a/MCPForUnity/Editor/Helpers/GameObjectLookup.cs +++ b/MCPForUnity/Editor/Helpers/GameObjectLookup.cs @@ -64,14 +64,24 @@ public static GameObject FindByTarget(JToken target, string searchMethod, bool i return results.Count > 0 ? FindById(results[0]) : null; } + /// + /// Resolves an instance ID to a UnityEngine.Object. + /// + public static UnityEngine.Object ResolveInstanceID(int instanceId) + { +#if UNITY_6000_3_OR_NEWER + return EditorUtility.EntityIdToObject(instanceId); +#else + return EditorUtility.InstanceIDToObject(instanceId); +#endif + } + /// /// Finds a GameObject by its instance ID. /// public static GameObject FindById(int instanceId) { -#pragma warning disable CS0618 // Type or member is obsolete - return EditorUtility.InstanceIDToObject(instanceId) as GameObject; -#pragma warning restore CS0618 + return ResolveInstanceID(instanceId) as GameObject; } /// @@ -105,9 +115,7 @@ public static List SearchGameObjects(SearchMethod method, string searchTerm case SearchMethod.ById: if (int.TryParse(searchTerm, out int instanceId)) { -#pragma warning disable CS0618 // Type or member is obsolete - var obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; -#pragma warning restore CS0618 + var obj = ResolveInstanceID(instanceId) as GameObject; if (obj != null && (includeInactive || obj.activeInHierarchy)) { results.Add(instanceId); diff --git a/MCPForUnity/Editor/Resources/Editor/Selection.cs b/MCPForUnity/Editor/Resources/Editor/Selection.cs index 022d9c488..406b9c8f8 100644 --- a/MCPForUnity/Editor/Resources/Editor/Selection.cs +++ b/MCPForUnity/Editor/Resources/Editor/Selection.cs @@ -21,7 +21,7 @@ public static object HandleCommand(JObject @params) activeObject = UnityEditor.Selection.activeObject?.name, activeGameObject = UnityEditor.Selection.activeGameObject?.name, activeTransform = UnityEditor.Selection.activeTransform?.name, - activeInstanceID = UnityEditor.Selection.activeInstanceID, + activeInstanceID = UnityEditor.Selection.activeObject?.GetInstanceID() ?? 0, count = UnityEditor.Selection.count, objects = UnityEditor.Selection.objects .Select(obj => new diff --git a/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs b/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs index 2588349f5..40f41a486 100644 --- a/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs +++ b/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs @@ -44,7 +44,7 @@ public static object HandleCommand(JObject @params) try { - var go = EditorUtility.InstanceIDToObject(instanceID.Value) as GameObject; + var go = GameObjectLookup.ResolveInstanceID(instanceID.Value) as GameObject; if (go == null) { return new ErrorResponse($"GameObject with instance ID {instanceID} not found."); @@ -150,7 +150,7 @@ public static object HandleCommand(JObject @params) try { - var go = EditorUtility.InstanceIDToObject(instanceID) as GameObject; + var go = GameObjectLookup.ResolveInstanceID(instanceID) as GameObject; if (go == null) { return new ErrorResponse($"GameObject with instance ID {instanceID} not found."); @@ -235,7 +235,7 @@ public static object HandleCommand(JObject @params) try { - var go = EditorUtility.InstanceIDToObject(instanceID) as GameObject; + var go = GameObjectLookup.ResolveInstanceID(instanceID) as GameObject; if (go == null) { return new ErrorResponse($"GameObject with instance ID {instanceID} not found."); diff --git a/MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs b/MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs new file mode 100644 index 000000000..11efa3800 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs @@ -0,0 +1,24 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools.Graphics; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Resources.Scene +{ + [McpForUnityResource("get_renderer_features")] + public static class RendererFeaturesResource + { + public static object HandleCommand(JObject @params) + { + try + { + return RendererFeatureOps.ListFeatures(@params ?? new JObject()); + } + catch (Exception e) + { + McpLog.Error($"[RendererFeaturesResource] Error: {e}"); + return new ErrorResponse($"Error listing renderer features: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs.meta b/MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs.meta new file mode 100644 index 000000000..a4b143b33 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/RendererFeaturesResource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 91dffec0c5224fca9ea78f7d92bfc569 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs b/MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs new file mode 100644 index 000000000..58f2ebbd8 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs @@ -0,0 +1,24 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools.Graphics; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Resources.Scene +{ + [McpForUnityResource("get_rendering_stats")] + public static class RenderingStatsResource + { + public static object HandleCommand(JObject @params) + { + try + { + return RenderingStatsOps.GetStats(@params ?? new JObject()); + } + catch (Exception e) + { + McpLog.Error($"[RenderingStatsResource] Error: {e}"); + return new ErrorResponse($"Error getting rendering stats: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs.meta b/MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs.meta new file mode 100644 index 000000000..5674f0069 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/RenderingStatsResource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a6c0a7ee8d9443a9aec534f04dbee225 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Resources/Scene/VolumesResource.cs b/MCPForUnity/Editor/Resources/Scene/VolumesResource.cs new file mode 100644 index 000000000..08afdfe7e --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/VolumesResource.cs @@ -0,0 +1,24 @@ +using System; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools.Graphics; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Resources.Scene +{ + [McpForUnityResource("get_volumes")] + public static class VolumesResource + { + public static object HandleCommand(JObject @params) + { + try + { + return VolumeOps.ListVolumes(@params ?? new JObject()); + } + catch (Exception e) + { + McpLog.Error($"[VolumesResource] Error listing volumes: {e}"); + return new ErrorResponse($"Error listing volumes: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Scene/VolumesResource.cs.meta b/MCPForUnity/Editor/Resources/Scene/VolumesResource.cs.meta new file mode 100644 index 000000000..0e4a7b0dc --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/VolumesResource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 83cc61dc0e644cf2abd24ad611aa315c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Cameras/CameraControl.cs b/MCPForUnity/Editor/Tools/Cameras/CameraControl.cs index 1b33393d1..982807f70 100644 --- a/MCPForUnity/Editor/Tools/Cameras/CameraControl.cs +++ b/MCPForUnity/Editor/Tools/Cameras/CameraControl.cs @@ -13,7 +13,11 @@ internal static class CameraControl { internal static object ListCameras(JObject @params) { +#if UNITY_2022_2_OR_NEWER + var unityCameras = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None); +#else var unityCameras = UnityEngine.Object.FindObjectsOfType(); +#endif var cameraList = new List(); var unityCamList = new List(); @@ -21,7 +25,11 @@ internal static object ListCameras(JObject @params) if (CameraHelpers.HasCinemachine) { var cmType = CameraHelpers.CinemachineCameraType; +#if UNITY_2022_2_OR_NEWER + var allCm = UnityEngine.Object.FindObjectsByType(cmType, FindObjectsSortMode.None); +#else var allCm = UnityEngine.Object.FindObjectsOfType(cmType); +#endif foreach (Component cm in allCm) { var follow = CameraHelpers.GetReflectionProperty(cm, "Follow") as Transform; diff --git a/MCPForUnity/Editor/Tools/Cameras/CameraHelpers.cs b/MCPForUnity/Editor/Tools/Cameras/CameraHelpers.cs index 7255e0470..5ca870e55 100644 --- a/MCPForUnity/Editor/Tools/Cameras/CameraHelpers.cs +++ b/MCPForUnity/Editor/Tools/Cameras/CameraHelpers.cs @@ -141,7 +141,11 @@ internal static Component FindBrain() if (!HasCinemachine || _cmBrainType == null) return null; +#if UNITY_2022_2_OR_NEWER + return UnityEngine.Object.FindFirstObjectByType(_cmBrainType) as Component; +#else return UnityEngine.Object.FindObjectOfType(_cmBrainType) as Component; +#endif } internal static UnityEngine.Camera FindMainCamera() @@ -149,7 +153,11 @@ internal static UnityEngine.Camera FindMainCamera() var main = UnityEngine.Camera.main; if (main != null) return main; +#if UNITY_2022_2_OR_NEWER + var allCams = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None); +#else var allCams = UnityEngine.Object.FindObjectsOfType(); +#endif return allCams.Length > 0 ? allCams[0] : null; } diff --git a/MCPForUnity/Editor/Tools/Graphics.meta b/MCPForUnity/Editor/Tools/Graphics.meta new file mode 100644 index 000000000..6eac24764 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fbd13c8334e847f58acef6bbae6d5c35 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Graphics/GraphicsHelpers.cs b/MCPForUnity/Editor/Tools/Graphics/GraphicsHelpers.cs new file mode 100644 index 000000000..cd5093899 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/GraphicsHelpers.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Graphics +{ + internal static class GraphicsHelpers + { + private static bool? _hasVolumeSystem; + private static Type _volumeType; + private static Type _volumeProfileType; + private static Type _volumeComponentType; + private static Type _volumeParameterType; + + internal static bool HasVolumeSystem + { + get + { + if (_hasVolumeSystem == null) DetectPackages(); + return _hasVolumeSystem.Value; + } + } + + internal static bool HasURP => + RenderPipelineUtility.GetActivePipeline() == RenderPipelineUtility.PipelineKind.Universal; + + internal static bool HasHDRP => + RenderPipelineUtility.GetActivePipeline() == RenderPipelineUtility.PipelineKind.HighDefinition; + + internal static Type VolumeType + { + get + { + if (_hasVolumeSystem == null) DetectPackages(); + return _volumeType; + } + } + + internal static Type VolumeProfileType + { + get + { + if (_hasVolumeSystem == null) DetectPackages(); + return _volumeProfileType; + } + } + + internal static Type VolumeComponentType + { + get + { + if (_hasVolumeSystem == null) DetectPackages(); + return _volumeComponentType; + } + } + + internal static Type VolumeParameterType + { + get + { + if (_hasVolumeSystem == null) DetectPackages(); + return _volumeParameterType; + } + } + + private static void DetectPackages() + { + _volumeType = Type.GetType("UnityEngine.Rendering.Volume, Unity.RenderPipelines.Core.Runtime"); + _volumeProfileType = Type.GetType("UnityEngine.Rendering.VolumeProfile, Unity.RenderPipelines.Core.Runtime"); + _volumeComponentType = Type.GetType("UnityEngine.Rendering.VolumeComponent, Unity.RenderPipelines.Core.Runtime"); + _volumeParameterType = Type.GetType("UnityEngine.Rendering.VolumeParameter, Unity.RenderPipelines.Core.Runtime"); + _hasVolumeSystem = _volumeType != null && _volumeProfileType != null; + } + + internal static Type ResolveVolumeComponentType(string effectName) + { + if (string.IsNullOrEmpty(effectName) || VolumeComponentType == null) + return null; + + var derivedTypes = TypeCache.GetTypesDerivedFrom(VolumeComponentType); + foreach (var t in derivedTypes) + { + if (t.IsAbstract) continue; + if (string.Equals(t.Name, effectName, StringComparison.OrdinalIgnoreCase)) + return t; + } + return null; + } + + internal static List GetAvailableEffectTypes() + { + if (VolumeComponentType == null) + return new List(); + var derivedTypes = TypeCache.GetTypesDerivedFrom(VolumeComponentType); + return derivedTypes + .Where(t => !t.IsAbstract && !t.IsGenericType) + .OrderBy(t => t.Name) + .ToList(); + } + + internal static Component FindVolume(JObject @params) + { + var p = new ToolParams(@params); + string target = p.Get("target"); + if (string.IsNullOrEmpty(target)) + { +#if UNITY_2022_2_OR_NEWER + var allVolumes = UnityEngine.Object.FindObjectsByType(VolumeType, FindObjectsSortMode.None); +#else + var allVolumes = UnityEngine.Object.FindObjectsOfType(VolumeType); +#endif + return allVolumes.Length > 0 ? allVolumes[0] as Component : null; + } + + if (int.TryParse(target, out int instanceId)) + { + var byId = GameObjectLookup.ResolveInstanceID(instanceId) as GameObject; + if (byId != null) return byId.GetComponent(VolumeType); + } + + var go = GameObject.Find(target); + if (go != null) return go.GetComponent(VolumeType); + + return null; + } + + internal static string GetPipelineName() + { + return RenderPipelineUtility.GetActivePipeline() switch + { + RenderPipelineUtility.PipelineKind.Universal => "Universal (URP)", + RenderPipelineUtility.PipelineKind.HighDefinition => "High Definition (HDRP)", + RenderPipelineUtility.PipelineKind.BuiltIn => "Built-in", + RenderPipelineUtility.PipelineKind.Custom => "Custom", + _ => "Unknown" + }; + } + + internal static object ReadSerializedValue(SerializedProperty prop) + { + return prop.propertyType switch + { + SerializedPropertyType.Boolean => prop.boolValue, + SerializedPropertyType.Integer => prop.intValue, + SerializedPropertyType.Float => prop.floatValue, + SerializedPropertyType.String => prop.stringValue, + SerializedPropertyType.Enum => prop.enumValueIndex < prop.enumNames.Length + ? prop.enumNames[prop.enumValueIndex] + : (object)prop.enumValueIndex, + SerializedPropertyType.ObjectReference => prop.objectReferenceValue != null + ? (object)new + { + name = prop.objectReferenceValue.name, + path = AssetDatabase.GetAssetPath(prop.objectReferenceValue) + } + : null, + SerializedPropertyType.Color => new[] { prop.colorValue.r, prop.colorValue.g, prop.colorValue.b, prop.colorValue.a }, + SerializedPropertyType.Vector2 => new[] { prop.vector2Value.x, prop.vector2Value.y }, + SerializedPropertyType.Vector3 => new[] { prop.vector3Value.x, prop.vector3Value.y, prop.vector3Value.z }, + SerializedPropertyType.LayerMask => prop.intValue, + _ => prop.propertyType.ToString() + }; + } + + internal static bool SetSerializedValue(SerializedProperty prop, JToken value) + { + try + { + switch (prop.propertyType) + { + case SerializedPropertyType.Boolean: + prop.boolValue = ParamCoercion.CoerceBool(value, false); + return true; + case SerializedPropertyType.Integer: + prop.intValue = ParamCoercion.CoerceInt(value, 0); + return true; + case SerializedPropertyType.Float: + prop.floatValue = ParamCoercion.CoerceFloat(value, 0f); + return true; + case SerializedPropertyType.String: + prop.stringValue = value.ToString(); + return true; + case SerializedPropertyType.Enum: + if (value.Type == JTokenType.String) + { + for (int i = 0; i < prop.enumNames.Length; i++) + { + if (string.Equals(prop.enumNames[i], value.ToString(), StringComparison.OrdinalIgnoreCase)) + { prop.enumValueIndex = i; return true; } + } + } + prop.enumValueIndex = ParamCoercion.CoerceInt(value, 0); + return true; + case SerializedPropertyType.ObjectReference: + if (value.Type == JTokenType.String) + { + string path = value.ToString(); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) { prop.objectReferenceValue = asset; return true; } + } + else if (value.Type == JTokenType.Object) + { + string path = value["path"]?.ToString(); + if (!string.IsNullOrEmpty(path)) + { + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) { prop.objectReferenceValue = asset; return true; } + } + } + else if (value.Type == JTokenType.Null) + { + prop.objectReferenceValue = null; + return true; + } + return false; + case SerializedPropertyType.Color: + if (value is JArray colorArr && colorArr.Count >= 3) + { + prop.colorValue = new Color( + (float)colorArr[0], (float)colorArr[1], (float)colorArr[2], + colorArr.Count >= 4 ? (float)colorArr[3] : 1f); + return true; + } + return false; + case SerializedPropertyType.Vector2: + if (value is JArray v2Arr && v2Arr.Count >= 2) + { + prop.vector2Value = new Vector2((float)v2Arr[0], (float)v2Arr[1]); + return true; + } + return false; + case SerializedPropertyType.Vector3: + if (value is JArray v3Arr && v3Arr.Count >= 3) + { + prop.vector3Value = new Vector3((float)v3Arr[0], (float)v3Arr[1], (float)v3Arr[2]); + return true; + } + return false; + case SerializedPropertyType.LayerMask: + prop.intValue = ParamCoercion.CoerceInt(value, 0); + return true; + default: + return false; + } + } + catch { return false; } + } + + internal static void MarkDirty(UnityEngine.Object obj) + { + if (obj == null) return; + EditorUtility.SetDirty(obj); + if (obj is Component comp) + { + var prefabStage = UnityEditor.SceneManagement.PrefabStageUtility.GetCurrentPrefabStage(); + if (prefabStage != null) + UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(prefabStage.scene); + else + UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(comp.gameObject.scene); + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/Graphics/GraphicsHelpers.cs.meta b/MCPForUnity/Editor/Tools/Graphics/GraphicsHelpers.cs.meta new file mode 100644 index 000000000..dfdaf2ee2 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/GraphicsHelpers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 99e4b1a4aa03465ea2d55e2794537155 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Graphics/LightBakingOps.cs b/MCPForUnity/Editor/Tools/Graphics/LightBakingOps.cs new file mode 100644 index 000000000..9d8241d25 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/LightBakingOps.cs @@ -0,0 +1,584 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.Rendering; + +namespace MCPForUnity.Editor.Tools.Graphics +{ + internal static class LightBakingOps + { + // === bake_start === + // Params: async (bool, default true) + internal static object StartBake(JObject @params) + { + if (Application.isPlaying) + return new ErrorResponse("Light baking requires Edit mode."); + + var p = new ToolParams(@params); + bool async_ = p.GetBool("async", true); + + if (async_) + { + Lightmapping.BakeAsync(); + return new PendingResponse( + "Light bake started (async). Use bake_status to check progress.", + pollIntervalSeconds: 2.0, + data: new { mode = "async" } + ); + } + + Lightmapping.Bake(); + return new + { + success = true, + message = "Light bake completed (synchronous).", + data = new + { + mode = "sync", + lightmapCount = LightmapSettings.lightmaps.Length + } + }; + } + + // === bake_cancel === + internal static object CancelBake(JObject @params) + { + Lightmapping.Cancel(); + return new + { + success = true, + message = "Light bake cancelled." + }; + } + + // === bake_get_status === + internal static object GetStatus(JObject @params) + { + bool running = Lightmapping.isRunning; + return new + { + success = true, + message = running ? "Light bake in progress." : "No bake running.", + data = new + { + isRunning = running, + bakedGI = Lightmapping.bakedGI, + realtimeGI = Lightmapping.realtimeGI, + lightmapCount = LightmapSettings.lightmaps.Length + } + }; + } + + // === bake_clear === + internal static object ClearBake(JObject @params) + { + Lightmapping.Clear(); + Lightmapping.ClearLightingDataAsset(); + return new + { + success = true, + message = "Cleared all baked lighting data and lighting data asset." + }; + } + + // === bake_reflection_probe === + // Params: target (name or instanceID of GameObject with ReflectionProbe) + internal static object BakeReflectionProbe(JObject @params) + { + if (Application.isPlaying) + return new ErrorResponse("Reflection probe baking requires Edit mode."); + + var p = new ToolParams(@params); + string target = p.Get("target"); + if (string.IsNullOrEmpty(target)) + return new ErrorResponse("'target' parameter is required (name or instanceID of a GameObject with ReflectionProbe)."); + + var go = FindGameObject(target); + if (go == null) + return new ErrorResponse($"GameObject '{target}' not found."); + + var probe = go.GetComponent(); + if (probe == null) + return new ErrorResponse($"GameObject '{go.name}' does not have a ReflectionProbe component."); + + string dir = "Assets/Lightmaps"; + if (!AssetDatabase.IsValidFolder(dir)) + AssetDatabase.CreateFolder("Assets", "Lightmaps"); + + string outputPath = $"{dir}/{probe.name}_ReflectionProbe.exr"; + + bool result = Lightmapping.BakeReflectionProbe(probe, outputPath); + if (!result) + return new ErrorResponse($"Failed to bake reflection probe '{probe.name}'."); + + return new + { + success = true, + message = $"Baked reflection probe '{probe.name}' to '{outputPath}'.", + data = new + { + probeName = probe.name, + outputPath, + instanceID = go.GetInstanceID() + } + }; + } + + // === bake_get_settings === + internal static object GetSettings(JObject @params) + { + var settings = EnsureLightingSettings(); + if (settings == null) + return new ErrorResponse( + "Failed to create LightingSettings. Open Window > Rendering > Lighting manually."); + + var data = new Dictionary + { + ["name"] = settings.name, + ["path"] = AssetDatabase.GetAssetPath(settings), + ["bakedGI"] = settings.bakedGI, + ["realtimeGI"] = settings.realtimeGI, + ["lightmapper"] = settings.lightmapper.ToString(), + ["lightmapResolution"] = settings.lightmapResolution, + ["lightmapMaxSize"] = settings.lightmapMaxSize, + ["directSampleCount"] = settings.directSampleCount, + ["indirectSampleCount"] = settings.indirectSampleCount, + ["environmentSampleCount"] = settings.environmentSampleCount, + ["mixedBakeMode"] = settings.mixedBakeMode.ToString(), + ["lightmapCompression"] = settings.lightmapCompression.ToString(), + ["ao"] = settings.ao, + ["aoMaxDistance"] = settings.aoMaxDistance + }; + + // bounceCount vs maxBounces — name varies by Unity version + ReadBounceCount(settings, data); + + return new + { + success = true, + message = $"Lighting settings: {settings.lightmapper}, resolution {settings.lightmapResolution}.", + data + }; + } + + // === bake_set_settings === + // Params: settings (dict of property name -> value) + internal static object SetSettings(JObject @params) + { + var p = new ToolParams(@params); + var settingsToken = p.GetRaw("settings") as JObject; + if (settingsToken == null || !settingsToken.HasValues) + return new ErrorResponse("'settings' parameter is required (dict of property name to value)."); + + var lightingSettings = EnsureLightingSettings(); + if (lightingSettings == null) + return new ErrorResponse( + "Failed to create LightingSettings. Open Window > Rendering > Lighting manually."); + + Undo.RecordObject(lightingSettings, "Modify Lighting Settings"); + + var changed = new List(); + var failed = new List(); + + foreach (var prop in settingsToken.Properties()) + { + string name = prop.Name; + JToken value = prop.Value; + + try + { + if (TrySetLightingSetting(lightingSettings, name, value)) + changed.Add(name); + else + failed.Add(name); + } + catch (Exception ex) + { + McpLog.Warn($"[LightBakingOps] Failed to set '{name}': {ex.Message}"); + failed.Add(name); + } + } + + if (changed.Count == 0 && failed.Count > 0) + return new ErrorResponse($"Failed to set any settings. Invalid properties: {string.Join(", ", failed)}"); + + EditorUtility.SetDirty(lightingSettings); + + var msg = $"Updated {changed.Count} lighting setting(s)"; + if (failed.Count > 0) + msg += $". Failed: {string.Join(", ", failed)}"; + + return new + { + success = true, + message = msg, + data = new { changed, failed } + }; + } + + // === bake_create_light_probe_group === + // Params: name, position, grid_size, spacing + internal static object CreateLightProbeGroup(JObject @params) + { + var p = new ToolParams(@params); + string name = p.Get("name") ?? "Light Probes"; + float spacing = p.GetFloat("spacing") ?? 2.0f; + + var posToken = p.GetRaw("position") as JArray; + Vector3 position = posToken != null && posToken.Count >= 3 + ? new Vector3(posToken[0].Value(), posToken[1].Value(), posToken[2].Value()) + : Vector3.zero; + + var gridToken = p.GetRaw("grid_size") as JArray; + int gridX = gridToken != null && gridToken.Count >= 1 ? gridToken[0].Value() : 3; + int gridY = gridToken != null && gridToken.Count >= 2 ? gridToken[1].Value() : 2; + int gridZ = gridToken != null && gridToken.Count >= 3 ? gridToken[2].Value() : 3; + + var go = new GameObject(name); + go.transform.position = position; + Undo.RegisterCreatedObjectUndo(go, $"Create Light Probe Group '{name}'"); + + var probeGroup = go.AddComponent(); + + var positions = new List(); + float halfX = (gridX - 1) * spacing * 0.5f; + float halfY = (gridY - 1) * spacing * 0.5f; + float halfZ = (gridZ - 1) * spacing * 0.5f; + + for (int x = 0; x < gridX; x++) + { + for (int y = 0; y < gridY; y++) + { + for (int z = 0; z < gridZ; z++) + { + positions.Add(new Vector3( + x * spacing - halfX, + y * spacing - halfY, + z * spacing - halfZ + )); + } + } + } + + probeGroup.probePositions = positions.ToArray(); + GraphicsHelpers.MarkDirty(probeGroup); + + return new + { + success = true, + message = $"Created Light Probe Group '{name}' with {positions.Count} probes ({gridX}x{gridY}x{gridZ} grid, spacing {spacing}).", + data = new + { + instanceID = go.GetInstanceID(), + probeCount = positions.Count, + gridSize = new[] { gridX, gridY, gridZ }, + spacing, + position = new[] { position.x, position.y, position.z } + } + }; + } + + // === bake_create_reflection_probe === + // Params: name, position, size, resolution, mode, hdr, box_projection + internal static object CreateReflectionProbe(JObject @params) + { + var p = new ToolParams(@params); + string name = p.Get("name") ?? "Reflection Probe"; + int resolution = p.GetInt("resolution") ?? 256; + bool hdr = p.GetBool("hdr", true); + bool boxProjection = p.GetBool("box_projection", false); + string modeStr = p.Get("mode") ?? "Baked"; + + var posToken = p.GetRaw("position") as JArray; + Vector3 position = posToken != null && posToken.Count >= 3 + ? new Vector3(posToken[0].Value(), posToken[1].Value(), posToken[2].Value()) + : Vector3.zero; + + var sizeToken = p.GetRaw("size") as JArray; + Vector3 size = sizeToken != null && sizeToken.Count >= 3 + ? new Vector3(sizeToken[0].Value(), sizeToken[1].Value(), sizeToken[2].Value()) + : new Vector3(10f, 10f, 10f); + + if (!Enum.TryParse(modeStr, true, out var mode)) + return new ErrorResponse( + $"Invalid mode '{modeStr}'. Valid values: Baked, Realtime, Custom."); + + var go = new GameObject(name); + go.transform.position = position; + Undo.RegisterCreatedObjectUndo(go, $"Create Reflection Probe '{name}'"); + + var probe = go.AddComponent(); + probe.size = size; + probe.resolution = resolution; + probe.mode = mode; + probe.hdr = hdr; + probe.boxProjection = boxProjection; + + GraphicsHelpers.MarkDirty(probe); + + return new + { + success = true, + message = $"Created Reflection Probe '{name}' (mode: {mode}, resolution: {resolution}, HDR: {hdr}).", + data = new + { + instanceID = go.GetInstanceID(), + mode = mode.ToString(), + resolution, + hdr, + boxProjection, + size = new[] { size.x, size.y, size.z }, + position = new[] { position.x, position.y, position.z } + } + }; + } + + // === bake_set_probe_positions === + // Params: target (name/instanceID), positions (array of [x,y,z]) + internal static object SetProbePositions(JObject @params) + { + var p = new ToolParams(@params); + string target = p.Get("target"); + if (string.IsNullOrEmpty(target)) + return new ErrorResponse("'target' parameter is required (name or instanceID of a GameObject with LightProbeGroup)."); + + var go = FindGameObject(target); + if (go == null) + return new ErrorResponse($"GameObject '{target}' not found."); + + var probeGroup = go.GetComponent(); + if (probeGroup == null) + return new ErrorResponse($"GameObject '{go.name}' does not have a LightProbeGroup component."); + + var positionsToken = p.GetRaw("positions") as JArray; + if (positionsToken == null || positionsToken.Count == 0) + return new ErrorResponse("'positions' parameter is required (array of [x,y,z] arrays)."); + + Undo.RecordObject(probeGroup, "Set Light Probe Positions"); + + var positions = new Vector3[positionsToken.Count]; + for (int i = 0; i < positionsToken.Count; i++) + { + var arr = positionsToken[i] as JArray; + if (arr == null || arr.Count < 3) + return new ErrorResponse($"Position at index {i} must be an array of [x, y, z]."); + positions[i] = new Vector3( + arr[0].Value(), + arr[1].Value(), + arr[2].Value() + ); + } + + probeGroup.probePositions = positions; + GraphicsHelpers.MarkDirty(probeGroup); + + return new + { + success = true, + message = $"Set {positions.Length} probe positions on '{go.name}'.", + data = new + { + instanceID = go.GetInstanceID(), + probeCount = positions.Length + } + }; + } + + // --- Helper: Ensure a LightingSettings asset exists --- + private static LightingSettings EnsureLightingSettings() + { + try + { + var settings = Lightmapping.lightingSettings; + if (settings != null) return settings; + } + catch { /* getter throws when no asset exists */ } + + try + { + var settings = new LightingSettings { name = "LightingSettings" }; + Lightmapping.lightingSettings = settings; + return Lightmapping.lightingSettings; + } + catch { return null; } + } + + // --- Helper: Find a GameObject by name or instanceID --- + private static GameObject FindGameObject(string target) + { + if (string.IsNullOrEmpty(target)) + return null; + + if (int.TryParse(target, out int instanceId)) + { + var byId = GameObjectLookup.ResolveInstanceID(instanceId) as GameObject; + if (byId != null) return byId; + } + + return GameObject.Find(target); + } + + // --- Helper: Read bounceCount with version fallback --- + private static void ReadBounceCount(LightingSettings settings, Dictionary data) + { + var type = typeof(LightingSettings); + + // Try bounceCount first (Unity 2022+) + var prop = type.GetProperty("bounceCount", BindingFlags.Public | BindingFlags.Instance); + if (prop != null) + { + data["bounceCount"] = prop.GetValue(settings); + return; + } + + // Fallback to maxBounces (older Unity versions) + prop = type.GetProperty("maxBounces", BindingFlags.Public | BindingFlags.Instance); + if (prop != null) + data["maxBounces"] = prop.GetValue(settings); + } + + // --- Helper: Set a single lighting setting by name --- + private static bool TrySetLightingSetting(LightingSettings settings, string name, JToken value) + { + switch (name.ToLowerInvariant()) + { + case "bakedgi": + case "baked_gi": + settings.bakedGI = ParamCoercion.CoerceBool(value, settings.bakedGI); + return true; + + case "realtimegi": + case "realtime_gi": + settings.realtimeGI = ParamCoercion.CoerceBool(value, settings.realtimeGI); + return true; + + case "lightmapper": + if (TryParseEnum(value, out var lm)) + { + settings.lightmapper = lm; + return true; + } + return false; + + case "lightmapresolution": + case "lightmap_resolution": + settings.lightmapResolution = ParamCoercion.CoerceFloat(value, settings.lightmapResolution); + return true; + + case "lightmapmaxsize": + case "lightmap_max_size": + settings.lightmapMaxSize = ParamCoercion.CoerceInt(value, settings.lightmapMaxSize); + return true; + + case "directsamplecount": + case "direct_sample_count": + settings.directSampleCount = ParamCoercion.CoerceInt(value, settings.directSampleCount); + return true; + + case "indirectsamplecount": + case "indirect_sample_count": + settings.indirectSampleCount = ParamCoercion.CoerceInt(value, settings.indirectSampleCount); + return true; + + case "environmentsamplecount": + case "environment_sample_count": + settings.environmentSampleCount = ParamCoercion.CoerceInt(value, settings.environmentSampleCount); + return true; + + case "bouncecount": + case "bounce_count": + case "maxbounces": + case "max_bounces": + return TrySetBounceCount(settings, ParamCoercion.CoerceInt(value, 2)); + + case "mixedbakemode": + case "mixed_bake_mode": + if (TryParseEnum(value, out var mlm)) + { + settings.mixedBakeMode = mlm; + return true; + } + return false; + + case "compresslightmaps": + case "compress_lightmaps": + case "lightmapcompression": + case "lightmap_compression": + var strVal = value?.ToString() ?? ""; + if (System.Enum.TryParse(strVal, true, out var compression)) + settings.lightmapCompression = compression; + else if (bool.TryParse(strVal, out var boolVal)) + settings.lightmapCompression = boolVal + ? LightmapCompression.NormalQuality : LightmapCompression.None; + else if (int.TryParse(strVal, out var intVal)) + settings.lightmapCompression = (LightmapCompression)intVal; + else + return false; + return true; + + case "ao": + settings.ao = ParamCoercion.CoerceBool(value, settings.ao); + return true; + + case "aomaxdistance": + case "ao_max_distance": + settings.aoMaxDistance = ParamCoercion.CoerceFloat(value, settings.aoMaxDistance); + return true; + + default: + return false; + } + } + + // --- Helper: Set bounceCount with version fallback --- + private static bool TrySetBounceCount(LightingSettings settings, int value) + { + var type = typeof(LightingSettings); + + // Try bounceCount first (Unity 2022+) + var prop = type.GetProperty("bounceCount", BindingFlags.Public | BindingFlags.Instance); + if (prop != null && prop.CanWrite) + { + prop.SetValue(settings, value); + return true; + } + + // Fallback to maxBounces (older Unity versions) + prop = type.GetProperty("maxBounces", BindingFlags.Public | BindingFlags.Instance); + if (prop != null && prop.CanWrite) + { + prop.SetValue(settings, value); + return true; + } + + return false; + } + + // --- Helper: Parse enum from JToken (string name or int value) --- + private static bool TryParseEnum(JToken value, out T result) where T : struct, Enum + { + result = default; + if (value == null || value.Type == JTokenType.Null) return false; + + string str = value.ToString(); + + // Try parse by name + if (Enum.TryParse(str, true, out result)) + return true; + + // Try parse by int value + if (int.TryParse(str, out int intVal)) + { + result = (T)Enum.ToObject(typeof(T), intVal); + return true; + } + + return false; + } + } +} diff --git a/MCPForUnity/Editor/Tools/Graphics/LightBakingOps.cs.meta b/MCPForUnity/Editor/Tools/Graphics/LightBakingOps.cs.meta new file mode 100644 index 000000000..7b12767be --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/LightBakingOps.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4bf5a93fc3954f3e880e60794fa968de +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Graphics/ManageGraphics.cs b/MCPForUnity/Editor/Tools/Graphics/ManageGraphics.cs new file mode 100644 index 000000000..6fe33e993 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/ManageGraphics.cs @@ -0,0 +1,175 @@ +using System; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools.Graphics +{ + [McpForUnityTool("manage_graphics", AutoRegister = false, Group = "core")] + public static class ManageGraphics + { + public static object HandleCommand(JObject @params) + { + if (@params == null) + return new ErrorResponse("Parameters cannot be null."); + + var p = new ToolParams(@params); + string action = p.Get("action")?.ToLowerInvariant(); + + if (string.IsNullOrEmpty(action)) + return new ErrorResponse("'action' parameter is required."); + + try + { + switch (action) + { + // --- Health check --- + case "ping": + var pipeName = GraphicsHelpers.GetPipelineName(); + return new + { + success = true, + message = $"Graphics tool ready. Pipeline: {pipeName}", + data = new + { + pipeline = RenderPipelineUtility.GetActivePipeline().ToString(), + pipelineName = pipeName, + hasVolumeSystem = GraphicsHelpers.HasVolumeSystem, + hasURP = GraphicsHelpers.HasURP, + hasHDRP = GraphicsHelpers.HasHDRP, + availableEffects = GraphicsHelpers.HasVolumeSystem + ? GraphicsHelpers.GetAvailableEffectTypes().Count : 0 + } + }; + + // --- Volume actions (require Volume system = URP or HDRP) --- + case "volume_create": + case "volume_add_effect": + case "volume_set_effect": + case "volume_remove_effect": + case "volume_get_info": + case "volume_set_properties": + case "volume_list_effects": + case "volume_create_profile": + { + if (!GraphicsHelpers.HasVolumeSystem) + return new ErrorResponse( + "Volume system not available. Requires URP or HDRP (com.unity.render-pipelines.core)."); + + return action switch + { + "volume_create" => VolumeOps.CreateVolume(@params), + "volume_add_effect" => VolumeOps.AddEffect(@params), + "volume_set_effect" => VolumeOps.SetEffect(@params), + "volume_remove_effect" => VolumeOps.RemoveEffect(@params), + "volume_get_info" => VolumeOps.GetInfo(@params), + "volume_set_properties" => VolumeOps.SetProperties(@params), + "volume_list_effects" => VolumeOps.ListEffects(@params), + "volume_create_profile" => VolumeOps.CreateProfile(@params), + _ => new ErrorResponse($"Unknown volume action: '{action}'") + }; + } + + // --- Bake actions (always available, Edit mode only) --- + case "bake_start": + return LightBakingOps.StartBake(@params); + case "bake_cancel": + return LightBakingOps.CancelBake(@params); + case "bake_status": + return LightBakingOps.GetStatus(@params); + case "bake_clear": + return LightBakingOps.ClearBake(@params); + case "bake_reflection_probe": + return LightBakingOps.BakeReflectionProbe(@params); + case "bake_get_settings": + return LightBakingOps.GetSettings(@params); + case "bake_set_settings": + return LightBakingOps.SetSettings(@params); + case "bake_create_light_probe_group": + return LightBakingOps.CreateLightProbeGroup(@params); + case "bake_create_reflection_probe": + return LightBakingOps.CreateReflectionProbe(@params); + case "bake_set_probe_positions": + return LightBakingOps.SetProbePositions(@params); + + // --- Stats actions (always available) --- + case "stats_get": + return RenderingStatsOps.GetStats(@params); + case "stats_list_counters": + return RenderingStatsOps.ListCounters(@params); + case "stats_set_scene_debug": + return RenderingStatsOps.SetSceneDebugMode(@params); + case "stats_get_memory": + return RenderingStatsOps.GetMemory(@params); + + // --- Pipeline actions (always available) --- + case "pipeline_get_info": + return RenderPipelineOps.GetInfo(@params); + case "pipeline_set_quality": + return RenderPipelineOps.SetQuality(@params); + case "pipeline_get_settings": + return RenderPipelineOps.GetSettings(@params); + case "pipeline_set_settings": + return RenderPipelineOps.SetSettings(@params); + + // --- Renderer feature actions (URP only) --- + case "feature_list": + case "feature_add": + case "feature_remove": + case "feature_configure": + case "feature_toggle": + case "feature_reorder": + { + if (!GraphicsHelpers.HasURP) + return new ErrorResponse("Renderer features require URP (Universal Render Pipeline)."); + + return action switch + { + "feature_list" => RendererFeatureOps.ListFeatures(@params), + "feature_add" => RendererFeatureOps.AddFeature(@params), + "feature_remove" => RendererFeatureOps.RemoveFeature(@params), + "feature_configure" => RendererFeatureOps.ConfigureFeature(@params), + "feature_toggle" => RendererFeatureOps.ToggleFeature(@params), + "feature_reorder" => RendererFeatureOps.ReorderFeatures(@params), + _ => new ErrorResponse($"Unknown feature action: '{action}'") + }; + } + + // --- Skybox / Environment actions (always available) --- + case "skybox_get": + return SkyboxOps.GetEnvironment(@params); + case "skybox_set_material": + return SkyboxOps.SetMaterial(@params); + case "skybox_set_properties": + return SkyboxOps.SetMaterialProperties(@params); + case "skybox_set_ambient": + return SkyboxOps.SetAmbient(@params); + case "skybox_set_fog": + return SkyboxOps.SetFog(@params); + case "skybox_set_reflection": + return SkyboxOps.SetReflection(@params); + case "skybox_set_sun": + return SkyboxOps.SetSun(@params); + + default: + return new ErrorResponse( + $"Unknown action: '{action}'. Valid actions: ping, " + + "volume_create, volume_add_effect, volume_set_effect, volume_remove_effect, " + + "volume_get_info, volume_set_properties, volume_list_effects, volume_create_profile, " + + "bake_start, bake_cancel, bake_status, bake_clear, bake_reflection_probe, " + + "bake_get_settings, bake_set_settings, bake_create_light_probe_group, " + + "bake_create_reflection_probe, bake_set_probe_positions, " + + "stats_get, stats_list_counters, stats_set_scene_debug, stats_get_memory, " + + "pipeline_get_info, pipeline_set_quality, pipeline_get_settings, pipeline_set_settings, " + + "feature_list, feature_add, feature_remove, feature_configure, feature_toggle, feature_reorder, " + + "skybox_get, skybox_set_material, skybox_set_properties, skybox_set_ambient, " + + "skybox_set_fog, skybox_set_reflection, skybox_set_sun."); + } + } + catch (Exception ex) + { + McpLog.Error($"[ManageGraphics] Action '{action}' failed: {ex}"); + return new ErrorResponse($"Error in action '{action}': {ex.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/Graphics/ManageGraphics.cs.meta b/MCPForUnity/Editor/Tools/Graphics/ManageGraphics.cs.meta new file mode 100644 index 000000000..9b27d6ad5 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/ManageGraphics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dafb0cedb22e465b8f7b19e68a636415 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Graphics/RenderPipelineOps.cs b/MCPForUnity/Editor/Tools/Graphics/RenderPipelineOps.cs new file mode 100644 index 000000000..46b4f6ffe --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/RenderPipelineOps.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.Rendering; + +namespace MCPForUnity.Editor.Tools.Graphics +{ + internal static class RenderPipelineOps + { + // === pipeline_get_info === + // Returns: active pipeline, quality level, renderer info, key settings + internal static object GetInfo(JObject @params) + { + var pipeline = RenderPipelineUtility.GetActivePipeline(); + var pipelineAsset = GraphicsSettings.currentRenderPipeline; + + // Quality level info + int currentQuality = QualitySettings.GetQualityLevel(); + string[] qualityNames = QualitySettings.names; + + var data = new Dictionary + { + ["pipeline"] = pipeline.ToString(), + ["pipelineName"] = GraphicsHelpers.GetPipelineName(), + ["qualityLevel"] = currentQuality, + ["qualityLevelName"] = currentQuality < qualityNames.Length ? qualityNames[currentQuality] : "Unknown", + ["qualityLevels"] = qualityNames, + ["colorSpace"] = QualitySettings.activeColorSpace.ToString(), + ["hasVolumeSystem"] = GraphicsHelpers.HasVolumeSystem, + }; + + // If SRP, add pipeline asset info + if (pipelineAsset != null) + { + data["pipelineAsset"] = pipelineAsset.name; + data["pipelineAssetPath"] = AssetDatabase.GetAssetPath(pipelineAsset); + data["pipelineAssetType"] = pipelineAsset.GetType().Name; + + // Read common public properties via reflection + var settings = new Dictionary(); + TryReadProperty(pipelineAsset, "renderScale", settings); + TryReadProperty(pipelineAsset, "supportsHDR", settings); + TryReadProperty(pipelineAsset, "msaaSampleCount", settings); + TryReadProperty(pipelineAsset, "shadowDistance", settings); + TryReadProperty(pipelineAsset, "shadowCascadeCount", settings); + TryReadProperty(pipelineAsset, "maxAdditionalLightsCount", settings); + TryReadProperty(pipelineAsset, "supportsSoftShadows", settings); + TryReadProperty(pipelineAsset, "colorGradingMode", settings); + + if (settings.Count > 0) + data["settings"] = settings; + } + + return new + { + success = true, + message = $"Pipeline: {GraphicsHelpers.GetPipelineName()}, Quality: {(currentQuality < qualityNames.Length ? qualityNames[currentQuality] : "?")}", + data + }; + } + + // === pipeline_set_quality === + // Params: level (int or string name) + internal static object SetQuality(JObject @params) + { + var p = new ToolParams(@params); + string levelName = p.Get("level"); + int? levelIndex = p.GetInt("level"); + + string[] names = QualitySettings.names; + int targetIndex = -1; + + if (levelIndex.HasValue) + { + targetIndex = levelIndex.Value; + } + else if (!string.IsNullOrEmpty(levelName)) + { + // Try exact match first + for (int i = 0; i < names.Length; i++) + { + if (string.Equals(names[i], levelName, StringComparison.OrdinalIgnoreCase)) + { + targetIndex = i; + break; + } + } + // Try parse as int + if (targetIndex < 0 && int.TryParse(levelName, out int parsed)) + targetIndex = parsed; + } + else + { + return new ErrorResponse($"'level' parameter required. Available: {string.Join(", ", names)}"); + } + + if (targetIndex < 0 || targetIndex >= names.Length) + return new ErrorResponse( + $"Invalid quality level. Available: {string.Join(", ", names)} (0-{names.Length - 1})"); + + QualitySettings.SetQualityLevel(targetIndex, true); + + return new + { + success = true, + message = $"Quality level set to '{names[targetIndex]}' (index {targetIndex}).", + data = new + { + level = targetIndex, + name = names[targetIndex], + allLevels = names + } + }; + } + + // === pipeline_get_settings === + // Detailed read of pipeline asset settings via public properties + SerializedObject fallback + internal static object GetSettings(JObject @params) + { + var pipelineAsset = GraphicsSettings.currentRenderPipeline; + if (pipelineAsset == null) + return new ErrorResponse("No render pipeline asset found (Built-in pipeline has no asset)."); + + var settings = new Dictionary(); + + // Public properties (URP) + string[] publicProps = { + "renderScale", "supportsHDR", "msaaSampleCount", "shadowDistance", + "shadowCascadeCount", "mainLightShadowmapResolution", + "additionalLightsShadowmapResolution", "maxAdditionalLightsCount", + "supportsSoftShadows", "colorGradingMode", "colorGradingLutSize" + }; + foreach (var propName in publicProps) + TryReadProperty(pipelineAsset, propName, settings); + + // SerializedObject for non-public settings + var serializedSettings = new Dictionary(); + string[] serializedPaths = { + "m_DefaultRendererIndex", "m_MainLightRenderingMode", + "m_AdditionalLightsRenderingMode", "m_SupportsOpaqueTexture", + "m_SupportsDepthTexture" + }; + + using (var so = new SerializedObject(pipelineAsset)) + { + foreach (var path in serializedPaths) + { + var prop = so.FindProperty(path); + if (prop != null) + serializedSettings[path] = GraphicsHelpers.ReadSerializedValue(prop); + } + } + + if (serializedSettings.Count > 0) + settings["_serialized"] = serializedSettings; + + return new + { + success = true, + message = $"Pipeline settings for '{pipelineAsset.name}'.", + data = new + { + assetName = pipelineAsset.name, + assetPath = AssetDatabase.GetAssetPath(pipelineAsset), + assetType = pipelineAsset.GetType().Name, + settings + } + }; + } + + // === pipeline_set_settings === + // Write pipeline asset settings via public properties + SerializedObject fallback + internal static object SetSettings(JObject @params) + { + var p = new ToolParams(@params); + var settingsToken = p.GetRaw("settings") as JObject; + if (settingsToken == null) + return new ErrorResponse("'settings' dict is required."); + + var pipelineAsset = GraphicsSettings.currentRenderPipeline; + if (pipelineAsset == null) + return new ErrorResponse("No render pipeline asset found."); + + var changed = new List(); + var failed = new List(); + + using (var so = new SerializedObject(pipelineAsset)) + { + foreach (var prop in settingsToken.Properties()) + { + string propName = prop.Name; + JToken value = prop.Value; + + // Try public property first + var publicProp = pipelineAsset.GetType().GetProperty(propName, + BindingFlags.Public | BindingFlags.Instance); + if (publicProp != null && publicProp.CanWrite) + { + try + { + object converted = ConvertPropertyValue(value, publicProp.PropertyType); + publicProp.SetValue(pipelineAsset, converted); + changed.Add(propName); + continue; + } + catch (Exception ex) + { + McpLog.Warn($"[RenderPipelineOps] Failed to set '{propName}' via property: {ex.Message}"); + } + } + + // Try SerializedObject fallback (for m_ prefixed properties) + string serializedPath = propName.StartsWith("m_") ? propName : $"m_{char.ToUpper(propName[0])}{propName.Substring(1)}"; + var sProp = so.FindProperty(serializedPath); + if (sProp == null && !propName.StartsWith("m_")) + sProp = so.FindProperty(propName); + + if (sProp != null) + { + if (GraphicsHelpers.SetSerializedValue(sProp, value)) + { + changed.Add(propName); + continue; + } + } + + failed.Add(propName); + } + so.ApplyModifiedProperties(); + } + + EditorUtility.SetDirty(pipelineAsset); + AssetDatabase.SaveAssets(); + + var msg = $"Updated {changed.Count} pipeline setting(s)"; + if (failed.Count > 0) + msg += $". Failed: {string.Join(", ", failed)}"; + + return new + { + success = true, + message = msg, + data = new { changed, failed } + }; + } + + // --- Helper: Convert JToken to target property type --- + private static object ConvertPropertyValue(JToken value, Type targetType) + { + if (targetType == typeof(bool)) return ParamCoercion.CoerceBool(value, false); + if (targetType == typeof(int)) return ParamCoercion.CoerceInt(value, 0); + if (targetType == typeof(float)) return ParamCoercion.CoerceFloat(value, 0f); + if (targetType == typeof(string)) return value.ToString(); + if (targetType.IsEnum) + { + string str = value.ToString(); + if (Enum.TryParse(targetType, str, true, out object enumVal)) + return enumVal; + if (int.TryParse(str, out int intVal)) + return Enum.ToObject(targetType, intVal); + } + return Convert.ChangeType(value.ToObject(), targetType); + } + + // --- Helper: Try to read a property value via reflection --- + private static void TryReadProperty(object obj, string propertyName, Dictionary target) + { + if (obj == null) return; + var prop = obj.GetType().GetProperty(propertyName, + BindingFlags.Public | BindingFlags.Instance); + if (prop != null) + { + try + { + var val = prop.GetValue(obj); + target[propertyName] = val is Enum e ? e.ToString() : val; + } + catch { /* skip unreadable properties */ } + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/Graphics/RenderPipelineOps.cs.meta b/MCPForUnity/Editor/Tools/Graphics/RenderPipelineOps.cs.meta new file mode 100644 index 000000000..996ce260c --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/RenderPipelineOps.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2e637fccb0c440e4b8cdb3bc6040c7c9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Graphics/RendererFeatureOps.cs b/MCPForUnity/Editor/Tools/Graphics/RendererFeatureOps.cs new file mode 100644 index 000000000..5da66af18 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/RendererFeatureOps.cs @@ -0,0 +1,576 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.Rendering; + +namespace MCPForUnity.Editor.Tools.Graphics +{ + internal static class RendererFeatureOps + { + // Cached URP types (resolved via reflection to avoid hard dependency) + private static Type _scriptableRendererDataType; + private static Type _scriptableRendererFeatureType; + private static Type _universalRenderPipelineAssetType; + private static bool _typesResolved; + + private static void EnsureTypes() + { + if (_typesResolved) return; + _typesResolved = true; + + _scriptableRendererDataType = Type.GetType( + "UnityEngine.Rendering.Universal.ScriptableRendererData, Unity.RenderPipelines.Universal.Runtime"); + _scriptableRendererFeatureType = Type.GetType( + "UnityEngine.Rendering.Universal.ScriptableRendererFeature, Unity.RenderPipelines.Universal.Runtime"); + _universalRenderPipelineAssetType = Type.GetType( + "UnityEngine.Rendering.Universal.UniversalRenderPipelineAsset, Unity.RenderPipelines.Universal.Runtime"); + } + + // === feature_list === + internal static object ListFeatures(JObject @params) + { + var rendererData = GetRendererData(@params); + if (rendererData == null) + return new ErrorResponse("Could not find URP ScriptableRendererData. Ensure URP is active."); + + var featuresProp = rendererData.GetType().GetProperty("rendererFeatures", + BindingFlags.Public | BindingFlags.Instance); + if (featuresProp == null) + return new ErrorResponse("rendererFeatures property not found on renderer data."); + + var featuresList = featuresProp.GetValue(rendererData) as System.Collections.IList; + if (featuresList == null) + return new { success = true, message = "No renderer features.", data = new { features = new object[0] } }; + + var features = new List(); + for (int i = 0; i < featuresList.Count; i++) + { + var feature = featuresList[i] as ScriptableObject; + if (feature == null) continue; + + var isActiveProp = feature.GetType().GetProperty("isActive", + BindingFlags.Public | BindingFlags.Instance); + + features.Add(new + { + index = i, + name = feature.name, + type = feature.GetType().Name, + isActive = isActiveProp != null ? (bool)isActiveProp.GetValue(feature) : true, + properties = GetFeatureProperties(feature) + }); + } + + return new + { + success = true, + message = $"Found {features.Count} renderer feature(s).", + data = new + { + rendererDataName = (rendererData as ScriptableObject)?.name, + features + } + }; + } + + // === feature_add === + internal static object AddFeature(JObject @params) + { + var p = new ToolParams(@params); + string typeName = p.Get("type"); + if (string.IsNullOrEmpty(typeName)) + return new ErrorResponse("'type' parameter required (e.g., 'FullScreenPassRendererFeature', 'RenderObjects')."); + + var rendererData = GetRendererData(@params); + if (rendererData == null) + return new ErrorResponse("Could not find URP ScriptableRendererData."); + + EnsureTypes(); + if (_scriptableRendererFeatureType == null) + return new ErrorResponse("ScriptableRendererFeature type not found. Is URP installed?"); + + // Resolve the feature type + var featureType = ResolveFeatureType(typeName); + if (featureType == null) + { + var available = GetAvailableFeatureTypes(); + return new ErrorResponse( + $"Feature type '{typeName}' not found. Available: {string.Join(", ", available.Select(t => t.Name))}"); + } + + // Create the feature instance + var feature = ScriptableObject.CreateInstance(featureType); + if (feature == null) + return new ErrorResponse($"Failed to create instance of '{featureType.Name}'."); + + string displayName = p.Get("name") ?? featureType.Name; + feature.name = displayName; + + // Add to the renderer data asset + Undo.RecordObject(rendererData as UnityEngine.Object, "Add Renderer Feature"); + AssetDatabase.AddObjectToAsset(feature, rendererData as UnityEngine.Object); + + // Add to the features list via SerializedObject + using (var so = new SerializedObject(rendererData as UnityEngine.Object)) + { + var rendererFeaturesProp = so.FindProperty("m_RendererFeatures"); + if (rendererFeaturesProp != null) + { + rendererFeaturesProp.arraySize++; + var element = rendererFeaturesProp.GetArrayElementAtIndex(rendererFeaturesProp.arraySize - 1); + element.objectReferenceValue = feature; + so.ApplyModifiedProperties(); + } + + // Also update the map (m_RendererFeatureMap) if it exists + // Map stores persistent local file IDs, not transient instance IDs + var mapProp = so.FindProperty("m_RendererFeatureMap"); + if (mapProp != null) + { + long localId = 0; + AssetDatabase.TryGetGUIDAndLocalFileIdentifier(feature, out _, out localId); + mapProp.arraySize++; + var mapElement = mapProp.GetArrayElementAtIndex(mapProp.arraySize - 1); + mapElement.longValue = localId; + so.ApplyModifiedProperties(); + } + } + + // Configure initial properties if provided + var propertiesToken = p.GetRaw("properties") as JObject; + if (propertiesToken != null) + ApplyFeatureProperties(feature, propertiesToken); + + // Set material if provided (common for FullScreenPass) + string materialPath = p.Get("material"); + if (!string.IsNullOrEmpty(materialPath)) + TrySetMaterial(feature, materialPath); + + EditorUtility.SetDirty(rendererData as UnityEngine.Object); + AssetDatabase.SaveAssets(); + + return new + { + success = true, + message = $"Added renderer feature '{displayName}' ({featureType.Name}).", + data = new + { + name = displayName, + type = featureType.Name, + instanceId = feature.GetInstanceID() + } + }; + } + + // === feature_remove === + internal static object RemoveFeature(JObject @params) + { + var p = new ToolParams(@params); + int? index = p.GetInt("index"); + string name = p.Get("name"); + + var rendererData = GetRendererData(@params); + if (rendererData == null) + return new ErrorResponse("Could not find URP ScriptableRendererData."); + + var featuresProp = rendererData.GetType().GetProperty("rendererFeatures", + BindingFlags.Public | BindingFlags.Instance); + if (featuresProp == null) + return new ErrorResponse("rendererFeatures property not found."); + + var featuresList = featuresProp.GetValue(rendererData) as System.Collections.IList; + if (featuresList == null || featuresList.Count == 0) + return new ErrorResponse("No renderer features to remove."); + + int targetIndex = ResolveFeatureIndex(featuresList, index, name); + if (targetIndex < 0) + return new ErrorResponse($"Feature not found. Specify 'index' (0-{featuresList.Count - 1}) or 'name'."); + + var feature = featuresList[targetIndex] as ScriptableObject; + string featureName = feature?.name ?? "Unknown"; + + Undo.RecordObject(rendererData as UnityEngine.Object, "Remove Renderer Feature"); + + // Remove from the list via SerializedObject + using (var so = new SerializedObject(rendererData as UnityEngine.Object)) + { + var rendererFeaturesPropSo = so.FindProperty("m_RendererFeatures"); + if (rendererFeaturesPropSo != null) + { + rendererFeaturesPropSo.DeleteArrayElementAtIndex(targetIndex); + // SerializedProperty.DeleteArrayElementAtIndex sets to null first for ObjectReference + if (rendererFeaturesPropSo.arraySize > targetIndex) + { + var element = rendererFeaturesPropSo.GetArrayElementAtIndex(targetIndex); + if (element.objectReferenceValue == null) + rendererFeaturesPropSo.DeleteArrayElementAtIndex(targetIndex); + } + so.ApplyModifiedProperties(); + } + + // Clean up the map + var mapProp = so.FindProperty("m_RendererFeatureMap"); + if (mapProp != null && targetIndex < mapProp.arraySize) + { + mapProp.DeleteArrayElementAtIndex(targetIndex); + so.ApplyModifiedProperties(); + } + } + + // Remove the sub-asset + if (feature != null) + AssetDatabase.RemoveObjectFromAsset(feature); + + EditorUtility.SetDirty(rendererData as UnityEngine.Object); + AssetDatabase.SaveAssets(); + + return new + { + success = true, + message = $"Removed renderer feature '{featureName}' at index {targetIndex}." + }; + } + + // === feature_configure === + internal static object ConfigureFeature(JObject @params) + { + var p = new ToolParams(@params); + int? index = p.GetInt("index"); + string name = p.Get("name"); + var propertiesToken = (p.GetRaw("properties") ?? p.GetRaw("settings")) as JObject; + + if (propertiesToken == null) + return new ErrorResponse("'properties' (or 'settings') dict is required."); + + var rendererData = GetRendererData(@params); + if (rendererData == null) + return new ErrorResponse("Could not find URP ScriptableRendererData."); + + var featuresProp = rendererData.GetType().GetProperty("rendererFeatures", + BindingFlags.Public | BindingFlags.Instance); + var featuresList = featuresProp?.GetValue(rendererData) as System.Collections.IList; + if (featuresList == null || featuresList.Count == 0) + return new ErrorResponse("No renderer features to configure."); + + int targetIndex = ResolveFeatureIndex(featuresList, index, name); + if (targetIndex < 0) + return new ErrorResponse($"Feature not found. Specify 'index' (0-{featuresList.Count - 1}) or 'name'."); + + var feature = featuresList[targetIndex] as ScriptableObject; + if (feature == null) + return new ErrorResponse($"Feature at index {targetIndex} is null."); + + Undo.RecordObject(feature, "Configure Renderer Feature"); + var result = ApplyFeatureProperties(feature, propertiesToken); + EditorUtility.SetDirty(feature); + EditorUtility.SetDirty(rendererData as UnityEngine.Object); + AssetDatabase.SaveAssets(); + + return new + { + success = true, + message = $"Configured '{feature.name}': {result.changed.Count} set, {result.failed.Count} failed.", + data = new { result.changed, result.failed } + }; + } + + // === feature_toggle === + internal static object ToggleFeature(JObject @params) + { + var p = new ToolParams(@params); + int? index = p.GetInt("index"); + string name = p.Get("name"); + bool? active = p.GetBool("active"); + + var rendererData = GetRendererData(@params); + if (rendererData == null) + return new ErrorResponse("Could not find URP ScriptableRendererData."); + + var featuresProp = rendererData.GetType().GetProperty("rendererFeatures", + BindingFlags.Public | BindingFlags.Instance); + var featuresList = featuresProp?.GetValue(rendererData) as System.Collections.IList; + if (featuresList == null || featuresList.Count == 0) + return new ErrorResponse("No renderer features."); + + int targetIndex = ResolveFeatureIndex(featuresList, index, name); + if (targetIndex < 0) + return new ErrorResponse($"Feature not found. Specify 'index' or 'name'."); + + var feature = featuresList[targetIndex] as ScriptableObject; + if (feature == null) + return new ErrorResponse($"Feature at index {targetIndex} is null."); + + // ScriptableRendererFeature.SetActive(bool) is public + var setActiveMethod = feature.GetType().GetMethod("SetActive", + BindingFlags.Public | BindingFlags.Instance); + if (setActiveMethod == null) + return new ErrorResponse("SetActive method not found on feature."); + + bool newState = active ?? true; + Undo.RecordObject(feature, "Toggle Renderer Feature"); + setActiveMethod.Invoke(feature, new object[] { newState }); + EditorUtility.SetDirty(feature); + EditorUtility.SetDirty(rendererData as UnityEngine.Object); + AssetDatabase.SaveAssets(); + + return new + { + success = true, + message = $"Feature '{feature.name}' {(newState ? "enabled" : "disabled")}." + }; + } + + // === feature_reorder === + internal static object ReorderFeatures(JObject @params) + { + var p = new ToolParams(@params); + var orderToken = p.GetRaw("order") as JArray; + if (orderToken == null) + return new ErrorResponse("'order' parameter required (array of indices, e.g. [2, 0, 1])."); + + var rendererData = GetRendererData(@params); + if (rendererData == null) + return new ErrorResponse("Could not find URP ScriptableRendererData."); + + var featuresProp = rendererData.GetType().GetProperty("rendererFeatures", + BindingFlags.Public | BindingFlags.Instance); + var featuresList = featuresProp?.GetValue(rendererData) as System.Collections.IList; + if (featuresList == null || featuresList.Count == 0) + return new ErrorResponse("No renderer features to reorder."); + + var newOrder = orderToken.Select(t => (int)t).ToList(); + if (newOrder.Count != featuresList.Count) + return new ErrorResponse( + $"Order array length ({newOrder.Count}) must match feature count ({featuresList.Count})."); + + // Validate all indices are present + var sorted = newOrder.OrderBy(x => x).ToList(); + for (int i = 0; i < sorted.Count; i++) + { + if (sorted[i] != i) + return new ErrorResponse("Order array must contain each index exactly once (0 to N-1)."); + } + + Undo.RecordObject(rendererData as UnityEngine.Object, "Reorder Renderer Features"); + + using (var so = new SerializedObject(rendererData as UnityEngine.Object)) + { + var rendererFeaturesPropSo = so.FindProperty("m_RendererFeatures"); + if (rendererFeaturesPropSo == null) + return new ErrorResponse("m_RendererFeatures property not found."); + + // Read current features + var current = new UnityEngine.Object[featuresList.Count]; + for (int i = 0; i < featuresList.Count; i++) + current[i] = rendererFeaturesPropSo.GetArrayElementAtIndex(i).objectReferenceValue; + + // Apply new order + for (int i = 0; i < newOrder.Count; i++) + rendererFeaturesPropSo.GetArrayElementAtIndex(i).objectReferenceValue = current[newOrder[i]]; + + // Also reorder the feature map to keep it in sync + var mapProp = so.FindProperty("m_RendererFeatureMap"); + if (mapProp != null && mapProp.arraySize == featuresList.Count) + { + var currentMap = new long[featuresList.Count]; + for (int i = 0; i < featuresList.Count; i++) + currentMap[i] = mapProp.GetArrayElementAtIndex(i).longValue; + for (int i = 0; i < newOrder.Count; i++) + mapProp.GetArrayElementAtIndex(i).longValue = currentMap[newOrder[i]]; + } + + so.ApplyModifiedProperties(); + } + + EditorUtility.SetDirty(rendererData as UnityEngine.Object); + AssetDatabase.SaveAssets(); + + return new + { + success = true, + message = $"Reordered {featuresList.Count} renderer features." + }; + } + + // ==================== Helpers ==================== + + private static object GetRendererData(JObject @params) + { + EnsureTypes(); + if (_universalRenderPipelineAssetType == null || _scriptableRendererDataType == null) + return null; + + var pipelineAsset = GraphicsSettings.currentRenderPipeline; + if (pipelineAsset == null || !_universalRenderPipelineAssetType.IsInstanceOfType(pipelineAsset)) + return null; + + var p = new ToolParams(@params); + int rendererIndex = p.GetInt("renderer_index") ?? -1; + + // Get renderer data from the URP asset + // Try scriptableRendererData property or GetRenderer method + if (rendererIndex >= 0) + { + // Use SerializedObject to get specific renderer + using (var so = new SerializedObject(pipelineAsset)) + { + var renderersProp = so.FindProperty("m_RendererDataList"); + if (renderersProp == null || rendererIndex >= renderersProp.arraySize) + return null; + + var element = renderersProp.GetArrayElementAtIndex(rendererIndex); + return element.objectReferenceValue; + } + } + + // Default: get the active renderer (index from m_DefaultRendererIndex) + using (var so = new SerializedObject(pipelineAsset)) + { + var defaultIndex = so.FindProperty("m_DefaultRendererIndex"); + int idx = defaultIndex != null ? defaultIndex.intValue : 0; + + var renderersProp = so.FindProperty("m_RendererDataList"); + if (renderersProp != null && idx < renderersProp.arraySize) + { + var element = renderersProp.GetArrayElementAtIndex(idx); + return element.objectReferenceValue; + } + } + + return null; + } + + private static Type ResolveFeatureType(string typeName) + { + EnsureTypes(); + if (_scriptableRendererFeatureType == null) return null; + + var derivedTypes = TypeCache.GetTypesDerivedFrom(_scriptableRendererFeatureType); + foreach (var t in derivedTypes) + { + if (t.IsAbstract) continue; + if (string.Equals(t.Name, typeName, StringComparison.OrdinalIgnoreCase)) + return t; + } + + // Try partial match (e.g., "FullScreenPass" matches "FullScreenPassRendererFeature") + foreach (var t in derivedTypes) + { + if (t.IsAbstract) continue; + if (t.Name.StartsWith(typeName, StringComparison.OrdinalIgnoreCase)) + return t; + } + + return null; + } + + private static List GetAvailableFeatureTypes() + { + EnsureTypes(); + if (_scriptableRendererFeatureType == null) return new List(); + + return TypeCache.GetTypesDerivedFrom(_scriptableRendererFeatureType) + .Where(t => !t.IsAbstract && !t.IsGenericType) + .OrderBy(t => t.Name) + .ToList(); + } + + private static int ResolveFeatureIndex(System.Collections.IList featuresList, int? index, string name) + { + if (index.HasValue && index.Value >= 0 && index.Value < featuresList.Count) + return index.Value; + + if (!string.IsNullOrEmpty(name)) + { + for (int i = 0; i < featuresList.Count; i++) + { + var feature = featuresList[i] as ScriptableObject; + if (feature == null) continue; + if (string.Equals(feature.name, name, StringComparison.OrdinalIgnoreCase) || + string.Equals(feature.GetType().Name, name, StringComparison.OrdinalIgnoreCase)) + return i; + } + } + + return -1; + } + + private static Dictionary GetFeatureProperties(ScriptableObject feature) + { + var props = new Dictionary(); + using (var so = new SerializedObject(feature)) + { + var iterator = so.GetIterator(); + if (iterator.NextVisible(true)) // Enter children + { + do + { + // Skip Unity internal properties + if (iterator.name == "m_Script" || iterator.name == "m_ObjectHideFlags" || iterator.name == "m_Name") + continue; + + props[iterator.name] = GraphicsHelpers.ReadSerializedValue(iterator); + } while (iterator.NextVisible(false)); + } + } + return props; + } + + private static (List changed, List failed) ApplyFeatureProperties( + ScriptableObject feature, JObject propertiesToken) + { + var changed = new List(); + var failed = new List(); + + using (var so = new SerializedObject(feature)) + { + foreach (var prop in propertiesToken.Properties()) + { + var sProp = so.FindProperty(prop.Name); + if (sProp != null) + { + if (GraphicsHelpers.SetSerializedValue(sProp, prop.Value)) + changed.Add(prop.Name); + else + failed.Add(prop.Name); + } + else + { + // Try nested: "settings.fieldName" + string nested = $"settings.{prop.Name}"; + sProp = so.FindProperty(nested); + if (sProp != null && GraphicsHelpers.SetSerializedValue(sProp, prop.Value)) + changed.Add(prop.Name); + else + failed.Add(prop.Name); + } + } + so.ApplyModifiedProperties(); + } + + return (changed, failed); + } + + private static void TrySetMaterial(ScriptableObject feature, string materialPath) + { + var mat = AssetDatabase.LoadAssetAtPath(materialPath); + if (mat == null) return; + + using (var so = new SerializedObject(feature)) + { + // FullScreenPassRendererFeature uses "m_PassMaterial" or "passMaterial" + var matProp = so.FindProperty("m_PassMaterial") ?? so.FindProperty("passMaterial"); + if (matProp != null && matProp.propertyType == SerializedPropertyType.ObjectReference) + { + matProp.objectReferenceValue = mat; + so.ApplyModifiedProperties(); + } + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/Graphics/RendererFeatureOps.cs.meta b/MCPForUnity/Editor/Tools/Graphics/RendererFeatureOps.cs.meta new file mode 100644 index 000000000..bee2aa711 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/RendererFeatureOps.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5a75368a2f6b478fbaa88a36c677b5af +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Graphics/RenderingStatsOps.cs b/MCPForUnity/Editor/Tools/Graphics/RenderingStatsOps.cs new file mode 100644 index 000000000..fbf018d1e --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/RenderingStatsOps.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using Unity.Profiling; +using Unity.Profiling.LowLevel.Unsafe; +using UnityEngine.Profiling; + +namespace MCPForUnity.Editor.Tools.Graphics +{ + internal static class RenderingStatsOps + { + private static readonly (string counterName, string jsonKey)[] COUNTER_MAP = new[] + { + ("Draw Calls Count", "draw_calls"), + ("Batches Count", "batches"), + ("SetPass Calls Count", "set_pass_calls"), + ("Triangles Count", "triangles"), + ("Vertices Count", "vertices"), + ("Dynamic Batches Count", "dynamic_batches"), + ("Dynamic Batched Draw Calls Count", "dynamic_batched_draw_calls"), + ("Static Batches Count", "static_batches"), + ("Static Batched Draw Calls Count", "static_batched_draw_calls"), + ("Instanced Batches Count", "instanced_batches"), + ("Instanced Batched Draw Calls Count", "instanced_batched_draw_calls"), + ("Shadow Casters Count", "shadow_casters"), + ("Render Textures Count", "render_textures"), + ("Render Textures Bytes", "render_textures_bytes"), + ("Used Textures Count", "used_textures"), + ("Used Textures Bytes", "used_textures_bytes"), + ("Render Textures Changes Count", "render_target_changes"), + ("Visible Skinned Meshes Count", "visible_skinned_meshes"), + }; + + // === stats_get === + internal static object GetStats(JObject @params) + { + var stats = new Dictionary(); + + foreach (var (counterName, jsonKey) in COUNTER_MAP) + { + using var recorder = ProfilerRecorder.StartNew(ProfilerCategory.Render, counterName); + stats[jsonKey] = recorder.Valid ? recorder.CurrentValue : 0; + } + + return new + { + success = true, + message = "Rendering stats captured.", + data = stats + }; + } + + // === stats_list_counters === + internal static object ListCounters(JObject @params) + { + var p = new ToolParams(@params); + string categoryName = p.Get("category"); + + // Default to "Render" category to avoid massive payloads (all categories = 300K+ chars) + ProfilerCategory category = ProfilerCategory.Render; + if (!string.IsNullOrEmpty(categoryName)) + { + category = TryResolveCategory(categoryName); + } + + var allHandles = new List(); + ProfilerRecorderHandle.GetAvailable(allHandles); + var counters = allHandles + .Select(h => ProfilerRecorderHandle.GetDescription(h)) + .Where(d => string.Equals(d.Category.Name, category.Name, StringComparison.OrdinalIgnoreCase)) + .Select(d => new + { + name = d.Name, + category = d.Category.Name, + unit = d.UnitType.ToString() + }) + .OrderBy(c => c.name).ToList(); + + return new + { + success = true, + message = $"Found {counters.Count} counters in category '{category.Name}'.", + data = new { counters } + }; + } + + // === stats_set_scene_debug_mode === + internal static object SetSceneDebugMode(JObject @params) + { + var p = new ToolParams(@params); + string modeName = p.Get("mode"); + if (string.IsNullOrEmpty(modeName)) + { + var validModes = string.Join(", ", Enum.GetNames(typeof(DrawCameraMode)).Take(20)); + return new ErrorResponse( + $"'mode' parameter required. Options: {validModes}"); + } + + if (!Enum.TryParse(modeName, true, out var drawMode)) + { + var validModes = string.Join(", ", Enum.GetNames(typeof(DrawCameraMode)).Take(20)); + return new ErrorResponse($"Unknown mode '{modeName}'. Valid: {validModes}"); + } + + var sceneView = SceneView.lastActiveSceneView; + if (sceneView == null) + return new ErrorResponse("No active Scene View found."); + + sceneView.cameraMode = SceneView.GetBuiltinCameraMode(drawMode); + + sceneView.Repaint(); + + return new + { + success = true, + message = $"Scene debug mode set to '{drawMode}'." + }; + } + + // === stats_get_memory === + internal static object GetMemory(JObject @params) + { + var data = new Dictionary + { + ["totalAllocatedMB"] = Math.Round(Profiler.GetTotalAllocatedMemoryLong() / (1024.0 * 1024.0), 2), + ["totalReservedMB"] = Math.Round(Profiler.GetTotalReservedMemoryLong() / (1024.0 * 1024.0), 2), + ["totalUnusedReservedMB"] = Math.Round(Profiler.GetTotalUnusedReservedMemoryLong() / (1024.0 * 1024.0), 2), + ["monoUsedMB"] = Math.Round(Profiler.GetMonoUsedSizeLong() / (1024.0 * 1024.0), 2), + ["monoHeapMB"] = Math.Round(Profiler.GetMonoHeapSizeLong() / (1024.0 * 1024.0), 2), + ["graphicsDriverMB"] = Math.Round(Profiler.GetAllocatedMemoryForGraphicsDriver() / (1024.0 * 1024.0), 2), + }; + + return new + { + success = true, + message = "Memory stats captured.", + data + }; + } + + // --- Helper: Try to resolve a ProfilerCategory by name --- + private static ProfilerCategory TryResolveCategory(string name) + { + // ProfilerCategory has static properties for well-known categories + switch (name.ToLowerInvariant()) + { + case "render": return ProfilerCategory.Render; + case "scripts": return ProfilerCategory.Scripts; + case "memory": return ProfilerCategory.Memory; + case "physics": return ProfilerCategory.Physics; + case "animation": return ProfilerCategory.Animation; + case "audio": return ProfilerCategory.Audio; + case "lighting": return ProfilerCategory.Lighting; + case "network": return ProfilerCategory.Network; + case "gui": return ProfilerCategory.Gui; + case "ai": return ProfilerCategory.Ai; + case "video": return ProfilerCategory.Video; + case "loading": return ProfilerCategory.Loading; + case "input": return ProfilerCategory.Input; + case "vr": return ProfilerCategory.Vr; + case "internal": return ProfilerCategory.Internal; + default: return ProfilerCategory.Render; + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/Graphics/RenderingStatsOps.cs.meta b/MCPForUnity/Editor/Tools/Graphics/RenderingStatsOps.cs.meta new file mode 100644 index 000000000..95761f60f --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/RenderingStatsOps.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c6a02f2bb19e450a9ddca18ee8d211bf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Graphics/SkyboxOps.cs b/MCPForUnity/Editor/Tools/Graphics/SkyboxOps.cs new file mode 100644 index 000000000..4ae2dbfe8 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/SkyboxOps.cs @@ -0,0 +1,480 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using UnityEditor; +using UnityEngine; +using UnityEngine.Rendering; + +namespace MCPForUnity.Editor.Tools.Graphics +{ + internal static class SkyboxOps + { + // --------------------------------------------------------------- + // skybox_get — read all environment settings + // --------------------------------------------------------------- + public static object GetEnvironment(JObject @params) + { + var skyMat = RenderSettings.skybox; + var sun = RenderSettings.sun; + + object matInfo = null; + if (skyMat != null) + { + var props = new List(); + int count = skyMat.shader.GetPropertyCount(); + for (int i = 0; i < count; i++) + { + string propName = skyMat.shader.GetPropertyName(i); + var propType = skyMat.shader.GetPropertyType(i); + object val = ReadMaterialProperty(skyMat, propName, propType); + props.Add(new { name = propName, type = propType.ToString(), value = val }); + } + matInfo = new + { + name = skyMat.name, + shader = skyMat.shader.name, + path = AssetDatabase.GetAssetPath(skyMat), + properties = props + }; + } + + return new + { + success = true, + message = "Environment settings retrieved.", + data = new + { + skybox = matInfo, + ambient = new + { + mode = RenderSettings.ambientMode.ToString(), + skyColor = ColorToArray(RenderSettings.ambientSkyColor), + equatorColor = ColorToArray(RenderSettings.ambientEquatorColor), + groundColor = ColorToArray(RenderSettings.ambientGroundColor), + ambientLight = ColorToArray(RenderSettings.ambientLight), + intensity = RenderSettings.ambientIntensity + }, + fog = new + { + enabled = RenderSettings.fog, + mode = RenderSettings.fogMode.ToString(), + color = ColorToArray(RenderSettings.fogColor), + density = RenderSettings.fogDensity, + startDistance = RenderSettings.fogStartDistance, + endDistance = RenderSettings.fogEndDistance + }, + reflection = new + { + intensity = RenderSettings.reflectionIntensity, + bounces = RenderSettings.reflectionBounces, + mode = RenderSettings.defaultReflectionMode.ToString(), + resolution = RenderSettings.defaultReflectionResolution, + customCubemap = RenderSettings.customReflectionTexture != null + ? AssetDatabase.GetAssetPath(RenderSettings.customReflectionTexture) + : null + }, + sun = sun != null + ? (object)new { name = sun.gameObject.name, instanceID = sun.gameObject.GetInstanceID() } + : null, + subtractiveShadowColor = ColorToArray(RenderSettings.subtractiveShadowColor) + } + }; + } + + // --------------------------------------------------------------- + // skybox_set_material — assign a skybox material + // --------------------------------------------------------------- + public static object SetMaterial(JObject @params) + { + var p = new ToolParams(@params); + string materialPath = p.Get("material") ?? p.Get("path") ?? p.Get("material_path"); + if (string.IsNullOrEmpty(materialPath)) + return new ErrorResponse("'material' (asset path) is required."); + + var mat = AssetDatabase.LoadAssetAtPath(materialPath); + if (mat == null) + return new ErrorResponse($"Material not found at '{materialPath}'."); + + RenderSettings.skybox = mat; + MarkSceneDirty(); + + return new + { + success = true, + message = $"Skybox set to '{mat.name}' (shader: {mat.shader.name}).", + data = new + { + material = mat.name, + shader = mat.shader.name, + path = materialPath + } + }; + } + + // --------------------------------------------------------------- + // skybox_set_properties — modify properties on the current skybox material + // --------------------------------------------------------------- + public static object SetMaterialProperties(JObject @params) + { + var p = new ToolParams(@params); + var skyMat = RenderSettings.skybox; + if (skyMat == null) + return new ErrorResponse("No skybox material is set."); + + var propsRaw = p.GetRaw("properties") ?? p.GetRaw("parameters"); + if (propsRaw == null || propsRaw.Type != JTokenType.Object) + return new ErrorResponse("'properties' dict is required."); + + var set = new List(); + var failed = new List(); + + foreach (var kvp in (JObject)propsRaw) + { + string propName = kvp.Key; + if (!skyMat.HasProperty(propName)) + { + string altName = "_" + propName; + if (skyMat.HasProperty(altName)) + propName = altName; + else + { + failed.Add(kvp.Key); + continue; + } + } + + if (SetMaterialProperty(skyMat, propName, kvp.Value)) + set.Add(kvp.Key); + else + failed.Add(kvp.Key); + } + + EditorUtility.SetDirty(skyMat); + AssetDatabase.SaveAssets(); + MarkSceneDirty(); + + return new + { + success = true, + message = $"Set {set.Count} property(ies) on skybox material '{skyMat.name}'.", + data = new { material = skyMat.name, set, failed } + }; + } + + // --------------------------------------------------------------- + // skybox_set_ambient — set ambient lighting mode and colors + // --------------------------------------------------------------- + public static object SetAmbient(JObject @params) + { + var p = new ToolParams(@params); + + string modeStr = p.Get("ambient_mode") ?? p.Get("mode"); + if (!string.IsNullOrEmpty(modeStr)) + { + if (Enum.TryParse(modeStr, true, out var mode)) + RenderSettings.ambientMode = mode; + else + return new ErrorResponse( + $"Invalid ambient mode '{modeStr}'. Valid: Skybox, Trilight, Flat, Custom."); + } + + var skyColor = ParseColorToken(p.GetRaw("color") ?? p.GetRaw("sky_color")); + if (skyColor.HasValue) + RenderSettings.ambientSkyColor = skyColor.Value; + + var equatorColor = ParseColorToken(p.GetRaw("equator_color")); + if (equatorColor.HasValue) + RenderSettings.ambientEquatorColor = equatorColor.Value; + + var groundColor = ParseColorToken(p.GetRaw("ground_color")); + if (groundColor.HasValue) + RenderSettings.ambientGroundColor = groundColor.Value; + + var intensity = p.GetFloat("intensity"); + if (intensity.HasValue) + RenderSettings.ambientIntensity = intensity.Value; + + MarkSceneDirty(); + + return new + { + success = true, + message = $"Ambient lighting updated (mode: {RenderSettings.ambientMode}).", + data = new + { + mode = RenderSettings.ambientMode.ToString(), + skyColor = ColorToArray(RenderSettings.ambientSkyColor), + equatorColor = ColorToArray(RenderSettings.ambientEquatorColor), + groundColor = ColorToArray(RenderSettings.ambientGroundColor), + intensity = RenderSettings.ambientIntensity + } + }; + } + + // --------------------------------------------------------------- + // skybox_set_fog — enable/configure fog + // --------------------------------------------------------------- + public static object SetFog(JObject @params) + { + var p = new ToolParams(@params); + + var enabledToken = p.GetRaw("fog_enabled") ?? p.GetRaw("enabled"); + if (enabledToken != null && enabledToken.Type != JTokenType.Null) + RenderSettings.fog = ParamCoercion.CoerceBool(enabledToken, RenderSettings.fog); + + string modeStr = p.Get("fog_mode") ?? p.Get("mode"); + if (!string.IsNullOrEmpty(modeStr)) + { + if (Enum.TryParse(modeStr, true, out var fogMode)) + RenderSettings.fogMode = fogMode; + else + return new ErrorResponse( + $"Invalid fog mode '{modeStr}'. Valid: Linear, Exponential, ExponentialSquared."); + } + + var fogColor = ParseColorToken(p.GetRaw("fog_color") ?? p.GetRaw("color")); + if (fogColor.HasValue) + RenderSettings.fogColor = fogColor.Value; + + var density = p.GetFloat("fog_density") ?? p.GetFloat("density"); + if (density.HasValue) + RenderSettings.fogDensity = density.Value; + + var start = p.GetFloat("fog_start") ?? p.GetFloat("start"); + if (start.HasValue) + RenderSettings.fogStartDistance = start.Value; + + var end = p.GetFloat("fog_end") ?? p.GetFloat("end"); + if (end.HasValue) + RenderSettings.fogEndDistance = end.Value; + + MarkSceneDirty(); + + return new + { + success = true, + message = $"Fog settings updated (enabled: {RenderSettings.fog}, mode: {RenderSettings.fogMode}).", + data = new + { + enabled = RenderSettings.fog, + mode = RenderSettings.fogMode.ToString(), + color = ColorToArray(RenderSettings.fogColor), + density = RenderSettings.fogDensity, + startDistance = RenderSettings.fogStartDistance, + endDistance = RenderSettings.fogEndDistance + } + }; + } + + // --------------------------------------------------------------- + // skybox_set_reflection — configure environment reflections + // --------------------------------------------------------------- + public static object SetReflection(JObject @params) + { + var p = new ToolParams(@params); + + var intensity = p.GetFloat("intensity"); + if (intensity.HasValue) + RenderSettings.reflectionIntensity = intensity.Value; + + var bounces = p.GetInt("bounces"); + if (bounces.HasValue) + RenderSettings.reflectionBounces = bounces.Value; + + string modeStr = p.Get("reflection_mode") ?? p.Get("mode"); + if (!string.IsNullOrEmpty(modeStr)) + { + if (Enum.TryParse(modeStr, true, out var mode)) + RenderSettings.defaultReflectionMode = mode; + else + return new ErrorResponse( + $"Invalid reflection mode '{modeStr}'. Valid: Skybox, Custom."); + } + + var resolution = p.GetInt("resolution"); + if (resolution.HasValue) + RenderSettings.defaultReflectionResolution = resolution.Value; + + string cubemapPath = p.Get("path") ?? p.Get("cubemap_path"); + if (!string.IsNullOrEmpty(cubemapPath)) + { + var cubemap = AssetDatabase.LoadAssetAtPath(cubemapPath); + if (cubemap != null) + RenderSettings.customReflectionTexture = cubemap; + else + return new ErrorResponse($"Cubemap not found at '{cubemapPath}'."); + } + + MarkSceneDirty(); + + return new + { + success = true, + message = $"Reflection settings updated (intensity: {RenderSettings.reflectionIntensity}, bounces: {RenderSettings.reflectionBounces}).", + data = new + { + intensity = RenderSettings.reflectionIntensity, + bounces = RenderSettings.reflectionBounces, + mode = RenderSettings.defaultReflectionMode.ToString(), + resolution = RenderSettings.defaultReflectionResolution, + customCubemap = RenderSettings.customReflectionTexture != null + ? AssetDatabase.GetAssetPath(RenderSettings.customReflectionTexture) + : null + } + }; + } + + // --------------------------------------------------------------- + // skybox_set_sun — set the sun source light + // --------------------------------------------------------------- + public static object SetSun(JObject @params) + { + var p = new ToolParams(@params); + string target = p.Get("target") ?? p.Get("name"); + if (string.IsNullOrEmpty(target)) + return new ErrorResponse("'target' (light GameObject name or instance ID) is required."); + + GameObject go = null; + if (int.TryParse(target, out int instanceId)) + go = GameObjectLookup.ResolveInstanceID(instanceId) as GameObject; + if (go == null) + go = GameObject.Find(target); + if (go == null) + return new ErrorResponse($"GameObject '{target}' not found."); + + var light = go.GetComponent(); + if (light == null) + return new ErrorResponse($"'{go.name}' does not have a Light component."); + + RenderSettings.sun = light; + MarkSceneDirty(); + + return new + { + success = true, + message = $"Sun source set to '{go.name}'.", + data = new + { + name = go.name, + instanceID = go.GetInstanceID(), + lightType = light.type.ToString() + } + }; + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private static float[] ColorToArray(Color c) + { + return new[] { c.r, c.g, c.b, c.a }; + } + + private static Color ArrayToColor(float[] arr) + { + return new Color( + arr[0], arr[1], arr[2], + arr.Length >= 4 ? arr[3] : 1f); + } + + private static Color? ParseColorToken(JToken token) + { + if (token == null || token.Type == JTokenType.Null) return null; + if (token is JArray arr && arr.Count >= 3) + { + return new Color( + (float)arr[0], (float)arr[1], (float)arr[2], + arr.Count >= 4 ? (float)arr[3] : 1f); + } + return null; + } + + private static object ReadMaterialProperty(Material mat, string propName, ShaderPropertyType propType) + { + switch (propType) + { + case ShaderPropertyType.Color: + return ColorToArray(mat.GetColor(propName)); + case ShaderPropertyType.Float: + case ShaderPropertyType.Range: + return mat.GetFloat(propName); + case ShaderPropertyType.Int: + return mat.GetInt(propName); + case ShaderPropertyType.Vector: + var v = mat.GetVector(propName); + return new[] { v.x, v.y, v.z, v.w }; + case ShaderPropertyType.Texture: + var tex = mat.GetTexture(propName); + return tex != null ? AssetDatabase.GetAssetPath(tex) : null; + default: + return null; + } + } + + private static bool SetMaterialProperty(Material mat, string propName, JToken value) + { + int propIdx = mat.shader.FindPropertyIndex(propName); + if (propIdx < 0) return false; + + var propType = mat.shader.GetPropertyType(propIdx); + try + { + switch (propType) + { + case ShaderPropertyType.Color: + if (value is JArray colorArr && colorArr.Count >= 3) + { + mat.SetColor(propName, new Color( + (float)colorArr[0], (float)colorArr[1], (float)colorArr[2], + colorArr.Count >= 4 ? (float)colorArr[3] : 1f)); + return true; + } + return false; + case ShaderPropertyType.Float: + case ShaderPropertyType.Range: + mat.SetFloat(propName, (float)value); + return true; + case ShaderPropertyType.Int: + mat.SetInt(propName, (int)value); + return true; + case ShaderPropertyType.Vector: + if (value is JArray vecArr && vecArr.Count >= 2) + { + mat.SetVector(propName, new Vector4( + (float)vecArr[0], (float)vecArr[1], + vecArr.Count >= 3 ? (float)vecArr[2] : 0f, + vecArr.Count >= 4 ? (float)vecArr[3] : 0f)); + return true; + } + return false; + case ShaderPropertyType.Texture: + if (value.Type == JTokenType.String) + { + var tex = AssetDatabase.LoadAssetAtPath(value.ToString()); + if (tex != null) { mat.SetTexture(propName, tex); return true; } + } + else if (value.Type == JTokenType.Null) + { + mat.SetTexture(propName, null); + return true; + } + return false; + default: + return false; + } + } + catch + { + return false; + } + } + + private static void MarkSceneDirty() + { + UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty( + UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene()); + } + } +} diff --git a/MCPForUnity/Editor/Tools/Graphics/SkyboxOps.cs.meta b/MCPForUnity/Editor/Tools/Graphics/SkyboxOps.cs.meta new file mode 100644 index 000000000..0919d0da7 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/SkyboxOps.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 93b73ba8237dee84088417864958084a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Graphics/VolumeOps.cs b/MCPForUnity/Editor/Tools/Graphics/VolumeOps.cs new file mode 100644 index 000000000..4bfceffb4 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/VolumeOps.cs @@ -0,0 +1,708 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Graphics +{ + internal static class VolumeOps + { + // === volume_create === + // Params: name (string), is_global (bool, default true), weight (float, default 1), + // priority (float, default 0), profile_path (string, optional - path to save VolumeProfile asset), + // effects (array of {type, ...params}, optional - effects to add immediately) + internal static object CreateVolume(JObject @params) + { + var p = new ToolParams(@params); + string name = p.Get("name") ?? "Volume"; + bool isGlobal = p.GetBool("is_global", true); + float weight = p.GetFloat("weight") ?? 1.0f; + float priority = p.GetFloat("priority") ?? 0f; + string profilePath = p.Get("profile_path"); + if (!string.IsNullOrEmpty(profilePath)) + { + if (!profilePath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) && + !profilePath.StartsWith("Assets\\", StringComparison.OrdinalIgnoreCase)) + profilePath = "Assets/" + profilePath; + if (!profilePath.EndsWith(".asset", StringComparison.OrdinalIgnoreCase)) + profilePath += ".asset"; + } + + var go = new GameObject(name); + Undo.RegisterCreatedObjectUndo(go, $"Create Volume '{name}'"); + + // Add Volume component via reflection + var volumeComp = go.AddComponent(GraphicsHelpers.VolumeType); + + // Set properties via reflection + SetProperty(volumeComp, "isGlobal", isGlobal); + SetProperty(volumeComp, "weight", weight); + SetProperty(volumeComp, "priority", priority); + + // Create or load VolumeProfile + object profile; + if (!string.IsNullOrEmpty(profilePath)) + { + // Load existing or create new profile asset + profile = AssetDatabase.LoadAssetAtPath(profilePath, GraphicsHelpers.VolumeProfileType); + if (profile == null) + { + profile = ScriptableObject.CreateInstance(GraphicsHelpers.VolumeProfileType); + // Ensure directory exists + var dir = System.IO.Path.GetDirectoryName(profilePath); + if (!string.IsNullOrEmpty(dir) && !System.IO.Directory.Exists(dir)) + System.IO.Directory.CreateDirectory(dir); + AssetDatabase.CreateAsset((UnityEngine.Object)profile, profilePath); + } + } + else + { + // Create embedded profile (not saved as asset) + profile = ScriptableObject.CreateInstance(GraphicsHelpers.VolumeProfileType); + } + + // Assign profile (sharedProfile is a public field, handled by SetProperty's field fallback) + SetProperty(volumeComp, "sharedProfile", profile); + + // Add initial effects if provided + var effectsToken = p.GetRaw("effects") as JArray; + var addedEffects = new List(); + if (effectsToken != null) + { + foreach (var effectDef in effectsToken) + { + if (effectDef is JObject effectObj) + { + string effectType = ParamCoercion.CoerceString(effectObj["type"], null); + if (string.IsNullOrEmpty(effectType)) continue; + + var type = GraphicsHelpers.ResolveVolumeComponentType(effectType); + if (type == null) continue; + + // profile.Add(type, true) + var addMethod = GraphicsHelpers.VolumeProfileType.GetMethod("Add", + new[] { typeof(Type), typeof(bool) }); + if (addMethod != null) + { + var component = addMethod.Invoke(profile, new object[] { type, true }); + if (component != null) + { + // Set parameters — support both nested {"parameters": {...}} and flat fields + var paramObj = effectObj["parameters"] as JObject; + if (paramObj != null) + { + foreach (var pp in paramObj.Properties()) + SetVolumeParameter(component, pp.Name, pp.Value); + } + else + { + foreach (var prop in effectObj.Properties()) + { + if (prop.Name == "type") continue; + SetVolumeParameter(component, prop.Name, prop.Value); + } + } + addedEffects.Add(effectType); + } + } + } + } + if (profile is UnityEngine.Object profileObj) + EditorUtility.SetDirty(profileObj); + } + + GraphicsHelpers.MarkDirty(volumeComp); + + return new + { + success = true, + message = $"Created {(isGlobal ? "global" : "local")} Volume '{name}'" + + (addedEffects.Count > 0 ? $" with effects: {string.Join(", ", addedEffects)}" : ""), + data = new + { + instanceID = go.GetInstanceID(), + isGlobal, + weight, + priority, + profilePath = profilePath ?? "(embedded)", + effects = addedEffects + } + }; + } + + // === volume_add_effect === + // Params: target (string/int), effect (string - type name like "Bloom") + internal static object AddEffect(JObject @params) + { + var p = new ToolParams(@params); + string effectName = p.Get("effect"); + if (string.IsNullOrEmpty(effectName)) + return new ErrorResponse("'effect' parameter is required (e.g., 'Bloom', 'Vignette')."); + + var volume = GraphicsHelpers.FindVolume(@params); + if (volume == null) + return new ErrorResponse("Volume not found. Specify 'target' (name or instance ID)."); + + var effectType = GraphicsHelpers.ResolveVolumeComponentType(effectName); + if (effectType == null) + { + var available = GraphicsHelpers.GetAvailableEffectTypes() + .Select(t => t.Name).ToList(); + return new ErrorResponse( + $"Effect type '{effectName}' not found. Available: {string.Join(", ", available.Take(20))}"); + } + + var profile = GetProperty(volume, "sharedProfile"); + if (profile == null) + return new ErrorResponse("Volume has no profile assigned."); + + // Check if effect already exists + var components = GetProperty(profile, "components") as System.Collections.IList; + if (components != null) + { + foreach (var comp in components) + { + if (comp != null && comp.GetType() == effectType) + return new ErrorResponse($"Effect '{effectName}' already exists on this Volume. Use volume_set_effect to modify it."); + } + } + + // profile.Add(effectType, true) -- 'true' means override all params + var addMethod = GraphicsHelpers.VolumeProfileType.GetMethod("Add", + new[] { typeof(Type), typeof(bool) }); + if (addMethod == null) + return new ErrorResponse("Could not find VolumeProfile.Add method."); + + var component = addMethod.Invoke(profile, new object[] { effectType, true }); + if (component == null) + return new ErrorResponse($"Failed to add effect '{effectName}'."); + + if (profile is UnityEngine.Object profileObj) + EditorUtility.SetDirty(profileObj); + GraphicsHelpers.MarkDirty(volume); + + return new + { + success = true, + message = $"Added '{effectName}' to Volume '{(volume as Component)?.gameObject.name}'.", + data = new { effect = effectName, volumeInstanceID = (volume as Component)?.gameObject.GetInstanceID() } + }; + } + + // === volume_set_effect === + // Params: target (string/int), effect (string), parameters (dict of field->value) + internal static object SetEffect(JObject @params) + { + var p = new ToolParams(@params); + string effectName = p.Get("effect"); + if (string.IsNullOrEmpty(effectName)) + return new ErrorResponse("'effect' parameter is required."); + + var volume = GraphicsHelpers.FindVolume(@params); + if (volume == null) + return new ErrorResponse("Volume not found. Specify 'target'."); + + var profile = GetProperty(volume, "sharedProfile"); + if (profile == null) + return new ErrorResponse("Volume has no profile assigned."); + + var effectType = GraphicsHelpers.ResolveVolumeComponentType(effectName); + if (effectType == null) + return new ErrorResponse($"Effect type '{effectName}' not found."); + + // Find the effect component in the profile + var components = GetProperty(profile, "components") as System.Collections.IList; + if (components == null) + return new ErrorResponse("Could not read profile components."); + + object targetComponent = null; + foreach (var comp in components) + { + if (comp != null && comp.GetType() == effectType) + { + targetComponent = comp; + break; + } + } + + if (targetComponent == null) + return new ErrorResponse($"Effect '{effectName}' not found on this Volume. Use volume_add_effect first."); + + // Set parameters + var parameters = p.GetRaw("parameters") as JObject; + if (parameters == null) + return new ErrorResponse("'parameters' dict is required."); + + var setParams = new List(); + var failedParams = new List(); + foreach (var prop in parameters.Properties()) + { + if (SetVolumeParameter(targetComponent, prop.Name, prop.Value)) + setParams.Add(prop.Name); + else + failedParams.Add(prop.Name); + } + + if (profile is UnityEngine.Object profileObj) + EditorUtility.SetDirty(profileObj); + + var msg = $"Set {setParams.Count} parameter(s) on '{effectName}'"; + if (failedParams.Count > 0) + msg += $". Failed: {string.Join(", ", failedParams)}"; + + return new + { + success = true, + message = msg, + data = new { effect = effectName, set = setParams, failed = failedParams } + }; + } + + // === volume_remove_effect === + // Params: target, effect + internal static object RemoveEffect(JObject @params) + { + var p = new ToolParams(@params); + string effectName = p.Get("effect"); + if (string.IsNullOrEmpty(effectName)) + return new ErrorResponse("'effect' parameter is required."); + + var volume = GraphicsHelpers.FindVolume(@params); + if (volume == null) + return new ErrorResponse("Volume not found."); + + var effectType = GraphicsHelpers.ResolveVolumeComponentType(effectName); + if (effectType == null) + return new ErrorResponse($"Effect type '{effectName}' not found."); + + var profile = GetProperty(volume, "sharedProfile"); + if (profile == null) + return new ErrorResponse("Volume has no profile."); + + // Check if effect exists before removing + bool found = false; + var components = GetProperty(profile, "components") as System.Collections.IList; + if (components != null) + { + foreach (var comp in components) + { + if (comp != null && comp.GetType() == effectType) + { + found = true; + break; + } + } + } + if (!found) + return new ErrorResponse($"Effect '{effectName}' not found on this Volume."); + + var removeMethod = GraphicsHelpers.VolumeProfileType.GetMethod("Remove", + new[] { typeof(Type) }); + if (removeMethod == null) + return new ErrorResponse("Could not find VolumeProfile.Remove method."); + + removeMethod.Invoke(profile, new object[] { effectType }); + + if (profile is UnityEngine.Object profileObj) + EditorUtility.SetDirty(profileObj); + GraphicsHelpers.MarkDirty(volume); + + return new + { + success = true, + message = $"Removed '{effectName}' from Volume.", + data = new { effect = effectName } + }; + } + + // === volume_get_info === + // Params: target (optional -- if omitted, returns info for all volumes) + internal static object GetInfo(JObject @params) + { + var volume = GraphicsHelpers.FindVolume(@params); + if (volume == null) + return new ErrorResponse("Volume not found."); + + var info = BuildVolumeInfo(volume); + return new + { + success = true, + message = $"Volume info for '{(volume as Component)?.gameObject.name}'.", + data = info + }; + } + + // === volume_set_properties === + // Params: target, weight, priority, is_global, blend_distance + // OR properties dict with those keys + internal static object SetProperties(JObject @params) + { + var p = new ToolParams(@params); + var volume = GraphicsHelpers.FindVolume(@params); + if (volume == null) + return new ErrorResponse("Volume not found."); + + // Unpack "properties" dict into top-level params so callers can use either style + var propsDict = p.GetRaw("properties") as JObject; + if (propsDict != null) + { + foreach (var prop in propsDict.Properties()) + { + if (@params[prop.Name] == null) + @params[prop.Name] = prop.Value; + } + p = new ToolParams(@params); + } + + var changed = new List(); + + var weight = p.GetFloat("weight"); + if (weight.HasValue) { SetProperty(volume, "weight", weight.Value); changed.Add("weight"); } + + var priority = p.GetFloat("priority"); + if (priority.HasValue) { SetProperty(volume, "priority", priority.Value); changed.Add("priority"); } + + if (p.Has("is_global")) { SetProperty(volume, "isGlobal", p.GetBool("is_global")); changed.Add("isGlobal"); } + + var blendDist = p.GetFloat("blend_distance"); + if (blendDist.HasValue) { SetProperty(volume, "blendDistance", blendDist.Value); changed.Add("blendDistance"); } + + if (changed.Count == 0) + return new ErrorResponse("No properties specified. Use: weight, priority, is_global, blend_distance."); + + GraphicsHelpers.MarkDirty(volume); + return new + { + success = true, + message = $"Updated Volume properties: {string.Join(", ", changed)}", + data = new { changed } + }; + } + + // === volume_list_effects === + // No params needed -- lists all available VolumeComponent types + internal static object ListEffects(JObject @params) + { + var types = GraphicsHelpers.GetAvailableEffectTypes(); + var effectList = types.Select(t => new + { + name = t.Name, + fullName = t.FullName, + ns = t.Namespace + }).ToList(); + + return new + { + success = true, + message = $"Found {effectList.Count} available volume effects.", + data = new { pipeline = GraphicsHelpers.GetPipelineName(), effects = effectList } + }; + } + + // === volume_create_profile === + // Params: path (string -- asset path like "Assets/Settings/MyProfile.asset") + internal static object CreateProfile(JObject @params) + { + var p = new ToolParams(@params); + string path = p.Get("path"); + if (string.IsNullOrEmpty(path)) + return new ErrorResponse("'path' parameter is required (e.g., 'Settings/MyProfile' or 'Assets/Settings/MyProfile.asset')."); + + // Auto-prepend Assets/ if missing (paths are relative to Assets/ by convention) + if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) && + !path.StartsWith("Assets\\", StringComparison.OrdinalIgnoreCase)) + path = "Assets/" + path; + + if (!path.EndsWith(".asset", StringComparison.OrdinalIgnoreCase)) + path += ".asset"; + + // Ensure directory exists + var dir = System.IO.Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !AssetDatabase.IsValidFolder(dir)) + { + // Create folders recursively + var parts = dir.Replace("\\", "/").Split('/'); + string current = parts[0]; + for (int i = 1; i < parts.Length; i++) + { + string next = current + "/" + parts[i]; + if (!AssetDatabase.IsValidFolder(next)) + AssetDatabase.CreateFolder(current, parts[i]); + current = next; + } + } + + var profile = ScriptableObject.CreateInstance(GraphicsHelpers.VolumeProfileType); + AssetDatabase.CreateAsset(profile, path); + AssetDatabase.SaveAssets(); + + return new + { + success = true, + message = $"Created VolumeProfile at '{path}'.", + data = new { path } + }; + } + + // === ListVolumes (used by VolumesResource) === + internal static object ListVolumes(JObject @params) + { + if (!GraphicsHelpers.HasVolumeSystem) + return new { success = true, message = "Volume system not available.", data = new { volumes = new List() } }; + +#if UNITY_2022_2_OR_NEWER + var allVolumes = UnityEngine.Object.FindObjectsByType(GraphicsHelpers.VolumeType, FindObjectsSortMode.None); +#else + var allVolumes = UnityEngine.Object.FindObjectsOfType(GraphicsHelpers.VolumeType); +#endif + var volumeList = new List(); + + foreach (Component vol in allVolumes) + { + volumeList.Add(BuildVolumeInfo(vol)); + } + + return new + { + success = true, + message = $"Found {volumeList.Count} volume(s).", + data = new { pipeline = GraphicsHelpers.GetPipelineName(), volumes = volumeList } + }; + } + + // --- Helper: Build info object for a single Volume --- + private static object BuildVolumeInfo(object volumeComponent) + { + var comp = volumeComponent as Component; + if (comp == null) return null; + + bool isGlobal = GetPropertyValue(volumeComponent, "isGlobal", true); + float weight = GetPropertyValue(volumeComponent, "weight", 1f); + float priority = GetPropertyValue(volumeComponent, "priority", 0f); + float blendDistance = GetPropertyValue(volumeComponent, "blendDistance", 0f); + + var profile = GetProperty(volumeComponent, "sharedProfile"); + string profileName = profile is UnityEngine.Object profileObj2 ? profileObj2.name : null; + string profilePath = profile is UnityEngine.Object po ? AssetDatabase.GetAssetPath(po) : null; + + var effectsList = new List(); + if (profile != null) + { + var components = GetProperty(profile, "components") as System.Collections.IList; + if (components != null) + { + foreach (var effect in components) + { + if (effect == null) continue; + var effectType = effect.GetType(); + bool active = GetPropertyValue(effect, "active", true); + + // Collect overridden parameters + var overriddenParams = new List(); + foreach (var field in effectType.GetFields(BindingFlags.Public | BindingFlags.Instance)) + { + var fieldValue = field.GetValue(effect); + if (fieldValue == null) continue; + var overrideProp = fieldValue.GetType().GetProperty("overrideState"); + if (overrideProp != null) + { + bool overridden = (bool)overrideProp.GetValue(fieldValue); + if (overridden) + overriddenParams.Add(field.Name); + } + } + + effectsList.Add(new + { + type = effectType.Name, + active, + overridden_params = overriddenParams + }); + } + } + } + + return new + { + name = comp.gameObject.name, + instance_id = comp.gameObject.GetInstanceID(), + is_global = isGlobal, + weight, + priority, + blend_distance = blendDistance, + profile = profileName, + profile_path = profilePath ?? "", + effects = effectsList + }; + } + + // --- Helper: Set a VolumeParameter field value via reflection --- + // VolumeParameter has: overrideState (bool), value (T) + internal static bool SetVolumeParameter(object component, string fieldName, JToken value) + { + if (component == null || string.IsNullOrEmpty(fieldName)) return false; + + var field = component.GetType().GetField(fieldName, + BindingFlags.Public | BindingFlags.Instance); + if (field == null) + { + // Try camelCase conversion from snake_case + string camelCase = StringCaseUtility.ToCamelCase(fieldName); + field = component.GetType().GetField(camelCase, + BindingFlags.Public | BindingFlags.Instance); + } + if (field == null) return false; + + var param = field.GetValue(component); + if (param == null) return false; + + // Set value with type conversion, then enable override on success + var valueProp = param.GetType().GetProperty("value"); + if (valueProp == null) return false; + + try + { + object converted = ConvertToParameterType(value, valueProp.PropertyType); + valueProp.SetValue(param, converted); + + var overrideProp = param.GetType().GetProperty("overrideState"); + if (overrideProp != null) + overrideProp.SetValue(param, true); + + return true; + } + catch (Exception ex) + { + McpLog.Warn($"[VolumeOps] Failed to set '{fieldName}': {ex.Message}"); + return false; + } + } + + // --- Helper: Convert JToken to target parameter type --- + private static object ConvertToParameterType(JToken value, Type targetType) + { + if (value == null || value.Type == JTokenType.Null) return null; + + // Handle Color + if (targetType == typeof(Color)) + { + if (value is JArray arr && arr.Count >= 3) + { + float r = arr[0].Value(); + float g = arr[1].Value(); + float b = arr[2].Value(); + float a = arr.Count >= 4 ? arr[3].Value() : 1f; + return new Color(r, g, b, a); + } + // Try hex string + if (value.Type == JTokenType.String) + { + if (ColorUtility.TryParseHtmlString(value.ToString(), out Color c)) + return c; + } + } + + // Handle Vector2 + if (targetType == typeof(Vector2)) + { + if (value is JArray arr && arr.Count >= 2) + return new Vector2(arr[0].Value(), arr[1].Value()); + } + + // Handle Vector3 + if (targetType == typeof(Vector3)) + { + if (value is JArray arr && arr.Count >= 3) + return new Vector3(arr[0].Value(), arr[1].Value(), arr[2].Value()); + } + + // Handle Vector4 + if (targetType == typeof(Vector4)) + { + if (value is JArray arr && arr.Count >= 4) + return new Vector4(arr[0].Value(), arr[1].Value(), + arr[2].Value(), arr[3].Value()); + } + + // Handle enums + if (targetType.IsEnum) + { + string str = value.ToString(); + if (Enum.TryParse(targetType, str, true, out object enumVal)) + return enumVal; + // Try as int + if (int.TryParse(str, out int intVal)) + return Enum.ToObject(targetType, intVal); + } + + // Handle bool + if (targetType == typeof(bool)) + return ParamCoercion.CoerceBool(value, false); + + // Handle float + if (targetType == typeof(float)) + return ParamCoercion.CoerceFloat(value, 0f); + + // Handle int + if (targetType == typeof(int)) + return ParamCoercion.CoerceInt(value, 0); + + // Handle Texture2D (by asset path) + if (targetType == typeof(Texture2D) || targetType == typeof(Texture)) + { + string path = value.ToString(); + return AssetDatabase.LoadAssetAtPath(path); + } + + // Fallback: try Convert + try + { + return Convert.ChangeType(value.ToObject(), targetType); + } + catch + { + return value.ToObject(); + } + } + + // --- Reflection helpers (with field fallback for Volume.sharedProfile etc.) --- + private static object GetProperty(object obj, string name) + { + if (obj == null) return null; + var prop = obj.GetType().GetProperty(name, + BindingFlags.Public | BindingFlags.Instance); + if (prop != null) return prop.GetValue(obj); + // Fallback: try as a field (e.g., Volume.sharedProfile is a public field, not a property) + var field = obj.GetType().GetField(name, + BindingFlags.Public | BindingFlags.Instance); + return field?.GetValue(obj); + } + + private static T GetPropertyValue(object obj, string name, T defaultValue) + { + var val = GetProperty(obj, name); + if (val is T typed) return typed; + return defaultValue; + } + + private static void SetProperty(object obj, string name, object value) + { + if (obj == null) return; + var prop = obj.GetType().GetProperty(name, + BindingFlags.Public | BindingFlags.Instance); + if (prop != null && prop.CanWrite) + { + prop.SetValue(obj, value); + return; + } + // Fallback: try as a field (e.g., Volume.sharedProfile is a public field, not a property) + var field = obj.GetType().GetField(name, + BindingFlags.Public | BindingFlags.Instance); + field?.SetValue(obj, value); + } + } +} diff --git a/MCPForUnity/Editor/Tools/Graphics/VolumeOps.cs.meta b/MCPForUnity/Editor/Tools/Graphics/VolumeOps.cs.meta new file mode 100644 index 000000000..39b55bf22 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Graphics/VolumeOps.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f9cc9cf9a8664f5bbefa277a02e020fc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs b/MCPForUnity/Editor/Tools/ManageAsset.cs index ed6e6c98e..1410c77c8 100644 --- a/MCPForUnity/Editor/Tools/ManageAsset.cs +++ b/MCPForUnity/Editor/Tools/ManageAsset.cs @@ -1058,7 +1058,7 @@ private static object GetAssetData(string path, bool generatePreview = false) try { rt = RenderTexture.GetTemporary(preview.width, preview.height); - Graphics.Blit(preview, rt); + UnityEngine.Graphics.Blit(preview, rt); RenderTexture.active = rt; readablePreview = new Texture2D(preview.width, preview.height, TextureFormat.RGB24, false); readablePreview.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); diff --git a/MCPForUnity/Editor/Tools/ManageEditor.cs b/MCPForUnity/Editor/Tools/ManageEditor.cs index 27048031a..20b298169 100644 --- a/MCPForUnity/Editor/Tools/ManageEditor.cs +++ b/MCPForUnity/Editor/Tools/ManageEditor.cs @@ -1,5 +1,6 @@ using System; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditorInternal; // Required for tag management @@ -134,9 +135,15 @@ public static object HandleCommand(JObject @params) // // Handle string name or int index // return SetQualityLevel(@params["qualityLevel"]); + // Package Deployment + case "deploy_package": + return DeployPackage(); + case "restore_package": + return RestorePackage(); + default: return new ErrorResponse( - $"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool." + $"Unknown action: '{action}'. Supported actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, deploy_package, restore_package. Use MCP resources for reading editor state, project info, tags, layers, selection, windows, prefab stage, and active tool." ); } } @@ -356,6 +363,49 @@ private static object RemoveLayer(string layerName) } } + // --- Package Deployment Methods --- + + private static object DeployPackage() + { + try + { + var result = MCPServiceLocator.Deployment.DeployFromStoredSource(); + if (!result.Success) + return new ErrorResponse(result.Message); + + return new SuccessResponse(result.Message, new + { + source_path = result.SourcePath, + target_path = result.TargetPath, + backup_path = result.BackupPath + }); + } + catch (Exception e) + { + return new ErrorResponse($"Deploy failed: {e.Message}"); + } + } + + private static object RestorePackage() + { + try + { + var result = MCPServiceLocator.Deployment.RestoreLastBackup(); + if (!result.Success) + return new ErrorResponse(result.Message); + + return new SuccessResponse(result.Message, new + { + target_path = result.TargetPath, + backup_path = result.BackupPath + }); + } + catch (Exception e) + { + return new ErrorResponse($"Restore failed: {e.Message}"); + } + } + // --- Helper Methods --- /// diff --git a/MCPForUnity/Editor/Tools/ManageScene.cs b/MCPForUnity/Editor/Tools/ManageScene.cs index 5cddb654b..b1810df84 100644 --- a/MCPForUnity/Editor/Tools/ManageScene.cs +++ b/MCPForUnity/Editor/Tools/ManageScene.cs @@ -481,7 +481,11 @@ private static object CaptureScreenshot(SceneCommand cmd) targetCamera = Camera.main; if (targetCamera == null) { +#if UNITY_2022_2_OR_NEWER + var allCams = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None); +#else var allCams = UnityEngine.Object.FindObjectsOfType(); +#endif targetCamera = allCams.Length > 0 ? allCams[0] : null; } } @@ -518,7 +522,11 @@ private static object CaptureScreenshot(SceneCommand cmd) // Default path: use ScreenCapture API if available, camera fallback otherwise bool screenCaptureAvailable = ScreenshotUtility.IsScreenCaptureModuleAvailable; +#if UNITY_2022_2_OR_NEWER + bool hasCameraFallback = Camera.main != null || UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None).Length > 0; +#else bool hasCameraFallback = Camera.main != null || UnityEngine.Object.FindObjectsOfType().Length > 0; +#endif #if UNITY_2022_1_OR_NEWER if (!screenCaptureAvailable && !hasCameraFallback) @@ -613,7 +621,11 @@ private static object CaptureSurroundBatch(SceneCommand cmd) // Default: calculate combined bounds of all renderers in the scene Bounds bounds = new Bounds(Vector3.zero, Vector3.zero); bool hasBounds = false; +#if UNITY_2022_2_OR_NEWER + var renderers = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None); +#else var renderers = UnityEngine.Object.FindObjectsOfType(); +#endif foreach (var r in renderers) { if (r == null || !r.gameObject.activeInHierarchy) continue; @@ -754,7 +766,11 @@ private static object CaptureOrbitBatch(SceneCommand cmd) // Default: calculate combined bounds of all renderers in the scene Bounds bounds = new Bounds(Vector3.zero, Vector3.zero); bool hasBounds = false; +#if UNITY_2022_2_OR_NEWER + var renderers = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None); +#else var renderers = UnityEngine.Object.FindObjectsOfType(); +#endif foreach (var r in renderers) { if (r == null || !r.gameObject.activeInHierarchy) continue; @@ -996,13 +1012,17 @@ private static Camera ResolveCamera(string cameraRef) // Try instance ID if (int.TryParse(cameraRef, out int id)) { - var obj = EditorUtility.InstanceIDToObject(id); + var obj = GameObjectLookup.ResolveInstanceID(id); if (obj is Camera cam) return cam; if (obj is GameObject go) return go.GetComponent(); } // Search all cameras by name or path +#if UNITY_2022_2_OR_NEWER + var allCams = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None); +#else var allCams = UnityEngine.Object.FindObjectsOfType(); +#endif foreach (var cam in allCams) { if (cam.name == cameraRef) return cam; @@ -1077,7 +1097,11 @@ private static object FrameSceneView(SceneCommand cmd) // Frame entire scene by computing combined bounds of all renderers Bounds allBounds = new Bounds(Vector3.zero, Vector3.zero); bool hasAny = false; +#if UNITY_2022_2_OR_NEWER + foreach (var r in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) +#else foreach (var r in UnityEngine.Object.FindObjectsOfType()) +#endif { if (r == null || !r.gameObject.activeInHierarchy) continue; if (!hasAny) { allBounds = r.bounds; hasAny = true; } @@ -1368,7 +1392,7 @@ private static GameObject ResolveGameObject(JToken targetToken, Scene activeScen { if (int.TryParse(targetToken.ToString(), out int id)) { - var obj = EditorUtility.InstanceIDToObject(id); + var obj = GameObjectLookup.ResolveInstanceID(id); if (obj is GameObject go) return go; if (obj is Component c) return c.gameObject; } diff --git a/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs b/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs index f5e8671ed..1f9651447 100644 --- a/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs @@ -319,7 +319,7 @@ private VisualElement CreateToolRow(ToolMetadata tool) row.Add(parametersLabel); } - if (IsManageSceneTool(tool) || IsManageCameraTool(tool)) + if (IsManageCameraTool(tool)) { row.Add(CreateManageSceneActions()); } @@ -596,7 +596,7 @@ private VisualElement CreateManageSceneActions() }; screenshotButton.AddToClassList("tool-action-button"); screenshotButton.style.marginTop = 4; - screenshotButton.tooltip = "Capture a screenshot to Assets/Screenshots via manage_scene."; + screenshotButton.tooltip = "Capture a screenshot to Assets/Screenshots via manage_camera."; var multiviewButton = new Button(OnManageSceneMultiviewClicked) { diff --git a/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs b/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs index ca34e8e65..6c32cd62b 100644 --- a/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs +++ b/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs @@ -91,8 +91,11 @@ private static Camera FindAvailableCamera() try { - // Use FindObjectsOfType for Unity 2021 compatibility. +#if UNITY_2022_2_OR_NEWER + var cams = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None); +#else var cams = UnityEngine.Object.FindObjectsOfType(); +#endif return cams.FirstOrDefault(); } catch diff --git a/MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs b/MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs index f7ecc9837..b593fb98e 100644 --- a/MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs +++ b/MCPForUnity/Runtime/Serialization/UnityTypeConverters.cs @@ -380,7 +380,11 @@ public override UnityEngine.Object ReadJson(JsonReader reader, Type objectType, if (jo.TryGetValue("instanceID", out JToken idToken) && idToken.Type == JTokenType.Integer) { int instanceId = idToken.ToObject(); +#if UNITY_6000_3_OR_NEWER + UnityEngine.Object obj = UnityEditor.EditorUtility.EntityIdToObject(instanceId); +#else UnityEngine.Object obj = UnityEditor.EditorUtility.InstanceIDToObject(instanceId); +#endif if (obj != null) { // Direct type match diff --git a/README.md b/README.md index 47cda5640..5c256d57b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@
Recent Updates +* **v9.5.3 (beta)** — New `manage_graphics` tool (33 actions): volume/post-processing, light baking, rendering stats, pipeline settings, URP renderer features. 3 new resources: `volumes`, `rendering_stats`, `renderer_features`. * **v9.5.2 (beta)** — New `manage_camera` tool with Cinemachine support (presets, priority, noise, blending, extensions), `cameras` resource, priority persistence fix via SerializedProperty. * **v9.4.8** — New editor UI, real-time tool toggling via `manage_tools`, skill sync window, multi-view screenshot, one-click Roslyn installer, Qwen Code & Gemini CLI clients, ProBuilder mesh editing via `manage_probuilder`. * **v9.4.7** — Per-call Unity instance routing, macOS pyenv PATH fix, domain reload resilience for script tools. @@ -92,10 +93,10 @@ openupm add com.coplaydev.unity-mcp * **Extensible** — Works with various MCP Clients ### Available Tools -`apply_text_edits` • `batch_execute` • `create_script` • `debug_request_context` • `delete_script` • `execute_custom_tool` • `execute_menu_item` • `find_gameobjects` • `find_in_file` • `get_sha` • `get_test_job` • `manage_animation` • `manage_asset` • `manage_camera` • `manage_components` • `manage_editor` • `manage_gameobject` • `manage_material` • `manage_prefabs` • `manage_probuilder` • `manage_scene` • `manage_script` • `manage_script_capabilities` • `manage_scriptable_object` • `manage_shader` • `manage_texture` • `manage_tools` • `manage_ui` • `manage_vfx` • `read_console` • `refresh_unity` • `run_tests` • `script_apply_edits` • `set_active_instance` • `validate_script` +`apply_text_edits` • `batch_execute` • `create_script` • `debug_request_context` • `delete_script` • `execute_custom_tool` • `execute_menu_item` • `find_gameobjects` • `find_in_file` • `get_sha` • `get_test_job` • `manage_animation` • `manage_asset` • `manage_camera` • `manage_components` • `manage_graphics` • `manage_editor` • `manage_gameobject` • `manage_material` • `manage_prefabs` • `manage_probuilder` • `manage_scene` • `manage_script` • `manage_script_capabilities` • `manage_scriptable_object` • `manage_shader` • `manage_texture` • `manage_tools` • `manage_ui` • `manage_vfx` • `read_console` • `refresh_unity` • `run_tests` • `script_apply_edits` • `set_active_instance` • `validate_script` ### Available Resources -`cameras` • `custom_tools` • `editor_active_tool` • `editor_prefab_stage` • `editor_selection` • `editor_state` • `editor_windows` • `gameobject` • `gameobject_api` • `gameobject_component` • `gameobject_components` • `get_tests` • `get_tests_for_mode` • `menu_items` • `prefab_api` • `prefab_hierarchy` • `prefab_info` • `project_info` • `project_layers` • `project_tags` • `tool_groups` • `unity_instances` +`cameras` • `custom_tools` • `renderer_features` • `rendering_stats` • `volumes` • `editor_active_tool` • `editor_prefab_stage` • `editor_selection` • `editor_state` • `editor_windows` • `gameobject` • `gameobject_api` • `gameobject_component` • `gameobject_components` • `get_tests` • `get_tests_for_mode` • `menu_items` • `prefab_api` • `prefab_hierarchy` • `prefab_info` • `project_info` • `project_layers` • `project_tags` • `tool_groups` • `unity_instances` **Performance Tip:** Use `batch_execute` for multiple operations — it's 10-100x faster than individual calls!
diff --git a/Server/src/cli/CLI_USAGE_GUIDE.md b/Server/src/cli/CLI_USAGE_GUIDE.md index 576196477..dfe0e3666 100644 --- a/Server/src/cli/CLI_USAGE_GUIDE.md +++ b/Server/src/cli/CLI_USAGE_GUIDE.md @@ -101,7 +101,7 @@ unity-mcp gameobject create "MyCube" --primitive Cube unity-mcp gameobject modify "MyCube" --position 0 2 0 # Take a screenshot -unity-mcp scene screenshot +unity-mcp camera screenshot # Enter play mode unity-mcp editor play @@ -367,9 +367,9 @@ unity-mcp scene load "Assets/Scenes/MyScene.unity" # Save current scene unity-mcp scene save -# Take screenshot -unity-mcp scene screenshot -unity-mcp scene screenshot --filename "my_screenshot" --supersize 2 +# Take screenshot (use camera command) +unity-mcp camera screenshot +unity-mcp camera screenshot --file-name "my_screenshot" --super-size 2 ``` ### GameObject Commands diff --git a/Server/src/cli/commands/editor.py b/Server/src/cli/commands/editor.py index 30c462c04..f55c02947 100644 --- a/Server/src/cli/commands/editor.py +++ b/Server/src/cli/commands/editor.py @@ -208,6 +208,44 @@ def set_tool(tool_name: str): print_success(f"Set active tool: {tool_name}") +@editor.command("deploy") +@handle_unity_errors +def deploy(): + """Deploy MCPForUnity package from configured source. + + Copies the configured MCPForUnity source folder into the project's + installed package location. The source path must be set in the + MCP for Unity Advanced Settings first. Triggers recompilation. + + \b + Examples: + unity-mcp editor deploy + """ + config = get_config() + result = run_command("manage_editor", {"action": "deploy_package"}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Package deployed") + + +@editor.command("restore") +@handle_unity_errors +def restore(): + """Restore MCPForUnity package from last backup. + + Reverts the last deployment by restoring from backup. + + \b + Examples: + unity-mcp editor restore + """ + config = get_config() + result = run_command("manage_editor", {"action": "restore_package"}, config) + click.echo(format_output(result, config.format)) + if result.get("success"): + print_success("Package restored from backup") + + @editor.command("menu") @click.argument("menu_path") @handle_unity_errors diff --git a/Server/src/cli/commands/graphics.py b/Server/src/cli/commands/graphics.py new file mode 100644 index 000000000..cbed3c2dc --- /dev/null +++ b/Server/src/cli/commands/graphics.py @@ -0,0 +1,545 @@ +import click +from cli.utils.connection import handle_unity_errors, run_command, get_config +from cli.utils.output import format_output + + +@click.group("graphics") +def graphics(): + """Manage rendering graphics: volumes, effects, and pipeline settings.""" + pass + + +def _coerce_cli_value(val: str): + """Convert a CLI string value to bool/float/int/str.""" + if val.lower() in ("true", "false"): + return val.lower() == "true" + try: + return float(val) if "." in val else int(val) + except ValueError: + return val + + +@graphics.command("ping") +@handle_unity_errors +def ping(): + """Check graphics system status.""" + config = get_config() + params = {"action": "ping"} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("volume-create") +@click.option("--name", "-n", default=None, help="Name for the Volume GameObject.") +@click.option("--global/--local", "is_global", default=True, help="Global or local Volume.") +@click.option("--weight", "-w", type=float, default=None, help="Volume weight (0-1).") +@click.option("--priority", "-p", type=float, default=None, help="Volume priority.") +@click.option("--profile-path", default=None, help="Existing VolumeProfile asset path to assign.") +@handle_unity_errors +def volume_create(name, is_global, weight, priority, profile_path): + """Create a Volume GameObject with a profile.""" + config = get_config() + params = {"action": "volume_create", "is_global": is_global} + if name: + params["name"] = name + if weight is not None: + params["weight"] = weight + if priority is not None: + params["priority"] = priority + if profile_path: + params["profile_path"] = profile_path + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("volume-add-effect") +@click.option("--target", "-t", required=True, help="Volume name or instance ID.") +@click.option("--effect", "-e", required=True, help="Effect type (e.g., Bloom, Vignette).") +@handle_unity_errors +def volume_add_effect(target, effect): + """Add an effect override to a Volume.""" + config = get_config() + params = {"action": "volume_add_effect", "target": target, "effect": effect} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("volume-set-effect") +@click.option("--target", "-t", required=True, help="Volume name or instance ID.") +@click.option("--effect", "-e", required=True, help="Effect type (e.g., Bloom).") +@click.option("--param", "-p", multiple=True, type=(str, str), help="Parameter key-value pair.") +@handle_unity_errors +def volume_set_effect(target, effect, param): + """Set parameters on a Volume effect.""" + config = get_config() + parameters = {k: v for k, v in param} + params = { + "action": "volume_set_effect", + "target": target, + "effect": effect, + "parameters": parameters, + } + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("volume-remove-effect") +@click.option("--target", "-t", required=True, help="Volume name or instance ID.") +@click.option("--effect", "-e", required=True, help="Effect type to remove.") +@handle_unity_errors +def volume_remove_effect(target, effect): + """Remove an effect from a Volume.""" + config = get_config() + params = {"action": "volume_remove_effect", "target": target, "effect": effect} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("volume-info") +@click.option("--target", "-t", required=True, help="Volume name or instance ID.") +@handle_unity_errors +def volume_info(target): + """Get all effects and parameters on a Volume.""" + config = get_config() + params = {"action": "volume_get_info", "target": target} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("volume-set-properties") +@click.option("--target", "-t", required=True, help="Volume name or instance ID.") +@click.option("--weight", "-w", type=float, default=None, help="Volume weight (0-1).") +@click.option("--priority", "-p", type=float, default=None, help="Volume priority.") +@click.option("--global/--local", "is_global", default=None, help="Global or local Volume.") +@handle_unity_errors +def volume_set_properties(target, weight, priority, is_global): + """Set Volume properties (weight, priority, is_global).""" + config = get_config() + params = {"action": "volume_set_properties", "target": target} + if weight is not None: + params["weight"] = weight + if priority is not None: + params["priority"] = priority + if is_global is not None: + params["is_global"] = is_global + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("volume-list-effects") +@handle_unity_errors +def volume_list_effects(): + """List available VolumeComponent effect types.""" + config = get_config() + params = {"action": "volume_list_effects"} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("volume-create-profile") +@click.option("--path", "-p", required=True, help="Asset path for the VolumeProfile (e.g., Assets/Profiles/MyProfile.asset).") +@click.option("--name", "-n", default=None, help="Display name for the profile.") +@handle_unity_errors +def volume_create_profile(path, name): + """Create a standalone VolumeProfile asset.""" + config = get_config() + params = {"action": "volume_create_profile", "path": path} + if name: + params["name"] = name + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("pipeline-info") +@handle_unity_errors +def pipeline_info(): + """Get active render pipeline, quality level, and settings.""" + config = get_config() + params = {"action": "pipeline_get_info"} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("pipeline-set-quality") +@click.option("--level", "-l", required=True, help="Quality level name or index.") +@handle_unity_errors +def pipeline_set_quality(level): + """Switch quality level.""" + config = get_config() + params = {"action": "pipeline_set_quality", "level": level} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("pipeline-settings") +@handle_unity_errors +def pipeline_settings(): + """Get detailed pipeline settings.""" + config = get_config() + result = run_command("manage_graphics", {"action": "pipeline_get_settings"}, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("pipeline-set-settings") +@click.option("--setting", "-s", multiple=True, type=(str, str), required=True, + help="Setting key-value pair (e.g., -s renderScale 0.5 -s supportsHDR true).") +@handle_unity_errors +def pipeline_set_settings(setting): + """Set pipeline asset settings.""" + config = get_config() + settings = {key: _coerce_cli_value(val) for key, val in setting} + params = {"action": "pipeline_set_settings", "settings": settings} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +# --- Bake commands --- + +@graphics.command("bake-start") +@click.option("--sync", is_flag=True, help="Synchronous bake (blocks until done).") +@handle_unity_errors +def bake_start(sync): + """Start lightmap bake.""" + config = get_config() + params = {"action": "bake_start", "async": not sync} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("bake-cancel") +@handle_unity_errors +def bake_cancel(): + """Cancel running bake.""" + config = get_config() + result = run_command("manage_graphics", {"action": "bake_cancel"}, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("bake-status") +@handle_unity_errors +def bake_status(): + """Get bake progress/status.""" + config = get_config() + result = run_command("manage_graphics", {"action": "bake_status"}, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("bake-clear") +@handle_unity_errors +def bake_clear(): + """Clear all baked lighting data.""" + config = get_config() + result = run_command("manage_graphics", {"action": "bake_clear"}, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("bake-settings") +@handle_unity_errors +def bake_settings(): + """Get current lighting/bake settings.""" + config = get_config() + result = run_command("manage_graphics", {"action": "bake_get_settings"}, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("bake-reflection-probe") +@click.option("--target", "-t", required=True, help="Name or instance ID of GameObject with ReflectionProbe.") +@handle_unity_errors +def bake_reflection_probe(target): + """Bake a specific reflection probe.""" + config = get_config() + params = {"action": "bake_reflection_probe", "target": target} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("bake-set-settings") +@click.option("--setting", "-s", multiple=True, type=(str, str), required=True, + help="Lighting setting key-value pair.") +@handle_unity_errors +def bake_set_settings(setting): + """Set lighting/bake settings.""" + config = get_config() + settings = {key: _coerce_cli_value(val) for key, val in setting} + params = {"action": "bake_set_settings", "settings": settings} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("bake-create-probes") +@click.option("--name", "-n", default=None, help="Name for the probe group.") +@click.option("--spacing", "-s", type=float, default=None, help="Grid spacing.") +@handle_unity_errors +def bake_create_probes(name, spacing): + """Create a light probe group with grid layout.""" + config = get_config() + params = {"action": "bake_create_light_probe_group"} + if name: + params["name"] = name + if spacing is not None: + params["spacing"] = spacing + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("bake-create-reflection") +@click.option("--name", "-n", default=None, help="Name for the reflection probe.") +@click.option("--resolution", "-r", type=int, default=None, help="Probe resolution.") +@click.option("--mode", "-m", default=None, help="Baked/Realtime/Custom.") +@handle_unity_errors +def bake_create_reflection(name, resolution, mode): + """Create a reflection probe.""" + config = get_config() + params = {"action": "bake_create_reflection_probe"} + if name: + params["name"] = name + if resolution is not None: + params["resolution"] = resolution + if mode: + params["mode"] = mode + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +# --- Stats commands --- + +@graphics.command("stats") +@handle_unity_errors +def stats(): + """Get rendering performance stats.""" + config = get_config() + result = run_command("manage_graphics", {"action": "stats_get"}, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("stats-memory") +@handle_unity_errors +def stats_memory(): + """Get memory allocation stats.""" + config = get_config() + result = run_command("manage_graphics", {"action": "stats_get_memory"}, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("stats-debug-mode") +@click.option("--mode", "-m", required=True, help="Debug mode (Overdraw, Wireframe, Mipmaps, etc.).") +@handle_unity_errors +def stats_debug_mode(mode): + """Set Scene view debug visualization mode.""" + config = get_config() + params = {"action": "stats_set_scene_debug", "mode": mode} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +# --- Feature commands --- + +@graphics.command("feature-list") +@handle_unity_errors +def feature_list(): + """List URP renderer features.""" + config = get_config() + result = run_command("manage_graphics", {"action": "feature_list"}, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("feature-add") +@click.option("--type", "-t", "feature_type", required=True, help="Feature type (e.g., FullScreenPassRendererFeature).") +@click.option("--name", "-n", default=None, help="Display name.") +@handle_unity_errors +def feature_add(feature_type, name): + """Add a renderer feature.""" + config = get_config() + params = {"action": "feature_add", "type": feature_type} + if name: + params["name"] = name + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("feature-remove") +@click.option("--index", "-i", type=int, default=None, help="Feature index.") +@click.option("--name", "-n", default=None, help="Feature name.") +@handle_unity_errors +def feature_remove(index, name): + """Remove a renderer feature.""" + config = get_config() + params = {"action": "feature_remove"} + if index is not None: + params["index"] = index + if name: + params["name"] = name + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("feature-configure") +@click.option("--index", "-i", type=int, default=None, help="Feature index.") +@click.option("--name", "-n", default=None, help="Feature name.") +@click.option("--prop", "-p", multiple=True, type=(str, str), required=True, + help="Property key-value pair.") +@handle_unity_errors +def feature_configure(index, name, prop): + """Configure properties on a renderer feature.""" + config = get_config() + properties = {key: _coerce_cli_value(val) for key, val in prop} + params = {"action": "feature_configure", "properties": properties} + if index is not None: + params["index"] = index + if name: + params["name"] = name + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("feature-reorder") +@click.option("--order", "-o", required=True, help="Comma-separated list of indices (e.g., '2,0,1').") +@handle_unity_errors +def feature_reorder(order): + """Reorder renderer features.""" + config = get_config() + order_list = [int(x.strip()) for x in order.split(",")] + params = {"action": "feature_reorder", "order": order_list} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("feature-toggle") +@click.option("--index", "-i", type=int, default=None, help="Feature index.") +@click.option("--name", "-n", default=None, help="Feature name.") +@click.option("--active/--inactive", default=True, help="Enable or disable.") +@handle_unity_errors +def feature_toggle(index, name, active): + """Enable/disable a renderer feature.""" + config = get_config() + params = {"action": "feature_toggle", "active": active} + if index is not None: + params["index"] = index + if name: + params["name"] = name + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +# --- Skybox / Environment commands --- + +@graphics.command("skybox-info") +@handle_unity_errors +def skybox_info(): + """Get all environment settings (skybox, ambient, fog, reflection, sun).""" + config = get_config() + result = run_command("manage_graphics", {"action": "skybox_get"}, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("skybox-set-material") +@click.option("--material", "-m", required=True, help="Asset path to skybox material.") +@handle_unity_errors +def skybox_set_material(material): + """Set the skybox material by asset path.""" + config = get_config() + params = {"action": "skybox_set_material", "material": material} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("skybox-set-properties") +@click.option("--prop", "-p", multiple=True, type=(str, str), required=True, + help="Material property key-value pair (e.g., -p _Exposure 1.3).") +@handle_unity_errors +def skybox_set_properties(prop): + """Set properties on the current skybox material.""" + config = get_config() + properties = {key: _coerce_cli_value(val) for key, val in prop} + params = {"action": "skybox_set_properties", "properties": properties} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("skybox-set-ambient") +@click.option("--mode", "-m", default=None, help="Ambient mode: Skybox, Trilight, Flat, Custom.") +@click.option("--intensity", "-i", type=float, default=None, help="Ambient intensity.") +@click.option("--color", "-c", default=None, help="Sky/ambient color as 'r,g,b[,a]'.") +@click.option("--equator-color", default=None, help="Equator color as 'r,g,b[,a]' (Trilight mode).") +@click.option("--ground-color", default=None, help="Ground color as 'r,g,b[,a]' (Trilight mode).") +@handle_unity_errors +def skybox_set_ambient(mode, intensity, color, equator_color, ground_color): + """Set ambient lighting mode and colors.""" + config = get_config() + params = {"action": "skybox_set_ambient"} + if mode: + params["ambient_mode"] = mode + if intensity is not None: + params["intensity"] = intensity + if color: + params["color"] = [float(x) for x in color.split(",")] + if equator_color: + params["equator_color"] = [float(x) for x in equator_color.split(",")] + if ground_color: + params["ground_color"] = [float(x) for x in ground_color.split(",")] + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("skybox-set-fog") +@click.option("--enable/--disable", "fog_enabled", default=None, help="Enable or disable fog.") +@click.option("--mode", "-m", default=None, help="Fog mode: Linear, Exponential, ExponentialSquared.") +@click.option("--color", "-c", default=None, help="Fog color as 'r,g,b[,a]'.") +@click.option("--density", "-d", type=float, default=None, help="Fog density.") +@click.option("--start", type=float, default=None, help="Fog start distance (Linear).") +@click.option("--end", type=float, default=None, help="Fog end distance (Linear).") +@handle_unity_errors +def skybox_set_fog(fog_enabled, mode, color, density, start, end): + """Enable and configure fog.""" + config = get_config() + params = {"action": "skybox_set_fog"} + if fog_enabled is not None: + params["fog_enabled"] = fog_enabled + if mode: + params["fog_mode"] = mode + if color: + params["fog_color"] = [float(x) for x in color.split(",")] + if density is not None: + params["fog_density"] = density + if start is not None: + params["fog_start"] = start + if end is not None: + params["fog_end"] = end + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("skybox-set-reflection") +@click.option("--intensity", "-i", type=float, default=None, help="Reflection intensity.") +@click.option("--bounces", "-b", type=int, default=None, help="Reflection bounces.") +@click.option("--mode", "-m", default=None, help="Reflection mode: Skybox, Custom.") +@click.option("--resolution", "-r", type=int, default=None, help="Default reflection resolution.") +@click.option("--cubemap", default=None, help="Custom cubemap asset path.") +@handle_unity_errors +def skybox_set_reflection(intensity, bounces, mode, resolution, cubemap): + """Configure environment reflection settings.""" + config = get_config() + params = {"action": "skybox_set_reflection"} + if intensity is not None: + params["intensity"] = intensity + if bounces is not None: + params["bounces"] = bounces + if mode: + params["reflection_mode"] = mode + if resolution is not None: + params["resolution"] = resolution + if cubemap: + params["path"] = cubemap + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) + + +@graphics.command("skybox-set-sun") +@click.option("--target", "-t", required=True, help="Light GameObject name or instance ID.") +@handle_unity_errors +def skybox_set_sun(target): + """Set the sun source light for the environment.""" + config = get_config() + params = {"action": "skybox_set_sun", "target": target} + result = run_command("manage_graphics", params, config) + click.echo(format_output(result, config.format)) diff --git a/Server/src/cli/commands/scene.py b/Server/src/cli/commands/scene.py index 6543879f0..ec4a4e58f 100644 --- a/Server/src/cli/commands/scene.py +++ b/Server/src/cli/commands/scene.py @@ -1,15 +1,12 @@ """Scene CLI commands.""" -import base64 -import os import sys -from datetime import datetime import click from typing import Optional, Any from cli.utils.config import get_config -from cli.utils.output import format_output, print_error, print_success, print_info +from cli.utils.output import format_output, print_error, print_success from cli.utils.connection import run_command, handle_unity_errors @@ -199,220 +196,3 @@ def build_settings(): click.echo(format_output(result, config.format)) -@scene.command("screenshot") -@click.option( - "--filename", "-f", - default=None, - help="Output filename (default: timestamp)." -) -@click.option( - "--supersize", "-s", - default=1, - type=int, - help="Supersize multiplier (1-4)." -) -@click.option( - "--camera", "-c", - default=None, - help="Camera to capture from (name, path, or instance ID). Defaults to Camera.main." -) -@click.option( - "--include-image", is_flag=True, - help="Return screenshot as inline base64 PNG in the response." -) -@click.option( - "--max-resolution", "-r", - default=None, - type=int, - help="Max resolution (longest edge) for inline image. Default 640." -) -@click.option( - "--batch", "-b", - default=None, - help="Batch capture mode: 'surround' for 6 angles, 'orbit' for configurable grid." -) -@click.option( - "--look-at", - default=None, - help="Target to aim at (GO name/path/ID or 'x,y,z' position)." -) -@click.option( - "--view-position", - default=None, - help="Camera position as 'x,y,z'." -) -@click.option( - "--view-rotation", - default=None, - help="Camera euler rotation as 'x,y,z'." -) -@click.option( - "--orbit-angles", - default=None, - type=int, - help="Number of azimuth samples for batch='orbit' (default 8)." -) -@click.option( - "--orbit-elevations", - default=None, - help="Elevation angles in degrees as JSON array, e.g. '[0,30,-15]'." -) -@click.option( - "--orbit-distance", - default=None, - type=float, - help="Camera distance from target for batch='orbit'." -) -@click.option( - "--orbit-fov", - default=None, - type=float, - help="Camera FOV in degrees for batch='orbit' (default 60)." -) -@click.option( - "--output-dir", "-o", - default=None, - help="Directory to save batch screenshots to (default: Unity project's Assets/Screenshots)." -) -@handle_unity_errors -def screenshot(filename: Optional[str], supersize: int, camera: Optional[str], - include_image: bool, max_resolution: Optional[int], - batch: Optional[str], look_at: Optional[str], - view_position: Optional[str], view_rotation: Optional[str], - orbit_angles: Optional[int], orbit_elevations: Optional[str], - orbit_distance: Optional[float], orbit_fov: Optional[float], - output_dir: Optional[str]): - """Capture a screenshot of the scene. - - \b - Examples: - unity-mcp scene screenshot - unity-mcp scene screenshot --filename "level_preview" - unity-mcp scene screenshot --supersize 2 - unity-mcp scene screenshot --camera "SecondCamera" --include-image - unity-mcp scene screenshot --include-image --max-resolution 512 - unity-mcp scene screenshot --batch surround --max-resolution 256 - unity-mcp scene screenshot --look-at "Player" --max-resolution 512 - unity-mcp scene screenshot --view-position "0,10,-10" --look-at "0,0,0" - """ - config = get_config() - - params: dict[str, Any] = {"action": "screenshot"} - if filename: - params["fileName"] = filename - if supersize > 1: - params["superSize"] = supersize - if camera: - params["camera"] = camera - if include_image: - params["includeImage"] = True - if max_resolution: - params["maxResolution"] = max_resolution - if batch: - params["batch"] = batch - if look_at: - # Try parsing as x,y,z coordinates - parts = look_at.split(",") - if len(parts) == 3: - try: - params["lookAt"] = [float(p.strip()) for p in parts] - except ValueError: - params["lookAt"] = look_at - else: - params["lookAt"] = look_at - if view_position: - parts = view_position.split(",") - if len(parts) == 3: - params["viewPosition"] = [float(p.strip()) for p in parts] - if view_rotation: - parts = view_rotation.split(",") - if len(parts) == 3: - params["viewRotation"] = [float(p.strip()) for p in parts] - if orbit_angles: - params["orbitAngles"] = orbit_angles - if orbit_elevations: - import json - try: - params["orbitElevations"] = json.loads(orbit_elevations) - except (json.JSONDecodeError, ValueError): - print_error(f"Invalid orbit-elevations JSON: {orbit_elevations}") - sys.exit(1) - if orbit_distance: - params["orbitDistance"] = orbit_distance - if orbit_fov: - params["orbitFov"] = orbit_fov - - result = run_command("manage_scene", params, config) - - # Unwrap the response: {"status":"success","result":{"success":true,"data":{...}}} - inner = result.get("result", result) # fallback to result if no nesting - is_success = (result.get("status") == "success" - or inner.get("success", False) - or result.get("success", False)) - data = inner.get("data", inner) if isinstance(inner, dict) else {} - - if batch and is_success: - composite_b64 = data.get("imageBase64") - shots = data.get("shots", []) - - if composite_b64: - # Determine output directory - if not output_dir: - output_dir = data.get("screenshotsFolder") - if not output_dir: - output_dir = os.getcwd() - output_dir = os.path.abspath(output_dir) - os.makedirs(output_dir, exist_ok=True) - - mode_label = batch # "orbit" or "surround" - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - fname = filename or f"{mode_label}_contact_{timestamp}.png" - if not fname.lower().endswith(".png"): - fname += ".png" - file_path = os.path.join(output_dir, fname) - try: - with open(file_path, "wb") as f: - f.write(base64.b64decode(composite_b64)) - w = data.get("imageWidth", "?") - h = data.get("imageHeight", "?") - print_success(f"Contact sheet ({w}x{h}, {len(shots)} shots) saved to {file_path}") - except Exception as e: - print_error(f"Failed to save {file_path}: {e}") - else: - print_success(f"Batch completed ({len(shots)} shots, no composite image returned)") - - # Print metadata - meta = {k: v for k, v in data.items() - if k not in ("imageBase64", "screenshotsFolder", "shots")} - if meta: - print_info(f" center={meta.get('sceneCenter')}, radius={meta.get('orbitRadius', meta.get('sceneRadius'))}, " - f"fov={meta.get('orbitFov')}, size={meta.get('imageWidth')}x{meta.get('imageHeight')}") - else: - # Handle positioned single-shot (returns base64 inline, no disk save from Unity) - if is_success: - b64 = data.get("imageBase64") - if b64 and not data.get("path"): - # Positioned screenshot — save to disk from base64 - if not output_dir: - output_dir = data.get("screenshotsFolder") - if not output_dir: - output_dir = os.getcwd() - output_dir = os.path.abspath(output_dir) - os.makedirs(output_dir, exist_ok=True) - - fname = filename or f"screenshot_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - if not fname.lower().endswith(".png"): - fname += ".png" - file_path = os.path.join(output_dir, fname) - try: - with open(file_path, "wb") as f: - f.write(base64.b64decode(b64)) - print_success(f"Screenshot saved to {file_path}") - except Exception as e: - print_error(f"Failed to save {file_path}: {e}") - else: - # Standard screenshot (already saved by Unity to Assets/Screenshots/) - click.echo(format_output(result, config.format)) - print_success("Screenshot captured") - else: - click.echo(format_output(result, config.format)) diff --git a/Server/src/cli/main.py b/Server/src/cli/main.py index b9568e1ba..e21d30594 100644 --- a/Server/src/cli/main.py +++ b/Server/src/cli/main.py @@ -268,6 +268,7 @@ def register_optional_command(module_name: str, command_name: str) -> None: ("cli.commands.texture", "texture"), ("cli.commands.probuilder", "probuilder"), ("cli.commands.camera", "camera"), + ("cli.commands.graphics", "graphics"), ] for module_name, command_name in optional_commands: diff --git a/Server/src/services/resources/renderer_features.py b/Server/src/services/resources/renderer_features.py new file mode 100644 index 000000000..14c232315 --- /dev/null +++ b/Server/src/services/resources/renderer_features.py @@ -0,0 +1,21 @@ +from fastmcp import Context + +from models import MCPResponse +from models.unity_response import parse_resource_response +from services.registry import mcp_for_unity_resource +from services.tools import get_unity_instance_from_context +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry + + +@mcp_for_unity_resource( + uri="mcpforunity://pipeline/renderer-features", + name="renderer_features", + description="Lists all URP renderer features on the active renderer with type, name, and active state.", +) +async def get_renderer_features(ctx: Context) -> MCPResponse: + unity_instance = await get_unity_instance_from_context(ctx) + response = await send_with_unity_instance( + async_send_command_with_retry, unity_instance, "get_renderer_features", {} + ) + return parse_resource_response(response, MCPResponse) diff --git a/Server/src/services/resources/rendering_stats.py b/Server/src/services/resources/rendering_stats.py new file mode 100644 index 000000000..64632259e --- /dev/null +++ b/Server/src/services/resources/rendering_stats.py @@ -0,0 +1,21 @@ +from fastmcp import Context + +from models import MCPResponse +from models.unity_response import parse_resource_response +from services.registry import mcp_for_unity_resource +from services.tools import get_unity_instance_from_context +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry + + +@mcp_for_unity_resource( + uri="mcpforunity://rendering/stats", + name="rendering_stats", + description="Snapshot of rendering performance statistics (draw calls, batches, triangles, frame time, etc.).", +) +async def get_rendering_stats(ctx: Context) -> MCPResponse: + unity_instance = await get_unity_instance_from_context(ctx) + response = await send_with_unity_instance( + async_send_command_with_retry, unity_instance, "get_rendering_stats", {} + ) + return parse_resource_response(response, MCPResponse) diff --git a/Server/src/services/resources/volumes.py b/Server/src/services/resources/volumes.py new file mode 100644 index 000000000..e194371a4 --- /dev/null +++ b/Server/src/services/resources/volumes.py @@ -0,0 +1,20 @@ +from fastmcp import Context +from models import MCPResponse +from models.unity_response import parse_resource_response +from services.registry import mcp_for_unity_resource +from services.tools import get_unity_instance_from_context +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry + + +@mcp_for_unity_resource( + uri="mcpforunity://scene/volumes", + name="volumes", + description="Lists all Volume components in the active scene with their profiles, effects, and settings.", +) +async def get_volumes(ctx: Context) -> MCPResponse: + unity_instance = await get_unity_instance_from_context(ctx) + response = await send_with_unity_instance( + async_send_command_with_retry, unity_instance, "get_volumes", {} + ) + return parse_resource_response(response, MCPResponse) diff --git a/Server/src/services/tools/manage_editor.py b/Server/src/services/tools/manage_editor.py index 845d57b56..ead5107d8 100644 --- a/Server/src/services/tools/manage_editor.py +++ b/Server/src/services/tools/manage_editor.py @@ -12,14 +12,14 @@ @mcp_for_unity_tool( - description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer.", + description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted. Read-only actions: telemetry_status, telemetry_ping. Modifying actions: play, pause, stop, set_active_tool, add_tag, remove_tag, add_layer, remove_layer, deploy_package, restore_package. deploy_package copies the configured MCPForUnity source folder into the project's installed package location (triggers recompile, no confirmation dialog). restore_package reverts to the pre-deployment backup.", annotations=ToolAnnotations( title="Manage Editor", ), ) async def manage_editor( ctx: Context, - action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer"], "Get and update the Unity Editor state."], + action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "set_active_tool", "add_tag", "remove_tag", "add_layer", "remove_layer", "deploy_package", "restore_package"], "Get and update the Unity Editor state. deploy_package copies the configured MCPForUnity source into the project's package location (triggers recompile). restore_package reverts the last deployment from backup."], wait_for_completion: Annotated[bool | str, "Optional. If True, waits for certain actions (accepts true/false or 'true'/'false')"] | None = None, tool_name: Annotated[str, diff --git a/Server/src/services/tools/manage_graphics.py b/Server/src/services/tools/manage_graphics.py new file mode 100644 index 000000000..f0005aa8e --- /dev/null +++ b/Server/src/services/tools/manage_graphics.py @@ -0,0 +1,166 @@ +from typing import Annotated, Any, Optional + +from fastmcp import Context +from mcp.types import ToolAnnotations + +from services.registry import mcp_for_unity_tool +from services.tools import get_unity_instance_from_context +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry + +VOLUME_ACTIONS = [ + "volume_create", "volume_add_effect", "volume_set_effect", + "volume_remove_effect", "volume_get_info", "volume_set_properties", + "volume_list_effects", "volume_create_profile", +] + +BAKE_ACTIONS = [ + "bake_start", "bake_cancel", "bake_status", "bake_clear", + "bake_reflection_probe", "bake_get_settings", "bake_set_settings", + "bake_create_light_probe_group", "bake_create_reflection_probe", + "bake_set_probe_positions", +] + +STATS_ACTIONS = [ + "stats_get", "stats_list_counters", "stats_set_scene_debug", "stats_get_memory", +] + +PIPELINE_ACTIONS = [ + "pipeline_get_info", "pipeline_set_quality", + "pipeline_get_settings", "pipeline_set_settings", +] + +FEATURE_ACTIONS = [ + "feature_list", "feature_add", "feature_remove", + "feature_configure", "feature_toggle", "feature_reorder", +] + +SKYBOX_ACTIONS = [ + "skybox_get", "skybox_set_material", "skybox_set_properties", + "skybox_set_ambient", "skybox_set_fog", "skybox_set_reflection", + "skybox_set_sun", +] + +ALL_ACTIONS = ( + ["ping"] + VOLUME_ACTIONS + BAKE_ACTIONS + STATS_ACTIONS + + PIPELINE_ACTIONS + FEATURE_ACTIONS + SKYBOX_ACTIONS +) + + +@mcp_for_unity_tool( + group="core", + description=( + "Manage rendering graphics: volumes, post-processing, light baking, " + "rendering stats, pipeline settings, and URP renderer features. " + "Use ping to check pipeline and available features.\n\n" + "VOLUME (require URP/HDRP):\n" + "- volume_create, volume_add_effect, volume_set_effect, volume_remove_effect\n" + "- volume_get_info, volume_set_properties, volume_list_effects, volume_create_profile\n\n" + "BAKE (Edit mode only):\n" + "- bake_start, bake_cancel, bake_status, bake_clear, bake_reflection_probe\n" + "- bake_get_settings, bake_set_settings\n" + "- bake_create_light_probe_group, bake_create_reflection_probe, bake_set_probe_positions\n\n" + "STATS:\n" + "- stats_get: Rendering counters (draw calls, batches, triangles, etc.)\n" + "- stats_list_counters, stats_set_scene_debug, stats_get_memory\n\n" + "PIPELINE:\n" + "- pipeline_get_info, pipeline_set_quality, pipeline_get_settings, pipeline_set_settings\n\n" + "FEATURES (URP only):\n" + "- feature_list, feature_add, feature_remove, feature_configure, feature_toggle, feature_reorder\n\n" + "SKYBOX / ENVIRONMENT:\n" + "- skybox_get: Read all environment settings (material, ambient, fog, reflection, sun)\n" + "- skybox_set_material: Set skybox material by asset path\n" + "- skybox_set_properties: Set properties on current skybox material (tint, exposure, rotation)\n" + "- skybox_set_ambient: Set ambient lighting mode and colors\n" + "- skybox_set_fog: Enable/configure fog (mode, color, density, start/end distance)\n" + "- skybox_set_reflection: Set environment reflection settings\n" + "- skybox_set_sun: Set the sun source light" + ), + annotations=ToolAnnotations(title="Manage Graphics", destructiveHint=True), +) +async def manage_graphics( + ctx: Context, + action: Annotated[str, "The graphics action to perform."], + target: Annotated[Optional[str], "Target object name or instance ID."] = None, + effect: Annotated[Optional[str], "Effect type name (e.g., 'Bloom', 'Vignette')."] = None, + parameters: Annotated[Optional[dict[str, Any]], "Dict of parameter values."] = None, + properties: Annotated[Optional[dict[str, Any]], "Dict of properties to set."] = None, + settings: Annotated[Optional[dict[str, Any]], "Dict of settings (bake/pipeline)."] = None, + name: Annotated[Optional[str], "Name for created objects."] = None, + is_global: Annotated[Optional[bool], "Whether Volume is global (default true)."] = None, + weight: Annotated[Optional[float], "Volume weight (0-1)."] = None, + priority: Annotated[Optional[float], "Volume priority."] = None, + profile_path: Annotated[Optional[str], "Asset path for VolumeProfile."] = None, + effects: Annotated[Optional[list[dict[str, Any]]], "Effect definitions for volume_create."] = None, + path: Annotated[Optional[str], "Asset path for volume_create_profile."] = None, + level: Annotated[Optional[str], "Quality level name or index."] = None, + position: Annotated[Optional[list[float]], "Position [x,y,z]."] = None, + grid_size: Annotated[Optional[list[int]], "Probe grid size [x,y,z]."] = None, + spacing: Annotated[Optional[float], "Probe grid spacing."] = None, + size: Annotated[Optional[list[float]], "Probe/volume size [x,y,z]."] = None, + resolution: Annotated[Optional[int], "Probe resolution."] = None, + mode: Annotated[Optional[str], "Probe mode or debug mode."] = None, + hdr: Annotated[Optional[bool], "HDR for reflection probes."] = None, + box_projection: Annotated[Optional[bool], "Box projection for reflection probes."] = None, + positions: Annotated[Optional[list[list[float]]], "Probe positions array."] = None, + index: Annotated[Optional[int], "Feature index."] = None, + active: Annotated[Optional[bool], "Feature active state."] = None, + order: Annotated[Optional[list[int]], "Feature reorder indices."] = None, + # bake_start + async_bake: Annotated[Optional[bool], "Async bake (default true)."] = None, + # feature_add + feature_type: Annotated[Optional[str], "Renderer feature type name."] = None, + material: Annotated[Optional[str], "Material asset path for feature."] = None, + # skybox / environment + color: Annotated[Optional[list[float]], "Color [r,g,b,a] for ambient/fog."] = None, + intensity: Annotated[Optional[float], "Intensity value (ambient/reflection)."] = None, + ambient_mode: Annotated[Optional[str], "Ambient mode: Skybox, Trilight, Flat, Custom."] = None, + equator_color: Annotated[Optional[list[float]], "Equator color [r,g,b,a] (Trilight mode)."] = None, + ground_color: Annotated[Optional[list[float]], "Ground color [r,g,b,a] (Trilight mode)."] = None, + fog_enabled: Annotated[Optional[bool], "Enable or disable fog."] = None, + fog_mode: Annotated[Optional[str], "Fog mode: Linear, Exponential, ExponentialSquared."] = None, + fog_color: Annotated[Optional[list[float]], "Fog color [r,g,b,a]."] = None, + fog_density: Annotated[Optional[float], "Fog density (Exponential modes)."] = None, + fog_start: Annotated[Optional[float], "Fog start distance (Linear mode)."] = None, + fog_end: Annotated[Optional[float], "Fog end distance (Linear mode)."] = None, + bounces: Annotated[Optional[int], "Reflection bounces."] = None, + reflection_mode: Annotated[Optional[str], "Default reflection mode: Skybox, Custom."] = None, +) -> dict[str, Any]: + action_lower = action.lower() + if action_lower not in ALL_ACTIONS: + return { + "success": False, + "message": f"Unknown action '{action}'. Valid actions: {', '.join(ALL_ACTIONS)}", + } + + unity_instance = await get_unity_instance_from_context(ctx) + + params_dict: dict[str, Any] = {"action": action_lower} + + # Map all non-None params + param_map = { + "target": target, "effect": effect, "parameters": parameters, + "properties": properties, "settings": settings, "name": name, + "is_global": is_global, "weight": weight, "priority": priority, + "profile_path": profile_path, "effects": effects, "path": path, + "level": level, "position": position, "grid_size": grid_size, + "spacing": spacing, "size": size, "resolution": resolution, + "mode": mode, "hdr": hdr, "box_projection": box_projection, + "positions": positions, "index": index, "active": active, + "order": order, "async": async_bake, "type": feature_type, + "material": material, "color": color, "intensity": intensity, + "ambient_mode": ambient_mode, "equator_color": equator_color, + "ground_color": ground_color, "fog_enabled": fog_enabled, + "fog_mode": fog_mode, "fog_color": fog_color, + "fog_density": fog_density, "fog_start": fog_start, + "fog_end": fog_end, "bounces": bounces, + "reflection_mode": reflection_mode, + } + for key, val in param_map.items(): + if val is not None: + params_dict[key] = val + + result = await send_with_unity_instance( + async_send_command_with_retry, unity_instance, "manage_graphics", params_dict + ) + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/Server/src/services/tools/manage_scene.py b/Server/src/services/tools/manage_scene.py index abfb01705..8b94fc854 100644 --- a/Server/src/services/tools/manage_scene.py +++ b/Server/src/services/tools/manage_scene.py @@ -1,12 +1,11 @@ from typing import Annotated, Literal, Any from fastmcp import Context -from fastmcp.server.server import ToolResult from mcp.types import ToolAnnotations from services.registry import mcp_for_unity_tool from services.tools import get_unity_instance_from_context -from services.tools.utils import coerce_int, coerce_bool, build_screenshot_params, extract_screenshot_images +from services.tools.utils import coerce_int, coerce_bool from transport.unity_transport import send_with_unity_instance from transport.legacy.unity_connection import async_send_command_with_retry from services.tools.preflight import preflight @@ -15,12 +14,9 @@ @mcp_for_unity_tool( description=( "Performs CRUD operations on Unity scenes. " - "Read-only actions: get_hierarchy, get_active, get_build_settings, screenshot, scene_view_frame. " + "Read-only actions: get_hierarchy, get_active, get_build_settings, scene_view_frame. " "Modifying actions: create, load, save. " - "screenshot supports include_image=true to return an inline base64 PNG for AI vision. " - "screenshot with batch='surround' captures 6 angles around the scene (no file saved) for comprehensive scene understanding. " - "screenshot with batch='orbit' captures configurable azimuth x elevation grid for visual QA (use orbit_angles, orbit_elevations, orbit_distance, orbit_fov). " - "screenshot with look_at/view_position creates a temp camera at that viewpoint and returns an inline image." + "For screenshots, use manage_camera (screenshot, screenshot_multiview actions)." ), annotations=ToolAnnotations( title="Manage Scene", @@ -36,48 +32,12 @@ async def manage_scene( "get_hierarchy", "get_active", "get_build_settings", - "screenshot", "scene_view_frame", - ], "Perform CRUD operations on Unity scenes, capture screenshots, and control the Scene View camera."], + ], "Perform CRUD operations on Unity scenes and control the Scene View camera."], name: Annotated[str, "Scene name."] | None = None, path: Annotated[str, "Scene path."] | None = None, build_index: Annotated[int | str, "Unity build index (quote as string, e.g., '0')."] | None = None, - # --- screenshot params --- - screenshot_file_name: Annotated[str, - "Screenshot file name (optional). Defaults to timestamp when omitted."] | None = None, - screenshot_super_size: Annotated[int | str, - "Screenshot supersize multiplier (integer ≥1). Optional."] | None = None, - camera: Annotated[str, - "Camera to capture from (name, path, or instance ID). Defaults to Camera.main."] | None = None, - include_image: Annotated[bool | str, - "If true, return the screenshot as an inline base64 PNG image in the response. " - "The AI can see the image. Default false. Recommended max_resolution=512 for context efficiency."] | None = None, - max_resolution: Annotated[int | str, - "Max resolution (longest edge in pixels) for the inline image. Default 640. " - "Use 256-512 for quick looks, 640-1024 for detail."] | None = None, - # --- screenshot extended params (batch, positioned capture) --- - batch: Annotated[str, - "Batch capture mode. 'surround' captures 6 fixed angles (front/back/left/right/top/bird_eye). " - "'orbit' captures configurable azimuth x elevation grid for visual QA (use orbit_angles, orbit_elevations, orbit_distance, orbit_fov). " - "Both modes center on look_at target or scene bounds. Returns inline images, no file saved."] | None = None, - look_at: Annotated[str | int | list[float], - "Target to aim the camera at before capture. Can be a GameObject name/path/ID or [x,y,z] position. " - "For batch='surround', centers the surround on this target. For single shots, creates a temp camera aimed here."] | None = None, - view_position: Annotated[list[float] | str, - "World position [x,y,z] to place the camera for a positioned screenshot."] | None = None, - view_rotation: Annotated[list[float] | str, - "Euler rotation [x,y,z] for the camera. Overrides look_at aiming if both provided."] | None = None, - # --- orbit batch params --- - orbit_angles: Annotated[int | str, - "Number of azimuth samples for batch='orbit' (default 8, max 36)."] | None = None, - orbit_elevations: Annotated[list[float] | str, - "Elevation angles in degrees for batch='orbit' (default [0, 30, -15]). " - "E.g., [0, 30, 60] for ground-level, mid, and high views."] | None = None, - orbit_distance: Annotated[float | str, - "Camera distance from target for batch='orbit' (default auto from bounds)."] | None = None, - orbit_fov: Annotated[float | str, - "Camera field of view in degrees for batch='orbit' (default 60)."] | None = None, # --- scene_view_frame params --- scene_view_target: Annotated[str | int, "GameObject reference for scene_view_frame (name, path, or instance ID)."] | None = None, @@ -96,7 +56,7 @@ async def manage_scene( "Child paging hint (safety)."] | None = None, include_transform: Annotated[bool | str, "If true, include local transform in node summaries."] | None = None, -) -> dict[str, Any] | ToolResult: +) -> dict[str, Any]: unity_instance = await get_unity_instance_from_context(ctx) gate = await preflight(ctx, wait_for_no_compile=True, refresh_if_dirty=True) if gate is not None: @@ -120,26 +80,6 @@ async def manage_scene( if coerced_build_index is not None: params["buildIndex"] = coerced_build_index - # screenshot params (shared with manage_camera) - screenshot_err = build_screenshot_params( - params, - screenshot_file_name=screenshot_file_name, - screenshot_super_size=screenshot_super_size, - camera=camera, - include_image=include_image, - max_resolution=max_resolution, - batch=batch, - look_at=look_at, - orbit_angles=orbit_angles, - orbit_elevations=orbit_elevations, - orbit_distance=orbit_distance, - orbit_fov=orbit_fov, - view_position=view_position, - view_rotation=view_rotation, - ) - if screenshot_err is not None: - return screenshot_err - # scene_view_frame params if scene_view_target is not None: params["sceneViewTarget"] = scene_view_target @@ -166,13 +106,6 @@ async def manage_scene( # Preserve structured failure data; unwrap success into a friendlier shape if isinstance(response, dict) and response.get("success"): friendly = {"success": True, "message": response.get("message", "Scene operation successful."), "data": response.get("data")} - - # For screenshot actions, check if inline images should be returned as ImageContent - if action == "screenshot": - image_result = extract_screenshot_images(response) - if image_result is not None: - return image_result - return friendly return response if isinstance(response, dict) else {"success": False, "message": str(response)} diff --git a/Server/tests/integration/test_manage_scene_screenshot_params.py b/Server/tests/integration/test_manage_scene_screenshot_params.py deleted file mode 100644 index 2966981f4..000000000 --- a/Server/tests/integration/test_manage_scene_screenshot_params.py +++ /dev/null @@ -1,258 +0,0 @@ -import pytest - -from .test_helpers import DummyContext -import services.tools.manage_scene as manage_scene_mod -from services.tools.utils import extract_screenshot_images - - -# --------------------------------------------------------------------------- -# _extract_images unit tests -# --------------------------------------------------------------------------- - -def test_extract_images_returns_none_for_non_dict(): - assert extract_screenshot_images("not a dict") is None - - -def test_extract_images_returns_none_for_failed_response(): - assert extract_screenshot_images({"success": False}) is None - - -def test_extract_images_returns_none_when_no_base64(): - resp = {"success": True, "data": {"filePath": "Assets/shot.png"}} - assert extract_screenshot_images(resp) is None - - -def test_extract_images_screenshot_returns_tool_result(): - resp = { - "success": True, - "message": "ok", - "data": { - "filePath": "Assets/shot.png", - "imageBase64": "iVBOR_FAKE_PNG_DATA", - "imageWidth": 512, - "imageHeight": 512, - }, - } - result = extract_screenshot_images(resp) - assert result is not None - # Should have TextContent + ImageContent - assert len(result.content) == 2 - assert result.content[0].type == "text" - assert result.content[1].type == "image" - assert result.content[1].data == "iVBOR_FAKE_PNG_DATA" - assert result.content[1].mimeType == "image/png" - # Text block should NOT contain base64 - assert "iVBOR_FAKE_PNG_DATA" not in result.content[0].text - - -def test_extract_images_batch_surround_returns_tool_result(): - resp = { - "success": True, - "message": "ok", - "data": { - "sceneCenter": [0, 0, 0], - "sceneRadius": 10.0, - "screenshots": [ - {"angle": "front", "imageBase64": "FRONT64", "imageWidth": 256, "imageHeight": 256}, - {"angle": "back", "imageBase64": "BACK64", "imageWidth": 256, "imageHeight": 256}, - ], - }, - } - result = extract_screenshot_images(resp) - assert result is not None - # 1 text summary + 2*(label + image) = 5 blocks - assert len(result.content) == 5 - assert result.content[0].type == "text" - assert result.content[1].type == "text" # angle label - assert result.content[2].type == "image" - assert result.content[2].data == "FRONT64" - assert result.content[3].type == "text" # angle label - assert result.content[4].type == "image" - assert result.content[4].data == "BACK64" - # Text summary should NOT contain base64 - assert "FRONT64" not in result.content[0].text - - -def test_extract_images_batch_no_screenshots(): - resp = {"success": True, "data": {"screenshots": []}} - assert extract_screenshot_images(resp) is None - - -def test_extract_images_positioned_returns_tool_result(): - resp = { - "success": True, - "message": "ok", - "data": { - "imageBase64": "VIEW_B64", - "imageWidth": 640, - "imageHeight": 480, - "viewPosition": [0, 10, -10], - "lookAt": [0, 0, 0], - }, - } - result = extract_screenshot_images(resp) - assert result is not None - assert len(result.content) == 2 - assert result.content[1].data == "VIEW_B64" - - -def test_extract_images_no_data_key(): - resp = {"success": True} - assert extract_screenshot_images(resp) is None - - -# --------------------------------------------------------------------------- -# manage_scene param pass-through tests -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_screenshot_camera_and_include_image_params(monkeypatch): - """New camera, include_image, and max_resolution params are forwarded.""" - captured = {} - - async def fake_send(cmd, params, **kwargs): - captured["params"] = params - return {"success": True, "data": {"filePath": "Assets/shot.png"}} - - monkeypatch.setattr(manage_scene_mod, "async_send_command_with_retry", fake_send) - - resp = await manage_scene_mod.manage_scene( - ctx=DummyContext(), - action="screenshot", - camera="MainCamera", - include_image=True, - max_resolution=256, - ) - - p = captured["params"] - assert p["action"] == "screenshot" - assert p["camera"] == "MainCamera" - assert p["includeImage"] is True - assert p["maxResolution"] == 256 - - -@pytest.mark.asyncio -async def test_screenshot_batch_surround_params(monkeypatch): - """batch='surround' and max_resolution are forwarded.""" - captured = {} - - async def fake_send(cmd, params, **kwargs): - captured["params"] = params - return {"success": True, "data": {"screenshots": []}} - - monkeypatch.setattr(manage_scene_mod, "async_send_command_with_retry", fake_send) - - resp = await manage_scene_mod.manage_scene( - ctx=DummyContext(), - action="screenshot", - batch="surround", - max_resolution=128, - ) - - p = captured["params"] - assert p["action"] == "screenshot" - assert p["batch"] == "surround" - assert p["maxResolution"] == 128 - - -@pytest.mark.asyncio -async def test_screenshot_positioned_params(monkeypatch): - """look_at and view_position params are forwarded.""" - captured = {} - - async def fake_send(cmd, params, **kwargs): - captured["params"] = params - return {"success": True, "data": {}} - - monkeypatch.setattr(manage_scene_mod, "async_send_command_with_retry", fake_send) - - await manage_scene_mod.manage_scene( - ctx=DummyContext(), - action="screenshot", - look_at="Player", - view_position=[0, 10, -10], - view_rotation=[45, 0, 0], - max_resolution=512, - ) - - p = captured["params"] - assert p["action"] == "screenshot" - assert p["lookAt"] == "Player" - assert p["viewPosition"] == [0, 10, -10] - assert p["viewRotation"] == [45, 0, 0] - assert p["maxResolution"] == 512 - - -@pytest.mark.asyncio -async def test_screenshot_batch_with_look_at_params(monkeypatch): - """batch='surround' + look_at centers surround on the target.""" - captured = {} - - async def fake_send(cmd, params, **kwargs): - captured["params"] = params - return {"success": True, "data": {"screenshots": []}} - - monkeypatch.setattr(manage_scene_mod, "async_send_command_with_retry", fake_send) - - await manage_scene_mod.manage_scene( - ctx=DummyContext(), - action="screenshot", - batch="surround", - look_at="Enemy", - max_resolution=256, - ) - - p = captured["params"] - assert p["action"] == "screenshot" - assert p["batch"] == "surround" - assert p["lookAt"] == "Enemy" - - -@pytest.mark.asyncio -async def test_scene_view_frame_params(monkeypatch): - captured = {} - - async def fake_send(cmd, params, **kwargs): - captured["params"] = params - return {"success": True, "data": {}} - - monkeypatch.setattr(manage_scene_mod, "async_send_command_with_retry", fake_send) - - await manage_scene_mod.manage_scene( - ctx=DummyContext(), - action="scene_view_frame", - scene_view_target="Player", - ) - - p = captured["params"] - assert p["action"] == "scene_view_frame" - assert p["sceneViewTarget"] == "Player" - - -@pytest.mark.asyncio -async def test_screenshot_returns_tool_result_with_image(monkeypatch): - """When Unity returns imageBase64, manage_scene should return a ToolResult.""" - - async def fake_send(cmd, params, **kwargs): - return { - "success": True, - "message": "ok", - "data": { - "filePath": "Assets/shot.png", - "imageBase64": "FAKE_B64", - "imageWidth": 256, - "imageHeight": 256, - }, - } - - monkeypatch.setattr(manage_scene_mod, "async_send_command_with_retry", fake_send) - - result = await manage_scene_mod.manage_scene( - ctx=DummyContext(), - action="screenshot", - include_image=True, - ) - - from fastmcp.server.server import ToolResult - assert isinstance(result, ToolResult) - assert any(getattr(c, "data", None) == "FAKE_B64" for c in result.content) diff --git a/Server/tests/test_cli.py b/Server/tests/test_cli.py index c97d49efe..baaf8e4d4 100644 --- a/Server/tests/test_cli.py +++ b/Server/tests/test_cli.py @@ -456,12 +456,6 @@ def test_scene_create(self, runner, mock_unity_response): result = runner.invoke(cli, ["scene", "create", "NewLevel"]) assert result.exit_code == 0 - def test_scene_screenshot(self, runner, mock_unity_response): - """Test scene screenshot command.""" - with patch("cli.commands.scene.run_command", return_value=mock_unity_response): - result = runner.invoke( - cli, ["scene", "screenshot", "--filename", "test"]) - assert result.exit_code == 0 # ============================================================================= diff --git a/Server/tests/test_manage_graphics.py b/Server/tests/test_manage_graphics.py new file mode 100644 index 000000000..140a0da16 --- /dev/null +++ b/Server/tests/test_manage_graphics.py @@ -0,0 +1,960 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from services.tools.manage_graphics import ( + manage_graphics, + ALL_ACTIONS, + VOLUME_ACTIONS, + BAKE_ACTIONS, + STATS_ACTIONS, + PIPELINE_ACTIONS, + FEATURE_ACTIONS, + SKYBOX_ACTIONS, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def mock_unity(monkeypatch): + """Patch Unity transport layer and return captured call dict.""" + captured: dict[str, object] = {} + + async def fake_send(send_fn, unity_instance, tool_name, params): + captured["unity_instance"] = unity_instance + captured["tool_name"] = tool_name + captured["params"] = params + return {"success": True, "message": "ok"} + + monkeypatch.setattr( + "services.tools.manage_graphics.get_unity_instance_from_context", + AsyncMock(return_value="unity-instance-1"), + ) + monkeypatch.setattr( + "services.tools.manage_graphics.send_with_unity_instance", + fake_send, + ) + return captured + + +# --------------------------------------------------------------------------- +# Action list completeness +# --------------------------------------------------------------------------- + +def test_all_actions_is_union_of_sub_lists(): + expected = set( + ["ping"] + VOLUME_ACTIONS + BAKE_ACTIONS + STATS_ACTIONS + + PIPELINE_ACTIONS + FEATURE_ACTIONS + SKYBOX_ACTIONS + ) + assert set(ALL_ACTIONS) == expected + + +def test_no_duplicate_actions(): + assert len(ALL_ACTIONS) == len(set(ALL_ACTIONS)) + + +def test_all_actions_count(): + assert len(ALL_ACTIONS) == 40 + + +def test_volume_actions_count(): + assert len(VOLUME_ACTIONS) == 8 + + +def test_bake_actions_count(): + assert len(BAKE_ACTIONS) == 10 + + +def test_stats_actions_count(): + assert len(STATS_ACTIONS) == 4 + + +def test_pipeline_actions_count(): + assert len(PIPELINE_ACTIONS) == 4 + + +def test_feature_actions_count(): + assert len(FEATURE_ACTIONS) == 6 + + +# --------------------------------------------------------------------------- +# Invalid / missing action +# --------------------------------------------------------------------------- + +def test_unknown_action_returns_error(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="nonexistent_action") + ) + assert result["success"] is False + assert "Unknown action" in result["message"] + assert "tool_name" not in mock_unity + + +def test_empty_action_returns_error(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="") + ) + assert result["success"] is False + + +# --------------------------------------------------------------------------- +# Ping +# --------------------------------------------------------------------------- + +def test_ping_sends_correct_params(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="ping") + ) + assert result["success"] is True + assert mock_unity["tool_name"] == "manage_graphics" + assert mock_unity["params"]["action"] == "ping" + + +# --------------------------------------------------------------------------- +# Volume actions +# --------------------------------------------------------------------------- + +def test_volume_create_with_all_params(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="volume_create", + name="PostProcess Volume", + is_global=True, + weight=0.8, + priority=1.0, + profile_path="Assets/Profiles/MyProfile.asset", + effects=[ + {"type": "Bloom", "parameters": {"intensity": 1.5}}, + {"type": "Vignette", "parameters": {"intensity": 0.3}}, + ], + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "volume_create" + assert mock_unity["params"]["name"] == "PostProcess Volume" + assert mock_unity["params"]["is_global"] is True + assert mock_unity["params"]["weight"] == 0.8 + assert mock_unity["params"]["priority"] == 1.0 + assert mock_unity["params"]["profile_path"] == "Assets/Profiles/MyProfile.asset" + assert len(mock_unity["params"]["effects"]) == 2 + + +def test_volume_create_minimal(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="volume_create") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "volume_create" + assert "name" not in mock_unity["params"] + assert "effects" not in mock_unity["params"] + + +def test_volume_add_effect_sends_target_and_effect(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="volume_add_effect", + target="PostProcess Volume", + effect="Bloom", + parameters={"intensity": 2.0, "threshold": 0.9}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "volume_add_effect" + assert mock_unity["params"]["target"] == "PostProcess Volume" + assert mock_unity["params"]["effect"] == "Bloom" + assert mock_unity["params"]["parameters"]["intensity"] == 2.0 + + +def test_volume_set_effect_sends_properties(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="volume_set_effect", + target="PostProcess Volume", + effect="Bloom", + parameters={"intensity": 3.0}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "volume_set_effect" + assert mock_unity["params"]["effect"] == "Bloom" + assert mock_unity["params"]["parameters"]["intensity"] == 3.0 + + +def test_volume_remove_effect_sends_target_and_effect(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="volume_remove_effect", + target="PostProcess Volume", + effect="Vignette", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "volume_remove_effect" + assert mock_unity["params"]["effect"] == "Vignette" + + +def test_volume_get_info_sends_target(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="volume_get_info", + target="PostProcess Volume", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "volume_get_info" + assert mock_unity["params"]["target"] == "PostProcess Volume" + + +def test_volume_set_properties_sends_properties(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="volume_set_properties", + target="PostProcess Volume", + properties={"weight": 0.5, "priority": 2.0, "isGlobal": False}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "volume_set_properties" + assert mock_unity["params"]["properties"]["weight"] == 0.5 + + +def test_volume_list_effects_sends_target(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="volume_list_effects", + target="PostProcess Volume", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "volume_list_effects" + + +def test_volume_create_profile_sends_path(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="volume_create_profile", + path="Assets/Profiles/NewProfile.asset", + effects=[{"type": "Bloom"}], + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "volume_create_profile" + assert mock_unity["params"]["path"] == "Assets/Profiles/NewProfile.asset" + assert len(mock_unity["params"]["effects"]) == 1 + + +# --------------------------------------------------------------------------- +# Bake actions +# --------------------------------------------------------------------------- + +def test_bake_start_sends_async_flag(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="bake_start", + async_bake=True, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "bake_start" + assert mock_unity["params"]["async"] is True + + +def test_bake_start_sync(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="bake_start", + async_bake=False, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["async"] is False + + +def test_bake_cancel_sends_no_extra_params(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="bake_cancel") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "bake_cancel" + assert "target" not in mock_unity["params"] + + +def test_bake_status_sends_action(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="bake_status") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "bake_status" + + +def test_bake_clear_sends_action(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="bake_clear") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "bake_clear" + + +def test_bake_reflection_probe_sends_target(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="bake_reflection_probe", + target="ReflectionProbe1", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "bake_reflection_probe" + assert mock_unity["params"]["target"] == "ReflectionProbe1" + + +def test_bake_get_settings_sends_action(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="bake_get_settings") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "bake_get_settings" + + +def test_bake_set_settings_sends_settings(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="bake_set_settings", + settings={"lightmapResolution": 40, "bounces": 3}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "bake_set_settings" + assert mock_unity["params"]["settings"]["lightmapResolution"] == 40 + assert mock_unity["params"]["settings"]["bounces"] == 3 + + +def test_bake_create_light_probe_group_sends_params(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="bake_create_light_probe_group", + name="LightProbes", + position=[0.0, 1.0, 0.0], + grid_size=[3, 3, 3], + spacing=2.5, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "bake_create_light_probe_group" + assert mock_unity["params"]["name"] == "LightProbes" + assert mock_unity["params"]["position"] == [0.0, 1.0, 0.0] + assert mock_unity["params"]["grid_size"] == [3, 3, 3] + assert mock_unity["params"]["spacing"] == 2.5 + + +def test_bake_create_reflection_probe_sends_params(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="bake_create_reflection_probe", + name="Probe1", + position=[5.0, 2.0, -3.0], + size=[10.0, 10.0, 10.0], + resolution=256, + mode="Baked", + hdr=True, + box_projection=True, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "bake_create_reflection_probe" + assert mock_unity["params"]["name"] == "Probe1" + assert mock_unity["params"]["position"] == [5.0, 2.0, -3.0] + assert mock_unity["params"]["size"] == [10.0, 10.0, 10.0] + assert mock_unity["params"]["resolution"] == 256 + assert mock_unity["params"]["mode"] == "Baked" + assert mock_unity["params"]["hdr"] is True + assert mock_unity["params"]["box_projection"] is True + + +def test_bake_set_probe_positions_sends_positions(mock_unity): + positions = [[0.0, 0.0, 0.0], [1.0, 2.0, 3.0], [5.0, 5.0, 5.0]] + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="bake_set_probe_positions", + target="LightProbes", + positions=positions, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "bake_set_probe_positions" + assert mock_unity["params"]["target"] == "LightProbes" + assert mock_unity["params"]["positions"] == positions + + +# --------------------------------------------------------------------------- +# Stats actions +# --------------------------------------------------------------------------- + +def test_stats_get_sends_action(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="stats_get") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "stats_get" + + +def test_stats_list_counters_sends_action(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="stats_list_counters") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "stats_list_counters" + + +def test_stats_set_scene_debug_sends_mode(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="stats_set_scene_debug", + mode="Wireframe", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "stats_set_scene_debug" + assert mock_unity["params"]["mode"] == "Wireframe" + + +def test_stats_get_memory_sends_action(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="stats_get_memory") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "stats_get_memory" + + +# --------------------------------------------------------------------------- +# Pipeline actions +# --------------------------------------------------------------------------- + +def test_pipeline_get_info_sends_action(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="pipeline_get_info") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "pipeline_get_info" + + +def test_pipeline_set_quality_sends_level(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="pipeline_set_quality", + level="Ultra", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "pipeline_set_quality" + assert mock_unity["params"]["level"] == "Ultra" + + +def test_pipeline_get_settings_sends_action(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="pipeline_get_settings") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "pipeline_get_settings" + + +def test_pipeline_set_settings_sends_settings(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="pipeline_set_settings", + settings={"renderScale": 1.5, "msaa": 4}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "pipeline_set_settings" + assert mock_unity["params"]["settings"]["renderScale"] == 1.5 + assert mock_unity["params"]["settings"]["msaa"] == 4 + + +# --------------------------------------------------------------------------- +# Feature actions +# --------------------------------------------------------------------------- + +def test_feature_list_sends_action(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="feature_list") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "feature_list" + + +def test_feature_add_sends_type_and_name(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="feature_add", + feature_type="RenderObjects", + name="DrawOpaqueOutline", + material="Assets/Materials/Outline.mat", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "feature_add" + assert mock_unity["params"]["type"] == "RenderObjects" + assert mock_unity["params"]["name"] == "DrawOpaqueOutline" + assert mock_unity["params"]["material"] == "Assets/Materials/Outline.mat" + + +def test_feature_remove_sends_index(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="feature_remove", + index=2, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "feature_remove" + assert mock_unity["params"]["index"] == 2 + + +def test_feature_configure_sends_index_and_settings(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="feature_configure", + index=0, + settings={"renderPassEvent": "AfterRenderingOpaques", "layerMask": 1}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "feature_configure" + assert mock_unity["params"]["index"] == 0 + assert mock_unity["params"]["settings"]["renderPassEvent"] == "AfterRenderingOpaques" + + +def test_feature_toggle_sends_index_and_active(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="feature_toggle", + index=1, + active=False, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "feature_toggle" + assert mock_unity["params"]["index"] == 1 + assert mock_unity["params"]["active"] is False + + +def test_feature_reorder_sends_order(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="feature_reorder", + order=[2, 0, 1], + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "feature_reorder" + assert mock_unity["params"]["order"] == [2, 0, 1] + + +# --------------------------------------------------------------------------- +# Parameter handling +# --------------------------------------------------------------------------- + +def test_none_params_omitted(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="ping", + target=None, + effect=None, + parameters=None, + properties=None, + settings=None, + name=None, + is_global=None, + weight=None, + priority=None, + profile_path=None, + effects=None, + path=None, + level=None, + position=None, + grid_size=None, + spacing=None, + size=None, + resolution=None, + mode=None, + hdr=None, + box_projection=None, + positions=None, + index=None, + active=None, + order=None, + async_bake=None, + feature_type=None, + material=None, + ) + ) + assert result["success"] is True + # Only "action" key should be present + assert mock_unity["params"] == {"action": "ping"} + + +def test_non_none_params_included(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="volume_create", + name="Vol1", + is_global=False, + weight=0.5, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["name"] == "Vol1" + assert mock_unity["params"]["is_global"] is False + assert mock_unity["params"]["weight"] == 0.5 + # Other optional params should not be present + assert "target" not in mock_unity["params"] + assert "effect" not in mock_unity["params"] + assert "settings" not in mock_unity["params"] + + +def test_async_bake_maps_to_async_key(mock_unity): + """The async_bake Python param maps to 'async' in the params dict.""" + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="bake_start", + async_bake=True, + ) + ) + assert result["success"] is True + assert "async" in mock_unity["params"] + assert mock_unity["params"]["async"] is True + assert "async_bake" not in mock_unity["params"] + + +def test_feature_type_maps_to_type_key(mock_unity): + """The feature_type Python param maps to 'type' in the params dict.""" + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="feature_add", + feature_type="ScreenSpaceAmbientOcclusion", + ) + ) + assert result["success"] is True + assert "type" in mock_unity["params"] + assert mock_unity["params"]["type"] == "ScreenSpaceAmbientOcclusion" + assert "feature_type" not in mock_unity["params"] + + +def test_non_dict_response_wrapped(monkeypatch): + """When Unity returns a non-dict, it should be wrapped.""" + monkeypatch.setattr( + "services.tools.manage_graphics.get_unity_instance_from_context", + AsyncMock(return_value="unity-1"), + ) + + async def fake_send(send_fn, unity_instance, tool_name, params): + return "unexpected string response" + + monkeypatch.setattr( + "services.tools.manage_graphics.send_with_unity_instance", + fake_send, + ) + + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="ping") + ) + assert result["success"] is False + assert "unexpected string response" in result["message"] + + +# --------------------------------------------------------------------------- +# Case insensitivity +# --------------------------------------------------------------------------- + +def test_action_case_insensitive(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="Volume_Create") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "volume_create" + + +def test_action_uppercase(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="PING") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "ping" + + +def test_action_mixed_case_bake(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="Bake_Start") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "bake_start" + + +# --------------------------------------------------------------------------- +# All actions forward correctly +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("action_name", ALL_ACTIONS) +def test_every_action_forwards_to_unity(mock_unity, action_name): + """Every valid action should be forwarded to Unity without error.""" + result = asyncio.run( + manage_graphics(SimpleNamespace(), action=action_name) + ) + assert result["success"] is True + assert mock_unity["tool_name"] == "manage_graphics" + assert mock_unity["params"]["action"] == action_name + + +# --------------------------------------------------------------------------- +# Tool registration +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Skybox actions +# --------------------------------------------------------------------------- + +def test_skybox_actions_count(): + assert len(SKYBOX_ACTIONS) == 7 + + +def test_skybox_get_sends_action(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="skybox_get") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "skybox_get" + + +def test_skybox_set_material_sends_material(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="skybox_set_material", + material="Assets/Materials/MySkybox.mat", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "skybox_set_material" + assert mock_unity["params"]["material"] == "Assets/Materials/MySkybox.mat" + + +def test_skybox_set_properties_sends_properties(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="skybox_set_properties", + properties={"_Exposure": 1.3, "_Rotation": 90}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "skybox_set_properties" + assert mock_unity["params"]["properties"]["_Exposure"] == 1.3 + assert mock_unity["params"]["properties"]["_Rotation"] == 90 + + +def test_skybox_set_ambient_with_mode_and_colors(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="skybox_set_ambient", + ambient_mode="Trilight", + color=[0.5, 0.6, 0.8, 1.0], + equator_color=[0.4, 0.4, 0.5, 1.0], + ground_color=[0.2, 0.15, 0.1, 1.0], + intensity=1.2, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "skybox_set_ambient" + assert mock_unity["params"]["ambient_mode"] == "Trilight" + assert mock_unity["params"]["color"] == [0.5, 0.6, 0.8, 1.0] + assert mock_unity["params"]["equator_color"] == [0.4, 0.4, 0.5, 1.0] + assert mock_unity["params"]["ground_color"] == [0.2, 0.15, 0.1, 1.0] + assert mock_unity["params"]["intensity"] == 1.2 + + +def test_skybox_set_ambient_minimal(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="skybox_set_ambient") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "skybox_set_ambient" + assert "ambient_mode" not in mock_unity["params"] + assert "color" not in mock_unity["params"] + + +def test_skybox_set_fog_all_params(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="skybox_set_fog", + fog_enabled=True, + fog_mode="Linear", + fog_color=[0.5, 0.5, 0.6, 1.0], + fog_density=0.02, + fog_start=10.0, + fog_end=100.0, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "skybox_set_fog" + assert mock_unity["params"]["fog_enabled"] is True + assert mock_unity["params"]["fog_mode"] == "Linear" + assert mock_unity["params"]["fog_color"] == [0.5, 0.5, 0.6, 1.0] + assert mock_unity["params"]["fog_density"] == 0.02 + assert mock_unity["params"]["fog_start"] == 10.0 + assert mock_unity["params"]["fog_end"] == 100.0 + + +def test_skybox_set_fog_disable(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="skybox_set_fog", + fog_enabled=False, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["fog_enabled"] is False + + +def test_skybox_set_reflection_all_params(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="skybox_set_reflection", + intensity=0.8, + bounces=3, + reflection_mode="Custom", + resolution=512, + path="Assets/Cubemaps/EnvCubemap.exr", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "skybox_set_reflection" + assert mock_unity["params"]["intensity"] == 0.8 + assert mock_unity["params"]["bounces"] == 3 + assert mock_unity["params"]["reflection_mode"] == "Custom" + assert mock_unity["params"]["resolution"] == 512 + assert mock_unity["params"]["path"] == "Assets/Cubemaps/EnvCubemap.exr" + + +def test_skybox_set_reflection_minimal(mock_unity): + result = asyncio.run( + manage_graphics(SimpleNamespace(), action="skybox_set_reflection") + ) + assert result["success"] is True + assert mock_unity["params"] == {"action": "skybox_set_reflection"} + + +def test_skybox_set_sun_sends_target(mock_unity): + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="skybox_set_sun", + target="Directional Light", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "skybox_set_sun" + assert mock_unity["params"]["target"] == "Directional Light" + + +def test_skybox_set_ambient_mode_maps_correctly(mock_unity): + """ambient_mode param passes through as ambient_mode key.""" + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="skybox_set_ambient", + ambient_mode="Flat", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["ambient_mode"] == "Flat" + + +def test_skybox_fog_mode_maps_correctly(mock_unity): + """fog_mode param passes through as fog_mode key.""" + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="skybox_set_fog", + fog_mode="ExponentialSquared", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["fog_mode"] == "ExponentialSquared" + + +def test_skybox_reflection_mode_maps_correctly(mock_unity): + """reflection_mode param passes through as reflection_mode key.""" + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="skybox_set_reflection", + reflection_mode="Skybox", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["reflection_mode"] == "Skybox" + + +def test_skybox_bounces_maps_correctly(mock_unity): + """bounces param passes through as bounces key.""" + result = asyncio.run( + manage_graphics( + SimpleNamespace(), + action="skybox_set_reflection", + bounces=5, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["bounces"] == 5 + + +# --------------------------------------------------------------------------- +# Tool registration +# --------------------------------------------------------------------------- + +def test_tool_registered_with_core_group(): + from services.registry.tool_registry import _tool_registry + + graphics_tools = [ + t for t in _tool_registry if t.get("name") == "manage_graphics" + ] + assert len(graphics_tools) == 1 + assert graphics_tools[0]["group"] == "core" diff --git a/Server/uv.lock b/Server/uv.lock index 45f91771d..8afc08289 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -858,7 +858,7 @@ wheels = [ [[package]] name = "mcpforunityserver" -version = "9.5.1" +version = "9.5.2" source = { editable = "." } dependencies = [ { name = "click" }, diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGraphicsTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGraphicsTests.cs new file mode 100644 index 000000000..c70e8be6c --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGraphicsTests.cs @@ -0,0 +1,688 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Tools.Graphics; +using static MCPForUnityTests.Editor.TestUtilities; + +namespace MCPForUnityTests.Editor.Tools +{ + public class ManageGraphicsTests + { + private const string TempRoot = "Assets/Temp/ManageGraphicsTests"; + private bool _hasVolumeSystem; + private bool _hasURP; + + [SetUp] + public void SetUp() + { + EnsureFolder(TempRoot); + + var pingResult = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "ping" })); + if (pingResult.Value("success")) + { + var data = pingResult["data"]; + _hasVolumeSystem = data?.Value("hasVolumeSystem") ?? false; + _hasURP = data?.Value("hasURP") ?? false; + } + } + + [TearDown] + public void TearDown() + { +#if UNITY_2022_2_OR_NEWER + foreach (var go in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) +#else + foreach (var go in UnityEngine.Object.FindObjectsOfType()) +#endif + { + if (go.name.StartsWith("GfxTest_")) + UnityEngine.Object.DestroyImmediate(go); + } + + if (AssetDatabase.IsValidFolder(TempRoot)) + AssetDatabase.DeleteAsset(TempRoot); + CleanupEmptyParentFolders(TempRoot); + + // Reset scene debug mode + ManageGraphics.HandleCommand(new JObject + { + ["action"] = "stats_set_scene_debug", + ["mode"] = "Textured" + }); + } + + // ===================================================================== + // Dispatch / Error Handling + // ===================================================================== + + [Test] + public void HandleCommand_NullParams_ReturnsError() + { + var result = ToJObject(ManageGraphics.HandleCommand(null)); + Assert.IsFalse(result.Value("success")); + } + + [Test] + public void HandleCommand_MissingAction_ReturnsError() + { + var result = ToJObject(ManageGraphics.HandleCommand(new JObject())); + Assert.IsFalse(result.Value("success")); + Assert.That(result["message"].ToString(), Does.Contain("action")); + } + + [Test] + public void HandleCommand_UnknownAction_ReturnsError() + { + var result = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "bogus_action" })); + Assert.IsFalse(result.Value("success")); + Assert.That(result["message"].ToString(), Does.Contain("Unknown action")); + } + + [Test] + public void Ping_ReturnsPipelineInfo() + { + var result = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "ping" })); + Assert.IsTrue(result.Value("success")); + Assert.That(result["message"].ToString(), Does.Contain("Pipeline")); + var data = result["data"]; + Assert.IsNotNull(data); + Assert.IsNotNull(data["pipeline"]); + Assert.IsNotNull(data["pipelineName"]); + } + + // ===================================================================== + // Volume Actions + // ===================================================================== + + private void AssumeVolumeSystem() + { + Assume.That(_hasVolumeSystem, "Volume system not available — skipping."); + } + + [Test] + public void VolumeCreate_Global_CreatesVolume() + { + AssumeVolumeSystem(); + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_create", + ["name"] = "GfxTest_Volume", + ["is_global"] = true, + ["priority"] = 10 + })); + Assert.IsTrue(result.Value("success"), result.ToString()); + Assert.IsTrue(result["data"]["isGlobal"].Value()); + Assert.AreEqual(10, result["data"]["priority"].Value()); + } + + [Test] + public void VolumeCreate_WithEffects_AddsEffects() + { + AssumeVolumeSystem(); + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_create", + ["name"] = "GfxTest_VolumeEffects", + ["effects"] = new JArray + { + new JObject { ["type"] = "Bloom", ["intensity"] = 2 }, + new JObject { ["type"] = "Vignette", ["intensity"] = 0.5 } + } + })); + Assert.IsTrue(result.Value("success"), result.ToString()); + var effects = result["data"]["effects"] as JArray; + Assert.IsNotNull(effects); + Assert.AreEqual(2, effects.Count); + } + + [Test] + public void VolumeCreate_Local_CreatesNonGlobal() + { + AssumeVolumeSystem(); + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_create", + ["name"] = "GfxTest_LocalVol", + ["is_global"] = false + })); + Assert.IsTrue(result.Value("success"), result.ToString()); + Assert.IsFalse(result["data"]["isGlobal"].Value()); + } + + [Test] + public void VolumeAddEffect_AddsEffect() + { + AssumeVolumeSystem(); + CreateTestVolume("GfxTest_AddFx"); + + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_add_effect", + ["target"] = "GfxTest_AddFx", + ["effect"] = "Bloom", + ["parameters"] = new JObject { ["intensity"] = 3 } + })); + Assert.IsTrue(result.Value("success"), result.ToString()); + Assert.AreEqual("Bloom", result["data"]["effect"].ToString()); + } + + [Test] + public void VolumeAddEffect_Duplicate_ReturnsError() + { + AssumeVolumeSystem(); + CreateTestVolume("GfxTest_DupFx"); + ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_add_effect", + ["target"] = "GfxTest_DupFx", + ["effect"] = "Bloom" + }); + + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_add_effect", + ["target"] = "GfxTest_DupFx", + ["effect"] = "Bloom" + })); + Assert.IsFalse(result.Value("success")); + Assert.That(result["message"].ToString(), Does.Contain("already exists")); + } + + [Test] + public void VolumeAddEffect_InvalidEffect_ReturnsError() + { + AssumeVolumeSystem(); + CreateTestVolume("GfxTest_BadFx"); + + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_add_effect", + ["target"] = "GfxTest_BadFx", + ["effect"] = "FakeEffect" + })); + Assert.IsFalse(result.Value("success")); + Assert.That(result["message"].ToString(), Does.Contain("not found")); + } + + [Test] + public void VolumeSetEffect_SetsParameters() + { + AssumeVolumeSystem(); + CreateTestVolume("GfxTest_SetFx"); + ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_add_effect", + ["target"] = "GfxTest_SetFx", + ["effect"] = "Bloom" + }); + + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_set_effect", + ["target"] = "GfxTest_SetFx", + ["effect"] = "Bloom", + ["parameters"] = new JObject { ["intensity"] = 5, ["scatter"] = 0.8 } + })); + Assert.IsTrue(result.Value("success"), result.ToString()); + var setParams = result["data"]["set"] as JArray; + Assert.IsNotNull(setParams); + Assert.That(setParams.Select(t => t.ToString()), Contains.Item("intensity")); + Assert.That(setParams.Select(t => t.ToString()), Contains.Item("scatter")); + } + + [Test] + public void VolumeSetEffect_InvalidParam_ReportsFailed() + { + AssumeVolumeSystem(); + CreateTestVolume("GfxTest_BadParam"); + ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_add_effect", + ["target"] = "GfxTest_BadParam", + ["effect"] = "Bloom" + }); + + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_set_effect", + ["target"] = "GfxTest_BadParam", + ["effect"] = "Bloom", + ["parameters"] = new JObject { ["nonExistent"] = 42 } + })); + Assert.IsTrue(result.Value("success")); + var failed = result["data"]["failed"] as JArray; + Assert.IsNotNull(failed); + Assert.That(failed.Select(t => t.ToString()), Contains.Item("nonExistent")); + } + + [Test] + public void VolumeRemoveEffect_RemovesEffect() + { + AssumeVolumeSystem(); + CreateTestVolume("GfxTest_RmFx"); + ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_add_effect", + ["target"] = "GfxTest_RmFx", + ["effect"] = "Vignette" + }); + + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_remove_effect", + ["target"] = "GfxTest_RmFx", + ["effect"] = "Vignette" + })); + Assert.IsTrue(result.Value("success"), result.ToString()); + + // Verify it's gone + var info = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_get_info", + ["target"] = "GfxTest_RmFx" + })); + var effects = info["data"]["effects"] as JArray; + Assert.IsNotNull(effects); + Assert.AreEqual(0, effects.Count); + } + + [Test] + public void VolumeRemoveEffect_NonExistent_ReturnsError() + { + AssumeVolumeSystem(); + CreateTestVolume("GfxTest_RmMissing"); + + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_remove_effect", + ["target"] = "GfxTest_RmMissing", + ["effect"] = "DepthOfField" + })); + Assert.IsFalse(result.Value("success")); + Assert.That(result["message"].ToString(), Does.Contain("not found")); + } + + [Test] + public void VolumeGetInfo_ReturnsEffectList() + { + AssumeVolumeSystem(); + CreateTestVolume("GfxTest_Info"); + ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_add_effect", + ["target"] = "GfxTest_Info", + ["effect"] = "Bloom" + }); + + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_get_info", + ["target"] = "GfxTest_Info" + })); + Assert.IsTrue(result.Value("success"), result.ToString()); + var data = result["data"]; + Assert.AreEqual("GfxTest_Info", data["name"].ToString()); + var effects = data["effects"] as JArray; + Assert.IsNotNull(effects); + Assert.AreEqual(1, effects.Count); + Assert.AreEqual("Bloom", effects[0]["type"].ToString()); + } + + [Test] + public void VolumeGetInfo_NonExistentTarget_ReturnsError() + { + AssumeVolumeSystem(); + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_get_info", + ["target"] = "NonExistentVolume" + })); + Assert.IsFalse(result.Value("success")); + } + + [Test] + public void VolumeSetProperties_UpdatesWeightAndPriority() + { + AssumeVolumeSystem(); + CreateTestVolume("GfxTest_Props"); + + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_set_properties", + ["target"] = "GfxTest_Props", + ["properties"] = new JObject { ["weight"] = 0.5, ["priority"] = 20 } + })); + Assert.IsTrue(result.Value("success"), result.ToString()); + var changed = result["data"]["changed"] as JArray; + Assert.IsNotNull(changed); + Assert.That(changed.Select(t => t.ToString()), Contains.Item("weight")); + Assert.That(changed.Select(t => t.ToString()), Contains.Item("priority")); + + // Verify via get_info + var info = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_get_info", + ["target"] = "GfxTest_Props" + })); + Assert.AreEqual(0.5f, info["data"]["weight"].Value(), 0.01f); + Assert.AreEqual(20f, info["data"]["priority"].Value(), 0.01f); + } + + [Test] + public void VolumeListEffects_ReturnsAvailableTypes() + { + AssumeVolumeSystem(); + var result = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "volume_list_effects" })); + Assert.IsTrue(result.Value("success"), result.ToString()); + var effects = result["data"]["effects"] as JArray; + Assert.IsNotNull(effects); + Assert.Greater(effects.Count, 0); + Assert.IsNotNull(effects[0]["name"]); + } + + [Test] + public void VolumeCreateProfile_CreatesAsset() + { + AssumeVolumeSystem(); + string path = $"{TempRoot}/TestProfile"; + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_create_profile", + ["path"] = path + })); + Assert.IsTrue(result.Value("success"), result.ToString()); + + string fullPath = result["data"]["path"].ToString(); + Assert.IsTrue(fullPath.EndsWith(".asset")); + Assert.IsNotNull(AssetDatabase.LoadAssetAtPath(fullPath)); + } + + // ===================================================================== + // Bake Actions + // ===================================================================== + + [Test] + public void BakeGetSettings_ReturnsLightmapperInfo() + { + var result = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "bake_get_settings" })); + Assert.IsTrue(result.Value("success"), result.ToString()); + var data = result["data"]; + Assert.IsNotNull(data["lightmapper"]); + Assert.IsNotNull(data["lightmapResolution"]); + } + + [Test] + public void BakeSetSettings_ChangesAndRestores() + { + // Read original + var original = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "bake_get_settings" })); + int origResolution = original["data"]["lightmapResolution"].Value(); + + // Change + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "bake_set_settings", + ["settings"] = new JObject { ["lightmapResolution"] = 20 } + })); + Assert.IsTrue(result.Value("success"), result.ToString()); + var changed = result["data"]["changed"] as JArray; + Assert.That(changed.Select(t => t.ToString()), Contains.Item("lightmapResolution")); + + // Verify + var verify = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "bake_get_settings" })); + Assert.AreEqual(20, verify["data"]["lightmapResolution"].Value()); + + // Restore + ManageGraphics.HandleCommand(new JObject + { + ["action"] = "bake_set_settings", + ["settings"] = new JObject { ["lightmapResolution"] = origResolution } + }); + } + + [Test] + public void BakeStatus_ReportsNotRunning() + { + var result = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "bake_status" })); + Assert.IsTrue(result.Value("success"), result.ToString()); + Assert.IsNotNull(result["data"]["isRunning"]); + } + + [Test] + public void BakeClear_Succeeds() + { + var result = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "bake_clear" })); + Assert.IsTrue(result.Value("success"), result.ToString()); + } + + [Test] + public void BakeCreateReflectionProbe_CreatesProbe() + { + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "bake_create_reflection_probe", + ["name"] = "GfxTest_ReflProbe", + ["position"] = new JArray(0, 1, 0), + ["size"] = new JArray(10, 10, 10), + ["resolution"] = 128 + })); + Assert.IsTrue(result.Value("success"), result.ToString()); + var go = GameObject.Find("GfxTest_ReflProbe"); + Assert.IsNotNull(go); + Assert.IsNotNull(go.GetComponent()); + } + + [Test] + public void BakeCreateLightProbeGroup_CreatesGrid() + { + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "bake_create_light_probe_group", + ["name"] = "GfxTest_LPGroup", + ["position"] = new JArray(0, 0, 0), + ["grid_size"] = new JArray(2, 2, 2), + ["spacing"] = 2 + })); + Assert.IsTrue(result.Value("success"), result.ToString()); + Assert.AreEqual(8, result["data"]["probeCount"].Value()); + var go = GameObject.Find("GfxTest_LPGroup"); + Assert.IsNotNull(go); + Assert.IsNotNull(go.GetComponent()); + } + + [Test] + public void BakeSetProbePositions_SetsPositions() + { + ManageGraphics.HandleCommand(new JObject + { + ["action"] = "bake_create_light_probe_group", + ["name"] = "GfxTest_LPPos", + ["grid_size"] = new JArray(1, 1, 1) + }); + + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "bake_set_probe_positions", + ["target"] = "GfxTest_LPPos", + ["positions"] = new JArray + { + new JArray(0, 0, 0), + new JArray(1, 0, 0), + new JArray(0, 1, 0) + } + })); + Assert.IsTrue(result.Value("success"), result.ToString()); + Assert.AreEqual(3, result["data"]["probeCount"].Value()); + } + + [Test] + public void BakeSetProbePositions_WrongComponent_ReturnsError() + { + var go = new GameObject("GfxTest_NoProbe"); + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "bake_set_probe_positions", + ["target"] = "GfxTest_NoProbe", + ["positions"] = new JArray { new JArray(0, 0, 0) } + })); + Assert.IsFalse(result.Value("success")); + Assert.That(result["message"].ToString(), Does.Contain("LightProbeGroup")); + } + + // ===================================================================== + // Stats Actions + // ===================================================================== + + [Test] + public void StatsGet_ReturnsCounters() + { + var result = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "stats_get" })); + Assert.IsTrue(result.Value("success"), result.ToString()); + Assert.IsNotNull(result["data"]["draw_calls"]); + Assert.IsNotNull(result["data"]["batches"]); + Assert.IsNotNull(result["data"]["triangles"]); + } + + [Test] + public void StatsListCounters_ReturnsList() + { + var result = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "stats_list_counters" })); + Assert.IsTrue(result.Value("success"), result.ToString()); + var counters = result["data"]["counters"] as JArray; + Assert.IsNotNull(counters); + Assert.Greater(counters.Count, 0); + } + + [Test] + public void StatsGetMemory_ReturnsMemoryInfo() + { + var result = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "stats_get_memory" })); + Assert.IsTrue(result.Value("success"), result.ToString()); + Assert.IsNotNull(result["data"]["totalAllocatedMB"]); + Assert.IsNotNull(result["data"]["graphicsDriverMB"]); + } + + [Test] + public void StatsSetSceneDebug_ValidMode_Succeeds() + { + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "stats_set_scene_debug", + ["mode"] = "Wireframe" + })); + Assert.IsTrue(result.Value("success"), result.ToString()); + } + + [Test] + public void StatsSetSceneDebug_InvalidMode_ReturnsError() + { + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "stats_set_scene_debug", + ["mode"] = "InvalidMode" + })); + Assert.IsFalse(result.Value("success")); + Assert.That(result["message"].ToString(), Does.Contain("Valid:")); + } + + // ===================================================================== + // Pipeline Actions + // ===================================================================== + + [Test] + public void PipelineGetInfo_ReturnsPipelineName() + { + var result = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "pipeline_get_info" })); + Assert.IsTrue(result.Value("success"), result.ToString()); + Assert.IsNotNull(result["data"]["pipelineName"]); + Assert.IsNotNull(result["data"]["qualityLevelName"]); + } + + [Test] + public void PipelineGetSettings_ReturnsSettings() + { + var result = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "pipeline_get_settings" })); + Assert.IsTrue(result.Value("success"), result.ToString()); + var settings = result["data"]["settings"]; + Assert.IsNotNull(settings); + Assert.IsNotNull(settings["renderScale"]); + } + + [Test] + public void PipelineSetQuality_InvalidLevel_ReturnsError() + { + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "pipeline_set_quality", + ["level"] = "NonExistentLevel" + })); + Assert.IsFalse(result.Value("success")); + Assert.That(result["message"].ToString(), Does.Contain("Available:")); + } + + // ===================================================================== + // Renderer Feature Actions (URP only) + // ===================================================================== + + private void AssumeURP() + { + Assume.That(_hasURP, "URP not available — skipping."); + } + + [Test] + public void FeatureList_ReturnsFeatures() + { + AssumeURP(); + var result = ToJObject(ManageGraphics.HandleCommand( + new JObject { ["action"] = "feature_list" })); + Assert.IsTrue(result.Value("success"), result.ToString()); + Assert.IsNotNull(result["data"]["features"]); + Assert.IsNotNull(result["data"]["rendererDataName"]); + } + + [Test] + public void FeatureAdd_InvalidType_ReturnsError() + { + AssumeURP(); + var result = ToJObject(ManageGraphics.HandleCommand(new JObject + { + ["action"] = "feature_add", + ["feature_type"] = "NonExistentFeature" + })); + Assert.IsFalse(result.Value("success")); + Assert.That(result["message"].ToString(), Does.Contain("not found")); + Assert.That(result["message"].ToString(), Does.Contain("Available:")); + } + + // ===================================================================== + // Helpers + // ===================================================================== + + private void CreateTestVolume(string name) + { + ManageGraphics.HandleCommand(new JObject + { + ["action"] = "volume_create", + ["name"] = name + }); + } + } +} diff --git a/docs/guides/CLI_EXAMPLE.md b/docs/guides/CLI_EXAMPLE.md index 59fc3c3f2..dd9c3d731 100644 --- a/docs/guides/CLI_EXAMPLE.md +++ b/docs/guides/CLI_EXAMPLE.md @@ -69,32 +69,17 @@ unity-mcp scene hierarchy [--limit 20] [--depth 3] unity-mcp scene active unity-mcp scene load "Assets/Scenes/Main.unity" unity-mcp scene save -unity-mcp scene screenshot --filename "capture" -unity-mcp scene screenshot --camera "MainCam" --include-image --max-resolution 512 -unity-mcp scene screenshot --look-at "Player" --view-position "0,10,-10" -unity-mcp scene screenshot --batch surround --max-resolution 256 -unity-mcp scene screenshot --batch orbit --look-at "Player" --orbit-angles 8 --orbit-elevations "[0,30]" -unity-mcp scene screenshot --batch orbit --look-at "Hero" --orbit-distance 5 --orbit-fov 40 unity-mcp --format json scene hierarchy ``` -**Screenshot Parameters:** -| Option | Description | -|--------|-------------| -| `--filename, -f` | Output filename (default: timestamp) | -| `--supersize, -s` | Resolution multiplier 1–4 | -| `--camera, -c` | Camera name/path/ID (default: Camera.main) | -| `--include-image` | Return base64 PNG inline | -| `--max-resolution, -r` | Max longest-edge pixels (default 640) | -| `--batch, -b` | `surround` (6 angles) or `orbit` (configurable grid) — outputs a contact sheet | -| `--look-at` | Target: GO name or `x,y,z` position | -| `--view-position` | Camera position `x,y,z` | -| `--view-rotation` | Camera rotation `x,y,z` | -| `--orbit-angles` | Azimuth samples (default 8) | -| `--orbit-elevations` | Vertical angles JSON, e.g. `[0,30,-15]` | -| `--orbit-distance` | Distance from target (auto-fit if omitted) | -| `--orbit-fov` | FOV degrees (default 60) | -| `--output-dir, -o` | Save directory (default: `Assets/Screenshots/`) | +**Screenshots** (via `camera` command): +```bash +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 +``` **GameObject Operations** ```bash diff --git a/docs/guides/CLI_USAGE.md b/docs/guides/CLI_USAGE.md index cf68580c1..81bdc6f09 100644 --- a/docs/guides/CLI_USAGE.md +++ b/docs/guides/CLI_USAGE.md @@ -68,31 +68,13 @@ unity-mcp scene active unity-mcp scene load "Assets/Scenes/Main.unity" unity-mcp scene save -# Take screenshot (saves to Assets/Screenshots/) -unity-mcp scene screenshot -unity-mcp scene screenshot --filename "level_preview" -unity-mcp scene screenshot --supersize 2 -unity-mcp scene screenshot --camera "SecondCamera" --include-image - -# Positioned screenshot (single shot from a custom viewpoint) -unity-mcp scene screenshot --view-position "0,10,-10" --look-at "0,0,0" -unity-mcp scene screenshot --look-at "Player" --max-resolution 512 -``` - -#### Batch Screenshots (Contact Sheet) - -Batch modes output a single composite contact-sheet PNG — a labeled grid of all captured angles. - -```bash -# Surround: 6 fixed angles (front/back/left/right/top/bird_eye) -unity-mcp scene screenshot --batch surround --max-resolution 256 -unity-mcp scene screenshot --batch surround --look-at "Player" - -# Orbit: configurable multi-angle grid around a target -unity-mcp scene screenshot --batch orbit --look-at "Player" --orbit-angles 8 -unity-mcp scene screenshot --batch orbit --look-at "Player" --orbit-angles 10 --orbit-elevations "[0,30,-15]" -unity-mcp scene screenshot --batch orbit --look-at "Main Camera" --orbit-angles 4 --max-resolution 512 -unity-mcp scene screenshot --batch orbit --look-at "0,1,0" --orbit-distance 10 --output-dir ./my_shots +# Screenshots (use camera command) +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 ``` ### GameObject Operations @@ -396,7 +378,7 @@ unity-mcp raw read_console '{"count": 20}' | Group | Subcommands | |-------|-------------| | `instance` | `list`, `set`, `current` | -| `scene` | `hierarchy`, `active`, `load`, `save`, `create`, `screenshot`, `build-settings` | +| `scene` | `hierarchy`, `active`, `load`, `save`, `create`, `build-settings` | | `code` | `read`, `search` | | `gameobject` | `find`, `create`, `modify`, `delete`, `duplicate`, `move` | | `component` | `add`, `remove`, `set`, `modify` | diff --git a/docs/i18n/README-zh.md b/docs/i18n/README-zh.md index b8686d4d5..fa3eaaa64 100644 --- a/docs/i18n/README-zh.md +++ b/docs/i18n/README-zh.md @@ -20,6 +20,7 @@
最近更新 +* **v9.5.3 (beta)** — 新增 `manage_graphics` 工具(33个操作):体积/后处理、光照烘焙、渲染统计、管线设置、URP渲染器特性。3个新资源:`volumes`、`rendering_stats`、`renderer_features`。 * **v9.5.2 (beta)** — 新增 `manage_camera` 工具,支持 Cinemachine(预设、优先级、噪声、混合、扩展)、`cameras` 资源、通过 SerializedProperty 修复优先级持久化问题。 * **v9.4.8** — 新编辑器 UI、通过 `manage_tools` 实时切换工具、技能同步窗口、多视图截图、一键 Roslyn 安装器、支持 Qwen Code 与 Gemini CLI 客户端、通过 `manage_probuilder` 进行 ProBuilder 网格编辑。 * **v9.4.7** — 支持按调用路由 Unity 实例、修复 macOS pyenv PATH 问题、脚本工具的域重载稳定性提升。 @@ -92,10 +93,10 @@ openupm add com.coplaydev.unity-mcp * **可扩展** — 可与多种 MCP Client 配合使用 ### 可用工具 -`apply_text_edits` • `batch_execute` • `create_script` • `debug_request_context` • `delete_script` • `execute_custom_tool` • `execute_menu_item` • `find_gameobjects` • `find_in_file` • `get_sha` • `get_test_job` • `manage_animation` • `manage_asset` • `manage_camera` • `manage_components` • `manage_editor` • `manage_gameobject` • `manage_material` • `manage_prefabs` • `manage_probuilder` • `manage_scene` • `manage_script` • `manage_script_capabilities` • `manage_scriptable_object` • `manage_shader` • `manage_texture` • `manage_tools` • `manage_ui` • `manage_vfx` • `read_console` • `refresh_unity` • `run_tests` • `script_apply_edits` • `set_active_instance` • `validate_script` +`apply_text_edits` • `batch_execute` • `create_script` • `debug_request_context` • `delete_script` • `execute_custom_tool` • `execute_menu_item` • `find_gameobjects` • `find_in_file` • `get_sha` • `get_test_job` • `manage_animation` • `manage_asset` • `manage_camera` • `manage_components` • `manage_graphics` • `manage_editor` • `manage_gameobject` • `manage_material` • `manage_prefabs` • `manage_probuilder` • `manage_scene` • `manage_script` • `manage_script_capabilities` • `manage_scriptable_object` • `manage_shader` • `manage_texture` • `manage_tools` • `manage_ui` • `manage_vfx` • `read_console` • `refresh_unity` • `run_tests` • `script_apply_edits` • `set_active_instance` • `validate_script` ### 可用资源 -`cameras` • `custom_tools` • `editor_active_tool` • `editor_prefab_stage` • `editor_selection` • `editor_state` • `editor_windows` • `gameobject` • `gameobject_api` • `gameobject_component` • `gameobject_components` • `get_tests` • `get_tests_for_mode` • `menu_items` • `prefab_api` • `prefab_hierarchy` • `prefab_info` • `project_info` • `project_layers` • `project_tags` • `tool_groups` • `unity_instances` +`cameras` • `custom_tools` • `renderer_features` • `rendering_stats` • `volumes` • `editor_active_tool` • `editor_prefab_stage` • `editor_selection` • `editor_state` • `editor_windows` • `gameobject` • `gameobject_api` • `gameobject_component` • `gameobject_components` • `get_tests` • `get_tests_for_mode` • `menu_items` • `prefab_api` • `prefab_hierarchy` • `prefab_info` • `project_info` • `project_layers` • `project_tags` • `tool_groups` • `unity_instances` **性能提示:** 多个操作请使用 `batch_execute` — 比逐个调用快 10-100 倍!
diff --git a/manifest.json b/manifest.json index 64b25f760..e0950d9f6 100644 --- a/manifest.json +++ b/manifest.json @@ -97,6 +97,10 @@ "name": "manage_camera", "description": "Manage cameras (Unity Camera + Cinemachine) with presets, pipelines, and blending" }, + { + "name": "manage_graphics", + "description": "Manage rendering graphics: volumes, post-processing, light baking, rendering stats, pipeline settings, and URP renderer features" + }, { "name": "manage_material", "description": "Create and modify Unity materials and shaders" diff --git a/unity-mcp-skill/SKILL.md b/unity-mcp-skill/SKILL.md index c03f8a655..dcbd635c1 100644 --- a/unity-mcp-skill/SKILL.md +++ b/unity-mcp-skill/SKILL.md @@ -59,22 +59,22 @@ 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", look_at="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", look_at="Player", view_position=[0, 10, -10], max_resolution=512) ``` **Best practices for AI scene understanding:** @@ -87,7 +87,7 @@ manage_scene(action="screenshot", look_at="Player", view_position=[0, 10, -10], ```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) @@ -159,10 +159,11 @@ uri="file:///full/path/to/file.cs" | **Objects** | `manage_gameobject`, `manage_components` | Creating/modifying GameObjects | | **Scripts** | `create_script`, `script_apply_edits`, `refresh_unity` | C# code management | | **Assets** | `manage_asset`, `manage_prefabs` | Asset operations | -| **Editor** | `manage_editor`, `execute_menu_item`, `read_console` | Editor control | +| **Editor** | `manage_editor`, `execute_menu_item`, `read_console` | Editor control, package deployment (`deploy_package`/`restore_package` actions) | | **Testing** | `run_tests`, `get_test_job` | Unity Test Framework | | **Batch** | `batch_execute` | Parallel/bulk operations | | **Camera** | `manage_camera` | Camera management (Unity Camera + Cinemachine). **Tier 1** (always available): create, target, lens, priority, list, screenshot. **Tier 2** (requires `com.unity.cinemachine`): brain, body/aim/noise pipeline, extensions, blending, force/release. 7 presets: follow, third_person, freelook, dolly, static, top_down, side_scroller. Resource: `mcpforunity://scene/cameras`. Use `ping` to check Cinemachine availability. See [tools-reference.md](references/tools-reference.md#camera-tools). | +| **Graphics** | `manage_graphics` | Rendering and post-processing management. 33 actions across 5 groups: **Volume** (create/configure volumes and effects, URP/HDRP), **Bake** (lightmaps, light probes, reflection probes, Edit mode only), **Stats** (draw calls, batches, memory), **Pipeline** (quality levels, pipeline settings), **Features** (URP renderer features: add, remove, toggle, reorder). Resources: `mcpforunity://scene/volumes`, `mcpforunity://rendering/stats`, `mcpforunity://pipeline/renderer-features`. Use `ping` to check pipeline status. See [tools-reference.md](references/tools-reference.md#graphics-tools). | | **ProBuilder** | `manage_probuilder` | 3D modeling, mesh editing, complex geometry. **When `com.unity.probuilder` is installed, prefer ProBuilder shapes over primitive GameObjects** for editable geometry, multi-material faces, or complex shapes. Supports 12 shape types, face/edge/vertex editing, smoothing, and per-face materials. See [ProBuilder Guide](references/probuilder-guide.md). | | **UI** | `manage_ui`, `batch_execute` with `manage_gameobject` + `manage_components` | **UI Toolkit**: Use `manage_ui` to create UXML/USS files, attach UIDocument, inspect visual trees. **uGUI (Canvas)**: Use `batch_execute` for Canvas, Panel, Button, Text, Slider, Toggle, Input Field. **Read `mcpforunity://project/info` first** to detect uGUI/TMP/Input System/UI Toolkit availability. (see [UI workflows](references/workflows.md#ui-creation-workflows)) | diff --git a/unity-mcp-skill/references/probuilder-guide.md b/unity-mcp-skill/references/probuilder-guide.md index d987b36fc..a65ad841d 100644 --- a/unity-mcp-skill/references/probuilder-guide.md +++ b/unity-mcp-skill/references/probuilder-guide.md @@ -441,4 +441,4 @@ These actions exist in the API but have known bugs that prevent them from workin 5. **Auto-smooth for organic shapes** -- 30 degrees is a good default 6. **Prefer ProBuilder over primitives** -- when the package is available and you need editable geometry 7. **Use batch_execute** -- for creating multiple shapes or repetitive operations -8. **Screenshot to verify** -- use `manage_scene(action="screenshot", include_image=True)` to check visual results after complex edits +8. **Screenshot to verify** -- use `manage_camera(action="screenshot", include_image=True)` to check visual results after complex edits diff --git a/unity-mcp-skill/references/resources-reference.md b/unity-mcp-skill/references/resources-reference.md index ec6859f12..a2aa74e3e 100644 --- a/unity-mcp-skill/references/resources-reference.md +++ b/unity-mcp-skill/references/resources-reference.md @@ -6,6 +6,7 @@ Resources provide read-only access to Unity state. Use resources to inspect befo - [Editor State Resources](#editor-state-resources) - [Camera Resources](#camera-resources) +- [Graphics Resources](#graphics-resources) - [Scene & GameObject Resources](#scene--gameobject-resources) - [Prefab Resources](#prefab-resources) - [Project Resources](#project-resources) @@ -22,7 +23,7 @@ All resources use `mcpforunity://` scheme: mcpforunity://{category}/{resource_path}[?query_params] ``` -**Categories:** `editor`, `scene`, `prefab`, `project`, `menu-items`, `custom-tools`, `tests`, `instances` +**Categories:** `editor`, `scene`, `prefab`, `project`, `pipeline`, `rendering`, `menu-items`, `custom-tools`, `tests`, `instances` --- @@ -180,6 +181,102 @@ mcpforunity://{category}/{resource_path}[?query_params] --- +## Graphics Resources + +### mcpforunity://scene/volumes + +**Purpose:** List all Volume components in the scene with effects and parameters. Read this before using `manage_graphics` volume actions. + +**Returns:** +```json +{ + "pipeline": "Universal (URP)", + "volumes": [ + { + "name": "PostProcessVolume", + "instance_id": -24600, + "is_global": true, + "weight": 1.0, + "priority": 0, + "blend_distance": 0, + "profile": "MyProfile", + "profile_path": "Assets/Settings/MyProfile.asset", + "effects": [ + { + "type": "Bloom", + "active": true, + "overridden_params": ["intensity", "threshold", "scatter"] + }, + { + "type": "Vignette", + "active": true, + "overridden_params": ["intensity", "smoothness"] + } + ] + } + ] +} +``` + +**Key Fields:** +- `is_global`: Whether the volume applies everywhere or only within its collider bounds +- `effects[].overridden_params`: Which parameters are actively overridden (not using defaults) +- `profile_path`: Empty string for embedded profiles, asset path for shared profiles + +**Use with:** `manage_graphics` volume actions (volume_create, volume_add_effect, volume_set_effect, etc.) + +### mcpforunity://rendering/stats + +**Purpose:** Current rendering performance counters (draw calls, batches, triangles, memory). + +**Returns:** +```json +{ + "draw_calls": 42, + "batches": 35, + "set_pass_calls": 12, + "triangles": 15234, + "vertices": 8456, + "dynamic_batches": 5, + "static_batches": 20, + "shadow_casters": 3, + "render_textures": 8, + "render_textures_bytes": 16777216, + "visible_skinned_meshes": 2 +} +``` + +**Use with:** `manage_graphics` stats actions (stats_get, stats_list_counters, stats_get_memory) + +### mcpforunity://pipeline/renderer-features + +**Purpose:** URP renderer features on the active renderer (SSAO, Decals, etc.). + +**Returns:** +```json +{ + "rendererDataName": "PC_Renderer", + "features": [ + { + "index": 0, + "name": "ScreenSpaceAmbientOcclusion", + "type": "ScreenSpaceAmbientOcclusion", + "isActive": true, + "properties": { "m_Settings": "Generic" } + } + ] +} +``` + +**Key Fields:** +- `index`: Position in the feature list (use for feature_toggle, feature_remove, feature_configure) +- `isActive`: Whether the feature is enabled +- `rendererDataName`: Which URP renderer data asset is active + +**Use with:** `manage_graphics` feature actions (feature_list, feature_add, feature_remove, feature_toggle, etc.) + +--- + ## Scene & GameObject Resources ### mcpforunity://scene/gameobject-api diff --git a/unity-mcp-skill/references/tools-reference.md b/unity-mcp-skill/references/tools-reference.md index 16de62132..2cc36daf8 100644 --- a/unity-mcp-skill/references/tools-reference.md +++ b/unity-mcp-skill/references/tools-reference.md @@ -16,6 +16,7 @@ Complete reference for all MCP tools. Each tool includes parameters, types, and - [Editor Control Tools](#editor-control-tools) - [Testing Tools](#testing-tools) - [Camera Tools](#camera-tools) +- [Graphics Tools](#graphics-tools) - [ProBuilder Tools](#probuilder-tools) --- @@ -112,7 +113,7 @@ manage_scene( ) # Screenshot (file only — saves to Assets/Screenshots/) -manage_scene(action="screenshot") +manage_camera(action="screenshot") # Screenshot with inline image (base64 PNG returned to AI) manage_scene( @@ -689,8 +690,14 @@ manage_editor(action="remove_tag", tag_name="OldTag") manage_editor(action="add_layer", layer_name="Projectiles") manage_editor(action="remove_layer", layer_name="OldLayer") + +# Package deployment (no confirmation dialog — designed for LLM-driven iteration) +manage_editor(action="deploy_package") # Copy configured MCPForUnity source into installed package +manage_editor(action="restore_package") # Revert to pre-deployment backup ``` +**Deploy workflow:** Set the source path in MCP for Unity Advanced Settings first. `deploy_package` copies the source into the project's package location, creates a backup, and triggers `AssetDatabase.Refresh`. Follow with `refresh_unity(wait_for_ready=True)` to wait for recompilation. + ### execute_menu_item Execute any Unity menu item. @@ -919,6 +926,151 @@ manage_camera(action="list_cameras") --- +## Graphics Tools + +### manage_graphics + +Unified rendering and graphics management: volumes/post-processing, light baking, rendering stats, pipeline configuration, and URP renderer features. Requires URP or HDRP for volume/feature actions. Use `ping` to check pipeline status and available features. + +**Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `action` | string | Yes | Action to perform (see categories below) | +| `target` | string | Sometimes | Target object name or instance ID | +| `effect` | string | Sometimes | Effect type name (e.g., `Bloom`, `Vignette`) | +| `properties` | dict | No | Action-specific properties to set | +| `parameters` | dict | No | Effect parameter values | +| `settings` | dict | No | Bake or pipeline settings | +| `name` | string | No | Name for created objects | +| `profile_path` | string | No | Asset path for VolumeProfile | +| `path` | string | No | Asset path (for `volume_create_profile`) | +| `position` | list[float] | No | Position [x,y,z] | + +**Actions by category:** + +**Status:** +- `ping` — Check render pipeline type, available features, and package status + +**Volume (require URP/HDRP):** +- `volume_create` — Create a Volume GameObject with optional effects. Properties: `name`, `is_global` (default true), `weight` (0-1), `priority`, `profile_path` (existing profile), `effects` (list of effect defs) +- `volume_add_effect` — Add effect override to a Volume. Params: `target` (Volume GO), `effect` (e.g., "Bloom") +- `volume_set_effect` — Set effect parameters. Params: `target`, `effect`, `parameters` (dict of param name to value) +- `volume_remove_effect` — Remove effect override. Params: `target`, `effect` +- `volume_get_info` — Get Volume details (profile, effects, parameters). Params: `target` +- `volume_set_properties` — Set Volume component properties (weight, priority, isGlobal). Params: `target`, `properties` +- `volume_list_effects` — List all available volume effects for the active pipeline +- `volume_create_profile` — Create a standalone VolumeProfile asset. Params: `path`, `effects` (optional) + +**Bake (Edit mode only):** +- `bake_start` — Start lightmap bake. Params: `async_bake` (default true) +- `bake_cancel` — Cancel in-progress bake +- `bake_status` — Check bake progress +- `bake_clear` — Clear baked lightmap data +- `bake_reflection_probe` — Bake a specific reflection probe. Params: `target` +- `bake_get_settings` — Get current Lightmapping settings +- `bake_set_settings` — Set Lightmapping settings. Params: `settings` (dict) +- `bake_create_light_probe_group` — Create a Light Probe Group. Params: `name`, `position`, `grid_size` [x,y,z], `spacing` +- `bake_create_reflection_probe` — Create a Reflection Probe. Params: `name`, `position`, `size` [x,y,z], `resolution`, `mode`, `hdr`, `box_projection` +- `bake_set_probe_positions` — Set Light Probe positions manually. Params: `target`, `positions` (array of [x,y,z]) + +**Stats:** +- `stats_get` — Get rendering counters (draw calls, batches, triangles, vertices, etc.) +- `stats_list_counters` — List all available ProfilerRecorder counters +- `stats_set_scene_debug` — Set Scene View debug/draw mode. Params: `mode` +- `stats_get_memory` — Get rendering memory usage + +**Pipeline:** +- `pipeline_get_info` — Get active render pipeline info (type, quality level, asset paths) +- `pipeline_set_quality` — Switch quality level. Params: `level` (name or index) +- `pipeline_get_settings` — Get pipeline asset settings +- `pipeline_set_settings` — Set pipeline asset settings. Params: `settings` (dict) + +**Features (URP only):** +- `feature_list` — List renderer features on the active URP renderer +- `feature_add` — Add a renderer feature. Params: `feature_type`, `name`, `material` (for full-screen effects) +- `feature_remove` — Remove a renderer feature. Params: `index` or `name` +- `feature_configure` — Set feature properties. Params: `index` or `name`, `properties` (dict) +- `feature_toggle` — Enable/disable a feature. Params: `index` or `name`, `active` (bool) +- `feature_reorder` — Reorder features. Params: `order` (list of indices) + +**Examples:** + +```python +# Check pipeline status +manage_graphics(action="ping") + +# Create a global post-processing volume with Bloom and Vignette +manage_graphics(action="volume_create", name="PostProcessing", is_global=True, + effects=[ + {"type": "Bloom", "parameters": {"intensity": 1.5, "threshold": 0.9}}, + {"type": "Vignette", "parameters": {"intensity": 0.4}} + ]) + +# Add an effect to an existing volume +manage_graphics(action="volume_add_effect", target="PostProcessing", effect="ColorAdjustments") + +# Configure effect parameters +manage_graphics(action="volume_set_effect", target="PostProcessing", + effect="ColorAdjustments", parameters={"postExposure": 0.5, "saturation": 10}) + +# Get volume info +manage_graphics(action="volume_get_info", target="PostProcessing") + +# List all available effects for the active pipeline +manage_graphics(action="volume_list_effects") + +# Create a VolumeProfile asset +manage_graphics(action="volume_create_profile", path="Assets/Settings/MyProfile.asset", + effects=[{"type": "Bloom"}, {"type": "Tonemapping"}]) + +# Start async lightmap bake +manage_graphics(action="bake_start", async_bake=True) + +# Check bake progress +manage_graphics(action="bake_status") + +# Create a Light Probe Group with a 3x2x3 grid +manage_graphics(action="bake_create_light_probe_group", name="ProbeGrid", + position=[0, 1, 0], grid_size=[3, 2, 3], spacing=2.0) + +# Create a Reflection Probe +manage_graphics(action="bake_create_reflection_probe", name="RoomProbe", + position=[0, 2, 0], size=[10, 5, 10], resolution=256, hdr=True) + +# Get rendering stats +manage_graphics(action="stats_get") + +# Get memory usage +manage_graphics(action="stats_get_memory") + +# Get pipeline info +manage_graphics(action="pipeline_get_info") + +# Switch quality level +manage_graphics(action="pipeline_set_quality", level="High") + +# List URP renderer features +manage_graphics(action="feature_list") + +# Add a full-screen renderer feature +manage_graphics(action="feature_add", feature_type="FullScreenPassRendererFeature", + name="NightVision", material="Assets/Materials/NightVision.mat") + +# Toggle a feature off +manage_graphics(action="feature_toggle", index=0, active=False) + +# Reorder features +manage_graphics(action="feature_reorder", order=[2, 0, 1]) +``` + +**Resources:** +- `mcpforunity://scene/volumes` — Lists all Volume components in the scene with their profiles and effects +- `mcpforunity://rendering/stats` — Current rendering performance counters +- `mcpforunity://pipeline/renderer-features` — URP renderer features on the active renderer + +--- + ## ProBuilder Tools ### manage_probuilder diff --git a/unity-mcp-skill/references/workflows.md b/unity-mcp-skill/references/workflows.md index d5add9ccf..8d1f83fb7 100644 --- a/unity-mcp-skill/references/workflows.md +++ b/unity-mcp-skill/references/workflows.md @@ -13,6 +13,8 @@ Common workflows and patterns for effective Unity-MCP usage. - [UI Creation Workflows](#ui-creation-workflows) - [Camera & Cinemachine Workflows](#camera--cinemachine-workflows) - [ProBuilder Workflows](#probuilder-workflows) +- [Graphics & Rendering Workflows](#graphics--rendering-workflows) +- [Package Deployment Workflows](#package-deployment-workflows) - [Batch Operations](#batch-operations) --- @@ -156,7 +158,7 @@ manage_gameobject(action="modify", target="Main Camera", position=[0, 5, -10], rotation=[30, 0, 0]) # 5. Verify with screenshot -manage_scene(action="screenshot") +manage_camera(action="screenshot") # 6. Save scene manage_scene(action="save") @@ -346,7 +348,7 @@ manage_material( ) # 3. Verify visually -manage_scene(action="screenshot") +manage_camera(action="screenshot") ``` ### Create Procedural Texture @@ -556,7 +558,7 @@ for item in hierarchy["data"]["items"]: print(f"Object {item['name']} fell through floor!") # 3. Visual verification -manage_scene(action="screenshot") +manage_camera(action="screenshot") ``` --- @@ -1583,7 +1585,7 @@ manage_probuilder(action="auto_smooth", target="Pillar1", properties={"angleThreshold": 45}) # 6. Screenshot to verify -manage_scene(action="screenshot", include_image=True, max_resolution=512) +manage_camera(action="screenshot", include_image=True, max_resolution=512) ``` ### Edit-Verify Loop Pattern @@ -1610,6 +1612,177 @@ manage_probuilder(action="delete_faces", target="Obj", properties={"faceIndices" --- +## Graphics & Rendering Workflows + +### Setting Up Post-Processing + +Add post-processing effects to a URP/HDRP scene using Volumes. + +```python +# 1. Check pipeline status and available effects +manage_graphics(action="ping") + +# 2. List available volume effects for the active pipeline +manage_graphics(action="volume_list_effects") + +# 3. Create a global post-processing volume with common effects +manage_graphics(action="volume_create", name="GlobalPostProcess", is_global=True, + effects=[ + {"type": "Bloom", "parameters": {"intensity": 1.0, "threshold": 0.9, "scatter": 0.7}}, + {"type": "Vignette", "parameters": {"intensity": 0.35}}, + {"type": "Tonemapping", "parameters": {"mode": 1}}, + {"type": "ColorAdjustments", "parameters": {"postExposure": 0.2, "contrast": 10}} + ]) + +# 4. Verify the volume was created +# Read mcpforunity://scene/volumes + +# 5. Fine-tune an effect parameter +manage_graphics(action="volume_set_effect", target="GlobalPostProcess", + effect="Bloom", parameters={"intensity": 1.5}) + +# 6. Screenshot to verify visual result +manage_camera(action="screenshot", include_image=True, max_resolution=512) +``` + +**Tips:** +- Always `ping` first to confirm URP/HDRP is active. Volumes do nothing on Built-in RP. +- Use `volume_list_effects` to discover available effect types for the active pipeline (URP and HDRP have different sets). +- Use `volume_get_info` to inspect current effect parameters before modifying. +- Create a reusable VolumeProfile asset with `volume_create_profile` and reference it via `profile_path` on multiple volumes. + +### Adding a Full-Screen Effect via Renderer Features (URP) + +Add a custom full-screen shader pass using URP Renderer Features. + +```python +# 1. Check pipeline and confirm URP +manage_graphics(action="ping") + +# 2. Create a material for the full-screen effect +manage_material(action="create", + material_path="Assets/Materials/GrayscaleEffect.mat", + shader="Shader Graphs/GrayscaleFullScreen") + +# 3. List current renderer features +manage_graphics(action="feature_list") + +# 4. Add a FullScreenPassRendererFeature with the material +manage_graphics(action="feature_add", + feature_type="FullScreenPassRendererFeature", + name="GrayscalePass", + material="Assets/Materials/GrayscaleEffect.mat") + +# 5. Verify it was added +manage_graphics(action="feature_list") + +# 6. Toggle it on/off to compare +manage_graphics(action="feature_toggle", index=0, active=False) # disable +manage_camera(action="screenshot", include_image=True, max_resolution=512) + +manage_graphics(action="feature_toggle", index=0, active=True) # re-enable +manage_camera(action="screenshot", include_image=True, max_resolution=512) + +# 7. Reorder features if needed (execution order matters) +manage_graphics(action="feature_reorder", order=[1, 0, 2]) +``` + +**Tips:** +- Renderer Features are URP-only. `feature_*` actions return an error on HDRP or Built-in RP. +- Read `mcpforunity://pipeline/renderer-features` to inspect features without modifying. +- Feature execution order affects the final image. Use `feature_reorder` to control pass ordering. + +### Configuring Light Baking + +Set up lightmaps, light probes, and reflection probes for baked GI. + +```python +# 1. Set lights to Baked or Mixed mode +manage_components(action="set_property", target="Directional Light", + component_type="Light", properties={"lightmapBakeType": 1}) # 1 = Mixed + +# 2. Mark static objects for lightmapping +manage_gameobject(action="modify", target="Environment", + component_properties={"StaticFlags": "ContributeGI"}) + +# 3. Configure lightmap settings +manage_graphics(action="bake_get_settings") +manage_graphics(action="bake_set_settings", settings={ + "lightmapper": 1, # 1 = Progressive GPU + "directSamples": 32, + "indirectSamples": 128, + "maxBounces": 4, + "lightmapResolution": 40 +}) + +# 4. Place light probes for dynamic objects +manage_graphics(action="bake_create_light_probe_group", name="MainProbeGrid", + position=[0, 1.5, 0], grid_size=[5, 3, 5], spacing=3.0) + +# 5. Place a reflection probe for an interior room +manage_graphics(action="bake_create_reflection_probe", name="RoomReflection", + position=[0, 2, 0], size=[8, 4, 8], resolution=256, + hdr=True, box_projection=True) + +# 6. Start async bake +manage_graphics(action="bake_start", async_bake=True) + +# 7. Poll bake status +manage_graphics(action="bake_status") +# Repeat until complete + +# 8. Bake the reflection probe separately if needed +manage_graphics(action="bake_reflection_probe", target="RoomReflection") + +# 9. Check rendering stats after bake +manage_graphics(action="stats_get") +``` + +**Tips:** +- Baking only works in Edit mode. If the editor is in Play mode, `bake_start` will fail. +- Use `bake_cancel` to abort a long bake. +- `bake_clear` removes all baked data (lightmaps, probes). Use before re-baking from scratch. +- For large scenes, use `async_bake=True` (default) and poll `bake_status` periodically. + +--- + +## Package Deployment Workflows + +### Iterative Development Loop (Edit → Deploy → Test) + +Use `deploy_package` to copy your local MCPForUnity source into the project's installed package location. This bypasses the UI dialog and triggers recompilation automatically. + +```python +# Prerequisites: Set the MCPForUnity source path in Advanced Settings first. + +# 1. Make code changes (e.g., edit C# tools) +# script_apply_edits or create_script as needed + +# 2. Deploy the updated package (copies source → installed package, creates backup) +manage_editor(action="deploy_package") + +# 3. Wait for recompilation to finish +refresh_unity(mode="force", compile="request", wait_for_ready=True) + +# 4. Check for compilation errors +read_console(types=["error"], count=10, include_stacktrace=True) + +# 5. Test the changes +run_tests(mode="EditMode") +``` + +### Rollback After Failed Deploy + +```python +# Restore from the automatic pre-deployment backup +manage_editor(action="restore_package") + +# Wait for recompilation +refresh_unity(mode="force", compile="request", wait_for_ready=True) +``` + +--- + ## Batch Operations ### Mass Property Update