diff --git a/MCPForUnity/Editor/Resources/Scene/CamerasResource.cs b/MCPForUnity/Editor/Resources/Scene/CamerasResource.cs new file mode 100644 index 000000000..a8850ca91 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/CamerasResource.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools.Cameras; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Resources.Scene +{ + [McpForUnityResource("get_cameras")] + public static class CamerasResource + { + public static object HandleCommand(JObject @params) + { + try + { + return CameraControl.ListCameras(@params ?? new JObject()); + } + catch (Exception e) + { + McpLog.Error($"[CamerasResource] Error listing cameras: {e}"); + return new ErrorResponse($"Error listing cameras: {e.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Resources/Scene/CamerasResource.cs.meta b/MCPForUnity/Editor/Resources/Scene/CamerasResource.cs.meta new file mode 100644 index 000000000..e8fb6d0a4 --- /dev/null +++ b/MCPForUnity/Editor/Resources/Scene/CamerasResource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 68c487cd2b284b09bcdce22f76127e95 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Cameras.meta b/MCPForUnity/Editor/Tools/Cameras.meta new file mode 100644 index 000000000..9b8412a1e --- /dev/null +++ b/MCPForUnity/Editor/Tools/Cameras.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 34337a86f21c4749be2a115f48fe6700 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Cameras/CameraConfigure.cs b/MCPForUnity/Editor/Tools/Cameras/CameraConfigure.cs new file mode 100644 index 000000000..214be1e85 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Cameras/CameraConfigure.cs @@ -0,0 +1,411 @@ +using System; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Cameras +{ + internal static class CameraConfigure + { + #region Tier 1 — Basic Camera + + internal static object SetBasicCameraTarget(JObject @params) + { + var go = CameraHelpers.FindTargetGameObject(@params); + if (go == null) return new ErrorResponse("Target Camera not found."); + + var cam = go.GetComponent(); + if (cam == null) return new ErrorResponse($"No Camera component on '{go.name}'."); + + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + var lookAtToken = props["lookAt"] ?? props["look_at"] ?? props["follow"]; + if (lookAtToken == null) + return new ErrorResponse("'follow' or 'lookAt' property is required."); + + var target = CameraHelpers.ResolveGameObjectRef(lookAtToken); + if (target == null) + return new ErrorResponse($"Target '{lookAtToken}' not found."); + + Undo.RecordObject(go.transform, "Set Camera Target"); + go.transform.LookAt(target.transform); + CameraHelpers.MarkDirty(go); + + return new + { + success = true, + message = $"Camera '{go.name}' now looking at '{target.name}'.", + data = new { instanceID = go.GetInstanceID() } + }; + } + + internal static object SetBasicCameraLens(JObject @params) + { + var go = CameraHelpers.FindTargetGameObject(@params); + if (go == null) return new ErrorResponse("Target Camera not found."); + + var cam = go.GetComponent(); + if (cam == null) return new ErrorResponse($"No Camera component on '{go.name}'."); + + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + Undo.RecordObject(cam, "Set Camera Lens"); + + if (props["fieldOfView"] != null) + cam.fieldOfView = ParamCoercion.CoerceFloat(props["fieldOfView"], cam.fieldOfView); + if (props["nearClipPlane"] != null) + cam.nearClipPlane = ParamCoercion.CoerceFloat(props["nearClipPlane"], cam.nearClipPlane); + if (props["farClipPlane"] != null) + cam.farClipPlane = ParamCoercion.CoerceFloat(props["farClipPlane"], cam.farClipPlane); + if (props["orthographicSize"] != null) + cam.orthographicSize = ParamCoercion.CoerceFloat(props["orthographicSize"], cam.orthographicSize); + + CameraHelpers.MarkDirty(go); + return new + { + success = true, + message = $"Lens properties set on Camera '{go.name}'.", + data = new { instanceID = go.GetInstanceID() } + }; + } + + internal static object SetBasicCameraPriority(JObject @params) + { + var go = CameraHelpers.FindTargetGameObject(@params); + if (go == null) return new ErrorResponse("Target Camera not found."); + + var cam = go.GetComponent(); + if (cam == null) return new ErrorResponse($"No Camera component on '{go.name}'."); + + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + float depth = ParamCoercion.CoerceFloat(props["priority"], cam.depth); + + Undo.RecordObject(cam, "Set Camera Depth"); + cam.depth = depth; + CameraHelpers.MarkDirty(go); + + return new + { + success = true, + message = $"Camera '{go.name}' depth set to {depth}.", + data = new { instanceID = go.GetInstanceID(), depth } + }; + } + + #endregion + + #region Tier 2 — Cinemachine + + internal static object SetCinemachineTarget(JObject @params) + { + var cmCamera = CameraHelpers.FindCinemachineCamera(@params); + if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found."); + + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + + Undo.RecordObject(cmCamera, "Set Cinemachine Target"); + + if (props.ContainsKey("follow")) + CameraHelpers.SetTransformTarget(cmCamera, "Follow", props["follow"]); + if (props.ContainsKey("lookAt") || props.ContainsKey("look_at")) + CameraHelpers.SetTransformTarget(cmCamera, "LookAt", props["lookAt"] ?? props["look_at"]); + + CameraHelpers.MarkDirty(cmCamera.gameObject); + + return new + { + success = true, + message = $"Targets set on CinemachineCamera '{cmCamera.gameObject.name}'.", + data = new { instanceID = cmCamera.gameObject.GetInstanceID() } + }; + } + + internal static object SetCinemachineLens(JObject @params) + { + var cmCamera = CameraHelpers.FindCinemachineCamera(@params); + if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found."); + + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + Undo.RecordObject(cmCamera, "Set Cinemachine Lens"); + + // Lens is a struct field — use SerializedProperty for reliable setting + using var so = new SerializedObject(cmCamera); + var lensProp = so.FindProperty("Lens") ?? so.FindProperty("m_Lens"); + if (lensProp == null) + return new ErrorResponse("Could not find Lens property on CinemachineCamera."); + + SetFloatSubProp(lensProp, "FieldOfView", props["fieldOfView"]); + SetFloatSubProp(lensProp, "NearClipPlane", props["nearClipPlane"]); + SetFloatSubProp(lensProp, "FarClipPlane", props["farClipPlane"]); + SetFloatSubProp(lensProp, "OrthographicSize", props["orthographicSize"]); + SetFloatSubProp(lensProp, "Dutch", props["dutch"]); + + so.ApplyModifiedProperties(); + CameraHelpers.MarkDirty(cmCamera.gameObject); + + return new + { + success = true, + message = $"Lens properties set on CinemachineCamera '{cmCamera.gameObject.name}'.", + data = new { instanceID = cmCamera.gameObject.GetInstanceID() } + }; + } + + internal static object SetCinemachinePriority(JObject @params) + { + var cmCamera = CameraHelpers.FindCinemachineCamera(@params); + if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found."); + + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + int priority = ParamCoercion.CoerceInt(props["priority"], 10); + + // PrioritySettings is a struct with Enabled + m_Value — use SerializedProperty + using var so = new SerializedObject(cmCamera); + var priorityProp = so.FindProperty("Priority"); + if (priorityProp != null) + { + var enabledProp = priorityProp.FindPropertyRelative("Enabled"); + var valueProp = priorityProp.FindPropertyRelative("m_Value"); + if (enabledProp != null) enabledProp.boolValue = true; + if (valueProp != null) valueProp.intValue = priority; + so.ApplyModifiedProperties(); + } + else + { + Undo.RecordObject(cmCamera, "Set Cinemachine Priority"); + CameraHelpers.SetReflectionProperty(cmCamera, "Priority", priority); + } + CameraHelpers.MarkDirty(cmCamera.gameObject); + + return new + { + success = true, + message = $"Priority set to {priority} on CinemachineCamera '{cmCamera.gameObject.name}'.", + data = new { instanceID = cmCamera.gameObject.GetInstanceID(), priority } + }; + } + + internal static object SetBody(JObject @params) + { + var cmCamera = CameraHelpers.FindCinemachineCamera(@params); + if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found."); + + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + var go = cmCamera.gameObject; + + // Optionally swap body component + string bodyTypeName = ParamCoercion.CoerceString(props["bodyType"] ?? props["body_type"], null); + Component bodyComponent; + + if (bodyTypeName != null) + { + bodyComponent = SwapPipelineComponent(go, "Body", bodyTypeName); + if (bodyComponent == null) + return new ErrorResponse($"Could not resolve body component type '{bodyTypeName}'."); + } + else + { + bodyComponent = CameraHelpers.GetPipelineComponent(cmCamera, "Body"); + if (bodyComponent == null) + return new ErrorResponse("No Body component found on this CinemachineCamera. Provide 'bodyType' to add one."); + } + + // Set properties on body component + SetComponentProperties(bodyComponent, props, new[] { "bodyType", "body_type" }); + CameraHelpers.MarkDirty(go); + + return new + { + success = true, + message = $"Body configured on CinemachineCamera '{go.name}'.", + data = new { instanceID = go.GetInstanceID(), body = bodyComponent.GetType().Name } + }; + } + + internal static object SetAim(JObject @params) + { + var cmCamera = CameraHelpers.FindCinemachineCamera(@params); + if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found."); + + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + var go = cmCamera.gameObject; + + string aimTypeName = ParamCoercion.CoerceString(props["aimType"] ?? props["aim_type"], null); + Component aimComponent; + + if (aimTypeName != null) + { + aimComponent = SwapPipelineComponent(go, "Aim", aimTypeName); + if (aimComponent == null) + return new ErrorResponse($"Could not resolve aim component type '{aimTypeName}'."); + } + else + { + aimComponent = CameraHelpers.GetPipelineComponent(cmCamera, "Aim"); + if (aimComponent == null) + return new ErrorResponse("No Aim component found. Provide 'aimType' to add one."); + } + + SetComponentProperties(aimComponent, props, new[] { "aimType", "aim_type" }); + CameraHelpers.MarkDirty(go); + + return new + { + success = true, + message = $"Aim configured on CinemachineCamera '{go.name}'.", + data = new { instanceID = go.GetInstanceID(), aim = aimComponent.GetType().Name } + }; + } + + internal static object SetNoise(JObject @params) + { + var cmCamera = CameraHelpers.FindCinemachineCamera(@params); + if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found."); + + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + var go = cmCamera.gameObject; + + // Get or add noise component + var noiseType = CameraHelpers.ResolveComponentType("CinemachineBasicMultiChannelPerlin"); + if (noiseType == null) + return new ErrorResponse("CinemachineBasicMultiChannelPerlin type not found."); + + var noiseComponent = go.GetComponent(noiseType); + bool added = false; + if (noiseComponent == null) + { + noiseComponent = Undo.AddComponent(go, noiseType); + added = true; + } + + Undo.RecordObject(noiseComponent, "Set Cinemachine Noise"); + SetComponentProperties(noiseComponent, props, Array.Empty()); + CameraHelpers.MarkDirty(go); + + return new + { + success = true, + message = added + ? $"Added noise to CinemachineCamera '{go.name}'." + : $"Noise configured on CinemachineCamera '{go.name}'.", + data = new { instanceID = go.GetInstanceID(), added } + }; + } + + internal static object AddExtension(JObject @params) + { + var cmCamera = CameraHelpers.FindCinemachineCamera(@params); + if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found."); + + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + string extTypeName = ParamCoercion.CoerceString( + props["extensionType"] ?? props["extension_type"], null); + if (string.IsNullOrEmpty(extTypeName)) + return new ErrorResponse("'extensionType' property is required."); + + var extType = CameraHelpers.ResolveComponentType(extTypeName); + if (extType == null) + return new ErrorResponse($"Extension type '{extTypeName}' not found."); + + var go = cmCamera.gameObject; + var existing = go.GetComponent(extType); + if (existing != null) + return new { success = true, message = $"Extension '{extTypeName}' already exists on '{go.name}'." }; + + var ext = Undo.AddComponent(go, extType); + SetComponentProperties(ext, props, new[] { "extensionType", "extension_type" }); + CameraHelpers.MarkDirty(go); + + return new + { + success = true, + message = $"Extension '{extTypeName}' added to CinemachineCamera '{go.name}'.", + data = new { instanceID = go.GetInstanceID(), extensionType = extTypeName } + }; + } + + internal static object RemoveExtension(JObject @params) + { + var cmCamera = CameraHelpers.FindCinemachineCamera(@params); + if (cmCamera == null) return new ErrorResponse("Target CinemachineCamera not found."); + + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + string extTypeName = ParamCoercion.CoerceString( + props["extensionType"] ?? props["extension_type"], null); + if (string.IsNullOrEmpty(extTypeName)) + return new ErrorResponse("'extensionType' property is required."); + + var extType = CameraHelpers.ResolveComponentType(extTypeName); + if (extType == null) + return new ErrorResponse($"Extension type '{extTypeName}' not found."); + + var go = cmCamera.gameObject; + var ext = go.GetComponent(extType); + if (ext == null) + return new ErrorResponse($"Extension '{extTypeName}' not found on '{go.name}'."); + + Undo.DestroyObjectImmediate(ext); + CameraHelpers.MarkDirty(go); + + return new + { + success = true, + message = $"Extension '{extTypeName}' removed from CinemachineCamera '{go.name}'.", + data = new { instanceID = go.GetInstanceID() } + }; + } + + #endregion + + #region Helpers + + private static void SetFloatSubProp(SerializedProperty parent, string subPropName, JToken value) + { + if (value == null || value.Type == JTokenType.Null) return; + var sub = parent.FindPropertyRelative(subPropName) + ?? parent.FindPropertyRelative("m_" + subPropName); + if (sub != null && sub.propertyType == SerializedPropertyType.Float) + sub.floatValue = ParamCoercion.CoerceFloat(value, sub.floatValue); + } + + private static Component SwapPipelineComponent(GameObject go, string stage, string newTypeName) + { + var newType = CameraHelpers.ResolveComponentType(newTypeName); + if (newType == null) return null; + + // Remove existing component of same pipeline stage + var cmCamera = go.GetComponent(CameraHelpers.CinemachineCameraType); + if (cmCamera != null) + { + var existing = CameraHelpers.GetPipelineComponent(cmCamera, stage); + if (existing != null && existing.GetType() != newType) + Undo.DestroyObjectImmediate(existing); + } + + // Add new component if not already present + var comp = go.GetComponent(newType); + if (comp == null) + comp = Undo.AddComponent(go, newType); + + return comp; + } + + private static void SetComponentProperties(Component component, JObject props, string[] skipKeys) + { + if (component == null || props == null) return; + + var skipSet = new System.Collections.Generic.HashSet( + skipKeys, StringComparer.OrdinalIgnoreCase); + + Undo.RecordObject(component, $"Configure {component.GetType().Name}"); + + foreach (var kv in props) + { + if (skipSet.Contains(kv.Key)) continue; + ComponentOps.SetProperty(component, kv.Key, kv.Value, out _); + } + } + + #endregion + } +} diff --git a/MCPForUnity/Editor/Tools/Cameras/CameraConfigure.cs.meta b/MCPForUnity/Editor/Tools/Cameras/CameraConfigure.cs.meta new file mode 100644 index 000000000..14b746502 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Cameras/CameraConfigure.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7a26286aeede4949844309a8a952b2b0 +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 new file mode 100644 index 000000000..1b33393d1 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Cameras/CameraControl.cs @@ -0,0 +1,314 @@ +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.Cameras +{ + internal static class CameraControl + { + internal static object ListCameras(JObject @params) + { + var unityCameras = UnityEngine.Object.FindObjectsOfType(); + var cameraList = new List(); + var unityCamList = new List(); + + // Cinemachine cameras + if (CameraHelpers.HasCinemachine) + { + var cmType = CameraHelpers.CinemachineCameraType; + var allCm = UnityEngine.Object.FindObjectsOfType(cmType); + foreach (Component cm in allCm) + { + var follow = CameraHelpers.GetReflectionProperty(cm, "Follow") as Transform; + var lookAt = CameraHelpers.GetReflectionProperty(cm, "LookAt") as Transform; + var isLive = CameraHelpers.GetReflectionProperty(cm, "IsLive"); + var priority = CameraHelpers.ReadCinemachinePriority(cm); + + var body = CameraHelpers.GetPipelineComponent(cm, "Body"); + var aim = CameraHelpers.GetPipelineComponent(cm, "Aim"); + var noise = CameraHelpers.GetPipelineComponent(cm, "Noise"); + + // Collect extensions + var extensions = new List(); + var cmExtBaseType = cm.GetType().Assembly.GetType("Unity.Cinemachine.CinemachineExtension"); + if (cmExtBaseType != null) + { + foreach (var comp in cm.gameObject.GetComponents(cmExtBaseType)) + { + if (comp != null) + extensions.Add(comp.GetType().Name); + } + } + + cameraList.Add(new + { + instanceID = cm.gameObject.GetInstanceID(), + name = cm.gameObject.name, + isLive = isLive is bool b && b, + priority, + follow = follow != null ? new { name = follow.gameObject.name, instanceID = follow.gameObject.GetInstanceID() } : null, + lookAt = lookAt != null ? new { name = lookAt.gameObject.name, instanceID = lookAt.gameObject.GetInstanceID() } : null, + body = body?.GetType().Name, + aim = aim?.GetType().Name, + noise = noise?.GetType().Name, + extensions + }); + } + } + + // Unity cameras + foreach (var cam in unityCameras) + { + bool hasBrain = CameraHelpers.HasCinemachine && + cam.gameObject.GetComponent(CameraHelpers.CinemachineBrainType) != null; + unityCamList.Add(new + { + instanceID = cam.gameObject.GetInstanceID(), + name = cam.gameObject.name, + depth = cam.depth, + fieldOfView = cam.fieldOfView, + hasBrain + }); + } + + // Brain info + object brainInfo = null; + if (CameraHelpers.HasCinemachine) + { + var brain = CameraHelpers.FindBrain(); + if (brain != null) + { + var activeCam = CameraHelpers.GetReflectionProperty(brain, "ActiveVirtualCamera"); + var isBlending = CameraHelpers.GetReflectionProperty(brain, "IsBlending"); + + string activeName = null; + int? activeID = null; + if (activeCam != null) + { + var nameProp = activeCam.GetType().GetProperty("Name"); + activeName = nameProp?.GetValue(activeCam) as string; + + if (activeCam is Component activeComp) + activeID = activeComp.gameObject.GetInstanceID(); + } + + brainInfo = new + { + exists = true, + gameObject = brain.gameObject.name, + instanceID = brain.gameObject.GetInstanceID(), + activeCameraName = activeName, + activeCameraID = activeID, + isBlending = isBlending is bool bl && bl + }; + } + } + + return new + { + success = true, + data = new + { + brain = brainInfo, + cinemachineCameras = cameraList, + unityCameras = unityCamList, + cinemachineInstalled = CameraHelpers.HasCinemachine + } + }; + } + + internal static object GetBrainStatus(JObject @params) + { + var brain = CameraHelpers.FindBrain(); + if (brain == null) + return new ErrorResponse("No CinemachineBrain found in the scene."); + + var activeCam = CameraHelpers.GetReflectionProperty(brain, "ActiveVirtualCamera"); + var isBlending = CameraHelpers.GetReflectionProperty(brain, "IsBlending"); + var activeBlend = CameraHelpers.GetReflectionProperty(brain, "ActiveBlend"); + + string activeName = null; + int? activeID = null; + if (activeCam != null) + { + var nameProp = activeCam.GetType().GetProperty("Name"); + activeName = nameProp?.GetValue(activeCam) as string; + if (activeCam is Component comp) + activeID = comp.gameObject.GetInstanceID(); + } + + string blendDesc = null; + if (activeBlend != null) + { + var descProp = activeBlend.GetType().GetProperty("Description"); + blendDesc = descProp?.GetValue(activeBlend) as string; + } + + return new + { + success = true, + data = new + { + gameObject = brain.gameObject.name, + instanceID = brain.gameObject.GetInstanceID(), + activeCameraName = activeName, + activeCameraID = activeID, + isBlending = isBlending is bool b && b, + blendDescription = blendDesc + } + }; + } + + internal static object SetBlend(JObject @params) + { + var brain = CameraHelpers.FindBrain(); + if (brain == null) + return new ErrorResponse("No CinemachineBrain found. Use 'ensure_brain' first."); + + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + Undo.RecordObject(brain, "Set Camera Blend"); + + using var so = new SerializedObject(brain); + var defaultBlendProp = so.FindProperty("DefaultBlend") ?? so.FindProperty("m_DefaultBlend"); + if (defaultBlendProp == null) + return new ErrorResponse("Could not find DefaultBlend property on CinemachineBrain."); + + string style = ParamCoercion.CoerceString(props["style"], null); + if (style != null) + { + var styleProp = defaultBlendProp.FindPropertyRelative("Style") + ?? defaultBlendProp.FindPropertyRelative("m_Style"); + if (styleProp != null && styleProp.propertyType == SerializedPropertyType.Enum) + { + // Try to parse the style enum + var enumNames = styleProp.enumNames; + int idx = Array.FindIndex(enumNames, n => n.Equals(style, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) + styleProp.enumValueIndex = idx; + } + } + + float duration = ParamCoercion.CoerceFloat(props["duration"], -1f); + if (duration >= 0) + { + var timeProp = defaultBlendProp.FindPropertyRelative("Time") + ?? defaultBlendProp.FindPropertyRelative("m_Time"); + if (timeProp != null) + timeProp.floatValue = duration; + } + + so.ApplyModifiedProperties(); + CameraHelpers.MarkDirty(brain.gameObject); + + return new + { + success = true, + message = "Default blend configured on CinemachineBrain.", + data = new { instanceID = brain.gameObject.GetInstanceID() } + }; + } + + private static int _overrideId = -1; + + internal static object ForceCamera(JObject @params) + { + var brain = CameraHelpers.FindBrain(); + if (brain == null) + return new ErrorResponse("No CinemachineBrain found. Use 'ensure_brain' first."); + + var cmCamera = CameraHelpers.FindCinemachineCamera(@params); + if (cmCamera == null) + return new ErrorResponse("Target CinemachineCamera not found."); + + // Use SetCameraOverride via reflection + var brainType = brain.GetType(); + var method = brainType.GetMethod("SetCameraOverride", + BindingFlags.Public | BindingFlags.Instance); + + if (method == null) + { + // Fallback: just set high priority + CameraHelpers.SetReflectionProperty(cmCamera, "Priority", 999); + return new + { + success = true, + message = $"Set high priority on '{cmCamera.gameObject.name}' (SetCameraOverride not available).", + data = new { instanceID = cmCamera.gameObject.GetInstanceID(), method = "priority" } + }; + } + + try + { + // CM3 signature: SetCameraOverride(int overrideId, int priority, + // ICinemachineCamera camA, ICinemachineCamera camB, float weightB, float deltaTime) + // -1 for overrideId creates a new override; same cam for A+B with weight=1 = instant switch + _overrideId = (int)method.Invoke(brain, new object[] + { + _overrideId >= 0 ? _overrideId : -1, + 999, // high priority to win over all others + cmCamera, // camA (at weight=0) + cmCamera, // camB (at weight=1) — same camera = no blend + 1f, // weightB = fully on camB + -1f // deltaTime = use default + }); + } + catch (Exception ex) + { + // Fallback + CameraHelpers.SetReflectionProperty(cmCamera, "Priority", 999); + return new + { + success = true, + message = $"Forced via priority (override failed: {ex.Message}).", + data = new { instanceID = cmCamera.gameObject.GetInstanceID(), method = "priority" } + }; + } + + return new + { + success = true, + message = $"Camera overridden to '{cmCamera.gameObject.name}'.", + data = new + { + instanceID = cmCamera.gameObject.GetInstanceID(), + overrideId = _overrideId, + method = "override" + } + }; + } + + internal static object ReleaseOverride(JObject @params) + { + var brain = CameraHelpers.FindBrain(); + if (brain == null) + return new ErrorResponse("No CinemachineBrain found."); + + if (_overrideId < 0) + return new { success = true, message = "No active camera override to release." }; + + var method = brain.GetType().GetMethod("ReleaseCameraOverride", + BindingFlags.Public | BindingFlags.Instance); + + if (method != null) + { + method.Invoke(brain, new object[] { _overrideId }); + int releasedId = _overrideId; + _overrideId = -1; + return new + { + success = true, + message = "Camera override released.", + data = new { releasedOverrideId = releasedId } + }; + } + + _overrideId = -1; + return new { success = true, message = "Override state cleared." }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/Cameras/CameraControl.cs.meta b/MCPForUnity/Editor/Tools/Cameras/CameraControl.cs.meta new file mode 100644 index 000000000..bd5a65829 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Cameras/CameraControl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6644251762504798895ef138ff182d29 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Cameras/CameraCreate.cs b/MCPForUnity/Editor/Tools/Cameras/CameraCreate.cs new file mode 100644 index 000000000..b33668bd2 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Cameras/CameraCreate.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.Cameras +{ + internal static class CameraCreate + { + private static readonly Dictionary Presets = new(StringComparer.OrdinalIgnoreCase) + { + ["follow"] = ("CinemachineFollow", "CinemachineRotationComposer"), + ["third_person"] = ("CinemachineThirdPersonFollow", "CinemachineRotationComposer"), + ["freelook"] = ("CinemachineOrbitalFollow", "CinemachineRotationComposer"), + ["dolly"] = ("CinemachineSplineDolly", "CinemachineRotationComposer"), + ["static"] = (null, "CinemachineHardLookAt"), + ["top_down"] = ("CinemachineFollow", null), + ["side_scroller"] = ("CinemachinePositionComposer", null), + }; + + internal static object CreateBasicCamera(JObject @params) + { + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + string name = ParamCoercion.CoerceString(props["name"], null) ?? "Camera"; + float fov = ParamCoercion.CoerceFloat(props["fieldOfView"], 60f); + float near = ParamCoercion.CoerceFloat(props["nearClipPlane"], 0.3f); + float far = ParamCoercion.CoerceFloat(props["farClipPlane"], 1000f); + + var go = new GameObject(name); + Undo.RegisterCreatedObjectUndo(go, $"Create Camera '{name}'"); + var cam = go.AddComponent(); + cam.fieldOfView = fov; + cam.nearClipPlane = near; + cam.farClipPlane = far; + + // Position near follow target if provided + string follow = ParamCoercion.CoerceString(props["follow"], null); + if (follow != null) + { + var target = CameraHelpers.ResolveGameObjectRef(follow); + if (target != null) + go.transform.position = target.transform.position + new Vector3(0, 5, -10); + } + + // Look at target if provided + string lookAt = ParamCoercion.CoerceString(props["lookAt"] ?? props["look_at"], null); + if (lookAt != null) + { + var target = CameraHelpers.ResolveGameObjectRef(lookAt); + if (target != null) + go.transform.LookAt(target.transform); + } + + CameraHelpers.MarkDirty(go); + + return new + { + success = true, + message = $"Created basic Camera '{name}' (Cinemachine not installed — using Unity Camera).", + data = new + { + instanceID = go.GetInstanceID(), + cinemachine = false, + hint = "Install com.unity.cinemachine for presets, blending, and virtual camera features." + } + }; + } + + internal static object CreateCinemachineCamera(JObject @params) + { + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + string name = ParamCoercion.CoerceString(props["name"], null) ?? "CM Camera"; + string preset = ParamCoercion.CoerceString(props["preset"], null) ?? "follow"; + int priority = ParamCoercion.CoerceInt(props["priority"], 10); + + if (!Presets.TryGetValue(preset, out var presetDef)) + { + return new ErrorResponse( + $"Unknown preset '{preset}'. Valid presets: {string.Join(", ", Presets.Keys)}."); + } + + var go = new GameObject(name); + Undo.RegisterCreatedObjectUndo(go, $"Create CinemachineCamera '{name}'"); + + // Add CinemachineCamera component + var cmType = CameraHelpers.CinemachineCameraType; + var cmCamera = go.AddComponent(cmType); + + // PrioritySettings is a struct with Enabled + m_Value — use SerializedProperty + using (var so = new SerializedObject(cmCamera)) + { + var priorityProp = so.FindProperty("Priority"); + if (priorityProp != null) + { + var enabledProp = priorityProp.FindPropertyRelative("Enabled"); + var valueProp = priorityProp.FindPropertyRelative("m_Value"); + if (enabledProp != null) enabledProp.boolValue = true; + if (valueProp != null) valueProp.intValue = priority; + so.ApplyModifiedProperties(); + } + else + { + CameraHelpers.SetReflectionProperty(cmCamera, "Priority", priority); + } + } + + // Add Body component + string bodyName = null; + if (presetDef.body != null) + { + var bodyType = CameraHelpers.ResolveComponentType(presetDef.body); + if (bodyType != null) + { + go.AddComponent(bodyType); + bodyName = presetDef.body; + } + } + + // Add Aim component + string aimName = null; + if (presetDef.aim != null) + { + var aimType = CameraHelpers.ResolveComponentType(presetDef.aim); + if (aimType != null) + { + go.AddComponent(aimType); + aimName = presetDef.aim; + } + } + + // Set Follow target + var followToken = props["follow"]; + if (followToken != null && followToken.Type != JTokenType.Null) + CameraHelpers.SetTransformTarget(cmCamera, "Follow", followToken); + + // Set LookAt target + var lookAtToken = props["lookAt"] ?? props["look_at"]; + if (lookAtToken != null && lookAtToken.Type != JTokenType.Null) + CameraHelpers.SetTransformTarget(cmCamera, "LookAt", lookAtToken); + + CameraHelpers.MarkDirty(go); + + return new + { + success = true, + message = $"Created CinemachineCamera '{name}' with preset '{preset}'.", + data = new + { + instanceID = go.GetInstanceID(), + cinemachine = true, + preset, + priority, + body = bodyName, + aim = aimName + } + }; + } + + internal static object EnsureBrain(JObject @params) + { + var props = CameraHelpers.ExtractProperties(@params) ?? new JObject(); + + // Check if Brain already exists + var existingBrain = CameraHelpers.FindBrain(); + if (existingBrain != null) + { + return new + { + success = true, + message = $"CinemachineBrain already exists on '{existingBrain.gameObject.name}'.", + data = new + { + instanceID = existingBrain.gameObject.GetInstanceID(), + alreadyExisted = true + } + }; + } + + // Find target camera + string cameraRef = ParamCoercion.CoerceString(props["camera"], null); + UnityEngine.Camera cam; + if (cameraRef != null) + { + var camGo = CameraHelpers.ResolveGameObjectRef(cameraRef); + cam = camGo != null ? camGo.GetComponent() : null; + } + else + { + cam = CameraHelpers.FindMainCamera(); + } + + if (cam == null) + return new ErrorResponse("No Camera found to add CinemachineBrain to."); + + var brainType = CameraHelpers.CinemachineBrainType; + Undo.RecordObject(cam.gameObject, "Add CinemachineBrain"); + var brain = cam.gameObject.AddComponent(brainType); + + // Configure default blend if provided + string blendStyle = ParamCoercion.CoerceString(props["defaultBlendStyle"] ?? props["default_blend_style"], null); + float blendDuration = ParamCoercion.CoerceFloat(props["defaultBlendDuration"] ?? props["default_blend_duration"], -1f); + + if (blendStyle != null || blendDuration >= 0) + { + // Set via SerializedProperty for the DefaultBlend struct + using var so = new SerializedObject(brain); + var defaultBlendProp = so.FindProperty("DefaultBlend") ?? so.FindProperty("m_DefaultBlend"); + if (defaultBlendProp != null) + { + if (blendStyle != null) + { + var styleProp = defaultBlendProp.FindPropertyRelative("Style") + ?? defaultBlendProp.FindPropertyRelative("m_Style"); + if (styleProp != null) + { + int idx = Array.FindIndex(styleProp.enumNames, + n => n.Equals(blendStyle, StringComparison.OrdinalIgnoreCase)); + if (idx >= 0) + styleProp.enumValueIndex = idx; + } + } + if (blendDuration >= 0) + { + var timeProp = defaultBlendProp.FindPropertyRelative("Time") + ?? defaultBlendProp.FindPropertyRelative("m_Time"); + if (timeProp != null) + timeProp.floatValue = blendDuration; + } + so.ApplyModifiedProperties(); + } + } + + CameraHelpers.MarkDirty(cam.gameObject); + + return new + { + success = true, + message = $"CinemachineBrain added to '{cam.gameObject.name}'.", + data = new + { + instanceID = cam.gameObject.GetInstanceID(), + alreadyExisted = false + } + }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/Cameras/CameraCreate.cs.meta b/MCPForUnity/Editor/Tools/Cameras/CameraCreate.cs.meta new file mode 100644 index 000000000..eb59bd854 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Cameras/CameraCreate.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a849a1ac03d245fe823e4c02b9e722d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Cameras/CameraHelpers.cs b/MCPForUnity/Editor/Tools/Cameras/CameraHelpers.cs new file mode 100644 index 000000000..7255e0470 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Cameras/CameraHelpers.cs @@ -0,0 +1,269 @@ +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.Cameras +{ + internal static class CameraHelpers + { + private static bool? _hasCinemachine; + private static Type _cmCameraType; + private static Type _cmBrainType; + + internal static bool HasCinemachine + { + get + { + if (_hasCinemachine == null) + DetectCinemachine(); + return _hasCinemachine.Value; + } + } + + internal static Type CinemachineCameraType + { + get + { + if (_hasCinemachine == null) + DetectCinemachine(); + return _cmCameraType; + } + } + + internal static Type CinemachineBrainType + { + get + { + if (_hasCinemachine == null) + DetectCinemachine(); + return _cmBrainType; + } + } + + private static void DetectCinemachine() + { + _cmCameraType = UnityTypeResolver.ResolveComponent("CinemachineCamera"); + _cmBrainType = UnityTypeResolver.ResolveComponent("CinemachineBrain"); + _hasCinemachine = _cmCameraType != null && _cmBrainType != null; + } + + internal static string GetCinemachineVersion() + { + if (!HasCinemachine || _cmCameraType == null) + return null; + + try + { + var assembly = _cmCameraType.Assembly; + var version = assembly.GetName().Version; + return version?.ToString(); + } + catch + { + return "unknown"; + } + } + + internal static GameObject FindTargetGameObject(JObject @params) + { + var targetToken = @params["target"]; + if (targetToken == null) + return null; + + string searchMethod = ParamCoercion.CoerceString( + @params["searchMethod"] ?? @params["search_method"], "by_name"); + + if (targetToken.Type == JTokenType.Integer) + { + int instanceId = targetToken.Value(); + return GameObjectLookup.FindById(instanceId); + } + + string targetStr = targetToken.ToString(); + if (int.TryParse(targetStr, out int parsedId)) + { + var byId = GameObjectLookup.FindById(parsedId); + if (byId != null) return byId; + } + + return GameObjectLookup.FindByTarget(targetToken, searchMethod, true); + } + + internal static GameObject ResolveGameObjectRef(object reference) + { + if (reference == null) return null; + + if (reference is JToken jt) + { + if (jt.Type == JTokenType.Integer) + return GameObjectLookup.FindById(jt.Value()); + if (jt.Type == JTokenType.String) + { + string str = jt.ToString(); + if (int.TryParse(str, out int id)) + { + var byId = GameObjectLookup.FindById(id); + if (byId != null) return byId; + } + return GameObjectLookup.FindByTarget(jt, "by_name", true); + } + } + + if (reference is string s) + { + if (int.TryParse(s, out int id)) + { + var byId = GameObjectLookup.FindById(id); + if (byId != null) return byId; + } + var ids = GameObjectLookup.SearchGameObjects( + GameObjectLookup.SearchMethod.ByName, s, includeInactive: true, maxResults: 1); + return ids.Count > 0 ? GameObjectLookup.FindById(ids[0]) : null; + } + + return null; + } + + internal static Component FindCinemachineCamera(JObject @params) + { + if (!HasCinemachine) return null; + var go = FindTargetGameObject(@params); + return go != null ? go.GetComponent(CinemachineCameraType) : null; + } + + internal static Component FindBrain() + { + if (!HasCinemachine || _cmBrainType == null) + return null; + + return UnityEngine.Object.FindObjectOfType(_cmBrainType) as Component; + } + + internal static UnityEngine.Camera FindMainCamera() + { + var main = UnityEngine.Camera.main; + if (main != null) return main; + + var allCams = UnityEngine.Object.FindObjectsOfType(); + return allCams.Length > 0 ? allCams[0] : null; + } + + internal static JObject ExtractProperties(JObject @params) + { + var props = @params["properties"] as JObject; + if (props != null) return props; + + var propsStr = ParamCoercion.CoerceString(@params["properties"], null); + if (propsStr != null) + { + try { return JObject.Parse(propsStr); } + catch { return null; } + } + + return null; + } + + internal static object GetReflectionProperty(Component component, string propertyName) + { + if (component == null) return null; + var type = component.GetType(); + var prop = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + return prop?.GetValue(component); + } + + /// Read priority int from a CinemachineCamera component via SerializedObject. + internal static int ReadCinemachinePriority(Component cmCamera) + { + if (cmCamera == null) return 0; + using var so = new SerializedObject(cmCamera); + var priorityProp = so.FindProperty("Priority"); + if (priorityProp == null) return 0; + var enabledProp = priorityProp.FindPropertyRelative("Enabled"); + var valueProp = priorityProp.FindPropertyRelative("m_Value"); + if (enabledProp != null && !enabledProp.boolValue) return 0; + return valueProp?.intValue ?? 0; + } + + internal static bool SetReflectionProperty(Component component, string propertyName, object value) + { + if (component == null) return false; + var type = component.GetType(); + var prop = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); + if (prop == null || !prop.CanWrite) return false; + prop.SetValue(component, value); + return true; + } + + internal static void SetTransformTarget(Component cmCamera, string propertyName, JToken targetRef) + { + if (cmCamera == null) return; + + if (targetRef == null || targetRef.Type == JTokenType.Null) + { + SetReflectionProperty(cmCamera, propertyName, null); + return; + } + + var go = ResolveGameObjectRef(targetRef); + if (go != null) + SetReflectionProperty(cmCamera, propertyName, go.transform); + } + + internal static Type ResolveComponentType(string typeName) + { + return UnityTypeResolver.ResolveComponent(typeName); + } + + internal static Component GetPipelineComponent(Component cmCamera, string stageName) + { + if (cmCamera == null) return null; + var type = cmCamera.GetType(); + + // CinemachineCamera.GetCinemachineComponent(CinemachineCore.Stage stage) + var stageEnumType = type.Assembly.GetType("Unity.Cinemachine.CinemachineCore+Stage") + ?? type.Assembly.GetType("Unity.Cinemachine.CinemachineCore")?.GetNestedType("Stage"); + + if (stageEnumType == null) return null; + + object stageEnum; + try { stageEnum = Enum.Parse(stageEnumType, stageName, true); } + catch { return null; } + + var method = type.GetMethod("GetCinemachineComponent", + BindingFlags.Public | BindingFlags.Instance, + null, new[] { stageEnumType }, null); + + if (method == null) return null; + return method.Invoke(cmCamera, new[] { stageEnum }) as Component; + } + + internal static string GetFallbackSuggestion(string action) + { + return action switch + { + "set_body" or "set_aim" => "Use 'set_lens' and 'set_target' for basic camera configuration.", + "set_blend" => "Without Cinemachine, switch cameras by enabling/disabling Camera components.", + "set_noise" => "Camera shake without Cinemachine requires a custom script.", + "ensure_brain" => "CinemachineBrain requires the Cinemachine package. Basic Camera does not need a Brain.", + "get_brain_status" => "No CinemachineBrain available. Cinemachine package not installed.", + _ => "Install Cinemachine via Window > Package Manager." + }; + } + + internal static void MarkDirty(GameObject go) + { + if (go == null) return; + EditorUtility.SetDirty(go); + var prefabStage = UnityEditor.SceneManagement.PrefabStageUtility.GetCurrentPrefabStage(); + if (prefabStage != null) + UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(prefabStage.scene); + else + UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(go.scene); + } + } +} diff --git a/MCPForUnity/Editor/Tools/Cameras/CameraHelpers.cs.meta b/MCPForUnity/Editor/Tools/Cameras/CameraHelpers.cs.meta new file mode 100644 index 000000000..b36c11522 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Cameras/CameraHelpers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9664df00dcfa4a22b45ffa104bf29e46 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/Cameras/ManageCamera.cs b/MCPForUnity/Editor/Tools/Cameras/ManageCamera.cs new file mode 100644 index 000000000..ba7212bc0 --- /dev/null +++ b/MCPForUnity/Editor/Tools/Cameras/ManageCamera.cs @@ -0,0 +1,134 @@ +using System; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools.Cameras +{ + [McpForUnityTool("manage_camera", AutoRegister = false)] + public static class ManageCamera + { + 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 + { + // Tier 1: Always-available actions (basic Camera fallback) + switch (action) + { + case "ping": + return new + { + success = true, + message = CameraHelpers.HasCinemachine + ? "Cinemachine is available." + : "Cinemachine not installed. Basic Camera operations available.", + data = new + { + cinemachine = CameraHelpers.HasCinemachine, + version = CameraHelpers.GetCinemachineVersion() + } + }; + + case "create_camera": + return CameraHelpers.HasCinemachine + ? CameraCreate.CreateCinemachineCamera(@params) + : CameraCreate.CreateBasicCamera(@params); + + case "set_target": + return CameraHelpers.HasCinemachine + ? CameraConfigure.SetCinemachineTarget(@params) + : CameraConfigure.SetBasicCameraTarget(@params); + + case "set_lens": + return CameraHelpers.HasCinemachine + ? CameraConfigure.SetCinemachineLens(@params) + : CameraConfigure.SetBasicCameraLens(@params); + + case "set_priority": + return CameraHelpers.HasCinemachine + ? CameraConfigure.SetCinemachinePriority(@params) + : CameraConfigure.SetBasicCameraPriority(@params); + + case "list_cameras": + return CameraControl.ListCameras(@params); + + case "screenshot": + case "screenshot_multiview": + { + // Delegate to ManageScene's screenshot infrastructure + var shotParams = new JObject(@params); + shotParams["action"] = "screenshot"; + if (action == "screenshot_multiview") + { + shotParams["batch"] = "surround"; + shotParams["includeImage"] = true; + } + return ManageScene.HandleCommand(shotParams); + } + } + + // Tier 2: Cinemachine-only actions + if (!CameraHelpers.HasCinemachine) + { + return new ErrorResponse( + $"Action '{action}' requires the Cinemachine package (com.unity.cinemachine). " + + CameraHelpers.GetFallbackSuggestion(action)); + } + + switch (action) + { + case "ensure_brain": + return CameraCreate.EnsureBrain(@params); + + case "get_brain_status": + return CameraControl.GetBrainStatus(@params); + + case "set_body": + return CameraConfigure.SetBody(@params); + + case "set_aim": + return CameraConfigure.SetAim(@params); + + case "set_noise": + return CameraConfigure.SetNoise(@params); + + case "add_extension": + return CameraConfigure.AddExtension(@params); + + case "remove_extension": + return CameraConfigure.RemoveExtension(@params); + + case "set_blend": + return CameraControl.SetBlend(@params); + + case "force_camera": + return CameraControl.ForceCamera(@params); + + case "release_override": + return CameraControl.ReleaseOverride(@params); + + default: + return new ErrorResponse( + $"Unknown action: '{action}'. Valid actions: ping, create_camera, set_target, " + + "set_lens, set_priority, list_cameras, screenshot, screenshot_multiview, " + + "ensure_brain, get_brain_status, " + + "set_body, set_aim, set_noise, add_extension, remove_extension, " + + "set_blend, force_camera, release_override."); + } + } + catch (Exception ex) + { + McpLog.Error($"[ManageCamera] Action '{action}' failed: {ex}"); + return new ErrorResponse($"Error in action '{action}': {ex.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/Cameras/ManageCamera.cs.meta b/MCPForUnity/Editor/Tools/Cameras/ManageCamera.cs.meta new file mode 100644 index 000000000..12b0cc35a --- /dev/null +++ b/MCPForUnity/Editor/Tools/Cameras/ManageCamera.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2f61f34d01e54d5ba8e47acab546ed98 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs b/MCPForUnity/Editor/Windows/Components/Tools/McpToolsSection.cs index a6f57365c..f5e8671ed 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)) + if (IsManageSceneTool(tool) || IsManageCameraTool(tool)) { row.Add(CreateManageSceneActions()); } @@ -734,6 +734,8 @@ private static Label CreateTag(string text) private static bool IsManageSceneTool(ToolMetadata tool) => string.Equals(tool?.Name, "manage_scene", StringComparison.OrdinalIgnoreCase); + private static bool IsManageCameraTool(ToolMetadata tool) => string.Equals(tool?.Name, "manage_camera", StringComparison.OrdinalIgnoreCase); + private static bool IsBatchExecuteTool(ToolMetadata tool) => string.Equals(tool?.Name, "batch_execute", StringComparison.OrdinalIgnoreCase); private static bool IsBuiltIn(ToolMetadata tool) => tool?.IsBuiltIn ?? false; diff --git a/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs b/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs index 569a27f36..ca34e8e65 100644 --- a/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs +++ b/MCPForUnity/Runtime/Helpers/ScreenshotUtility.cs @@ -46,6 +46,7 @@ public static class ScreenshotUtility private const string ScreenshotsFolderName = "Screenshots"; private static bool s_loggedLegacyScreenCaptureFallback; private static bool? s_screenCaptureModuleAvailable; + private static System.Reflection.MethodInfo s_captureScreenshotMethod; /// /// Checks if the Screen Capture module (com.unity.modules.screencapture) is enabled. @@ -57,9 +58,15 @@ public static bool IsScreenCaptureModuleAvailable { if (!s_screenCaptureModuleAvailable.HasValue) { - // Check if ScreenCapture type exists (module might be disabled) - s_screenCaptureModuleAvailable = Type.GetType("UnityEngine.ScreenCapture, UnityEngine.ScreenCaptureModule") != null - || Type.GetType("UnityEngine.ScreenCapture, UnityEngine.CoreModule") != null; + // Check if ScreenCapture type exists (module might be disabled in Package Manager > Built-in) + var screenCaptureType = Type.GetType("UnityEngine.ScreenCapture, UnityEngine.ScreenCaptureModule") + ?? Type.GetType("UnityEngine.ScreenCapture, UnityEngine.CoreModule"); + s_screenCaptureModuleAvailable = screenCaptureType != null; + if (screenCaptureType != null) + { + s_captureScreenshotMethod = screenCaptureType.GetMethod("CaptureScreenshot", + new Type[] { typeof(string), typeof(int) }); + } } return s_screenCaptureModuleAvailable.Value; } @@ -96,24 +103,20 @@ private static Camera FindAvailableCamera() public static ScreenshotCaptureResult CaptureToAssetsFolder(string fileName = null, int superSize = 1, bool ensureUniqueFileName = true) { -#if UNITY_2022_1_OR_NEWER - // Check if Screen Capture module is available (can be disabled in Package Manager > Built-in) - if (IsScreenCaptureModuleAvailable) + // Use reflection to call ScreenCapture.CaptureScreenshot so the code compiles + // even when the Screen Capture module (com.unity.modules.screencapture) is disabled. + if (IsScreenCaptureModuleAvailable && s_captureScreenshotMethod != null) { ScreenshotCaptureResult result = PrepareCaptureResult(fileName, superSize, ensureUniqueFileName, isAsync: true); - ScreenCapture.CaptureScreenshot(result.AssetsRelativePath, result.SuperSize); + s_captureScreenshotMethod.Invoke(null, new object[] { result.AssetsRelativePath, result.SuperSize }); return result; } else { - // Module disabled - try camera fallback + // Module disabled or unavailable - try camera fallback Debug.LogWarning("[MCP for Unity] " + ScreenCaptureModuleNotAvailableError); return CaptureWithCameraFallback(fileName, superSize, ensureUniqueFileName); } -#else - // Unity < 2022.1 - always use camera fallback - return CaptureWithCameraFallback(fileName, superSize, ensureUniqueFileName); -#endif } private static ScreenshotCaptureResult CaptureWithCameraFallback(string fileName, int superSize, bool ensureUniqueFileName) diff --git a/README.md b/README.md index 4367696a3..47cda5640 100644 --- a/README.md +++ b/README.md @@ -20,15 +20,15 @@
Recent Updates -* **v9.4.8 (beta)** — 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.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. * **v9.4.6** — New `manage_animation` tool, Cline client support, stale connection detection, tool state persistence across reloads. -* **v9.4.4** — Configurable `batch_execute` limits, tool filtering by session state, IPv6/IPv4 loopback fixes.
Older releases - +* **v9.4.4** — Configurable `batch_execute` limits, tool filtering by session state, IPv6/IPv4 loopback fixes.
@@ -92,10 +92,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_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_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 -`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` • `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/commands/camera.py b/Server/src/cli/commands/camera.py new file mode 100644 index 000000000..2e1cad659 --- /dev/null +++ b/Server/src/cli/commands/camera.py @@ -0,0 +1,536 @@ +"""Camera CLI commands for managing Unity Camera + Cinemachine.""" + +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 +from cli.utils.connection import run_command, handle_unity_errors +from cli.utils.parsers import parse_json_dict_or_exit +from cli.utils.constants import SEARCH_METHOD_CHOICE_BASIC + + +_CAM_TOP_LEVEL_KEYS = {"action", "target", "searchMethod", "properties"} + + +def _normalize_cam_params(params: dict[str, Any]) -> dict[str, Any]: + params = dict(params) + properties: dict[str, Any] = {} + for key in list(params.keys()): + if key in _CAM_TOP_LEVEL_KEYS: + continue + properties[key] = params.pop(key) + + if properties: + existing = params.get("properties") + if isinstance(existing, dict): + params["properties"] = {**properties, **existing} + else: + params["properties"] = properties + + return {k: v for k, v in params.items() if v is not None} + + +@click.group() +def camera(): + """Camera operations - create, configure, and control cameras.""" + pass + + +# ============================================================================= +# Setup +# ============================================================================= + +@camera.command("ping") +@handle_unity_errors +def ping(): + """Check if Cinemachine is available. + + \b + Examples: + unity-mcp camera ping + """ + config = get_config() + result = run_command(config, "manage_camera", {"action": "ping"}) + format_output(result, config) + + +@camera.command("list") +@handle_unity_errors +def list_cameras(): + """List all cameras in the scene. + + \b + Examples: + unity-mcp camera list + """ + config = get_config() + result = run_command(config, "manage_camera", {"action": "list_cameras"}) + format_output(result, config) + + +@camera.command("brain-status") +@handle_unity_errors +def brain_status(): + """Get CinemachineBrain status. + + \b + Examples: + unity-mcp camera brain-status + """ + config = get_config() + result = run_command(config, "manage_camera", {"action": "get_brain_status"}) + format_output(result, config) + + +# ============================================================================= +# Creation +# ============================================================================= + +@camera.command("create") +@click.option("--name", "-n", default=None, help="Name for the camera GameObject.") +@click.option("--preset", "-p", default=None, + type=click.Choice(["follow", "third_person", "freelook", "dolly", + "static", "top_down", "side_scroller"]), + help="Camera preset (Cinemachine only).") +@click.option("--follow", default=None, help="Follow target (name/path/ID).") +@click.option("--look-at", default=None, help="LookAt target (name/path/ID).") +@click.option("--priority", type=int, default=None, help="Camera priority.") +@click.option("--fov", type=float, default=None, help="Field of view.") +@handle_unity_errors +def create(name, preset, follow, look_at, priority, fov): + """Create a new camera. + + \b + Examples: + unity-mcp camera create --name "FollowCam" --preset third_person --follow Player + unity-mcp camera create --name "MainCam" --fov 50 + """ + config = get_config() + props: dict[str, Any] = {} + if name: + props["name"] = name + if preset: + props["preset"] = preset + if follow: + props["follow"] = follow + if look_at: + props["lookAt"] = look_at + if priority is not None: + props["priority"] = priority + if fov is not None: + props["fieldOfView"] = fov + + params: dict[str, Any] = {"action": "create_camera"} + if props: + params["properties"] = props + + result = run_command(config, "manage_camera", params) + format_output(result, config) + + +@camera.command("ensure-brain") +@click.option("--camera-ref", default=None, help="Camera to add Brain to (name/path/ID).") +@click.option("--blend-style", default=None, help="Default blend style.") +@click.option("--blend-duration", type=float, default=None, help="Default blend duration.") +@handle_unity_errors +def ensure_brain(camera_ref, blend_style, blend_duration): + """Ensure CinemachineBrain exists on main camera. + + \b + Examples: + unity-mcp camera ensure-brain + unity-mcp camera ensure-brain --blend-style EaseInOut --blend-duration 2.0 + """ + config = get_config() + props: dict[str, Any] = {} + if camera_ref: + props["camera"] = camera_ref + if blend_style: + props["defaultBlendStyle"] = blend_style + if blend_duration is not None: + props["defaultBlendDuration"] = blend_duration + + params: dict[str, Any] = {"action": "ensure_brain"} + if props: + params["properties"] = props + + result = run_command(config, "manage_camera", params) + format_output(result, config) + + +# ============================================================================= +# Configuration +# ============================================================================= + +@camera.command("set-target") +@click.argument("target") +@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None) +@click.option("--follow", default=None, help="Follow target (name/path/ID).") +@click.option("--look-at", default=None, help="LookAt target (name/path/ID).") +@handle_unity_errors +def set_target(target, search_method, follow, look_at): + """Set camera Follow/LookAt targets. + + \b + Examples: + unity-mcp camera set-target "CM Camera" --follow Player --look-at Player + """ + config = get_config() + props: dict[str, Any] = {} + if follow: + props["follow"] = follow + if look_at: + props["lookAt"] = look_at + + params = _normalize_cam_params({ + "action": "set_target", + "target": target, + "searchMethod": search_method, + "properties": props if props else None, + }) + result = run_command(config, "manage_camera", params) + format_output(result, config) + + +@camera.command("set-lens") +@click.argument("target") +@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None) +@click.option("--fov", type=float, default=None, help="Field of view.") +@click.option("--near", type=float, default=None, help="Near clip plane.") +@click.option("--far", type=float, default=None, help="Far clip plane.") +@click.option("--ortho-size", type=float, default=None, help="Orthographic size.") +@click.option("--dutch", type=float, default=None, help="Dutch angle (Cinemachine).") +@handle_unity_errors +def set_lens(target, search_method, fov, near, far, ortho_size, dutch): + """Set camera lens properties. + + \b + Examples: + unity-mcp camera set-lens "CM Camera" --fov 40 --near 0.1 + """ + config = get_config() + props: dict[str, Any] = {} + if fov is not None: + props["fieldOfView"] = fov + if near is not None: + props["nearClipPlane"] = near + if far is not None: + props["farClipPlane"] = far + if ortho_size is not None: + props["orthographicSize"] = ortho_size + if dutch is not None: + props["dutch"] = dutch + + params = _normalize_cam_params({ + "action": "set_lens", + "target": target, + "searchMethod": search_method, + "properties": props if props else None, + }) + result = run_command(config, "manage_camera", params) + format_output(result, config) + + +@camera.command("set-priority") +@click.argument("target") +@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None) +@click.option("--priority", "-p", type=int, required=True, help="Priority value.") +@handle_unity_errors +def set_priority(target, search_method, priority): + """Set camera priority. + + \b + Examples: + unity-mcp camera set-priority "CM Camera" --priority 20 + """ + config = get_config() + params = _normalize_cam_params({ + "action": "set_priority", + "target": target, + "searchMethod": search_method, + "properties": {"priority": priority}, + }) + result = run_command(config, "manage_camera", params) + format_output(result, config) + + +# ============================================================================= +# Cinemachine Pipeline +# ============================================================================= + +@camera.command("set-body") +@click.argument("target") +@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None) +@click.option("--body-type", default=None, help="Body component type to swap to.") +@click.option("--props", default=None, help="Body properties as JSON.") +@handle_unity_errors +def set_body(target, search_method, body_type, props): + """Configure Body component on CinemachineCamera. + + \b + Examples: + unity-mcp camera set-body "CM Camera" --body-type CinemachineFollow + unity-mcp camera set-body "CM Camera" --props '{"cameraDistance": 5.0}' + """ + config = get_config() + properties: dict[str, Any] = {} + if body_type: + properties["bodyType"] = body_type + if props: + properties.update(parse_json_dict_or_exit(props)) + + params = _normalize_cam_params({ + "action": "set_body", + "target": target, + "searchMethod": search_method, + "properties": properties if properties else None, + }) + result = run_command(config, "manage_camera", params) + format_output(result, config) + + +@camera.command("set-aim") +@click.argument("target") +@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None) +@click.option("--aim-type", default=None, help="Aim component type to swap to.") +@click.option("--props", default=None, help="Aim properties as JSON.") +@handle_unity_errors +def set_aim(target, search_method, aim_type, props): + """Configure Aim component on CinemachineCamera. + + \b + Examples: + unity-mcp camera set-aim "CM Camera" --aim-type CinemachineHardLookAt + """ + config = get_config() + properties: dict[str, Any] = {} + if aim_type: + properties["aimType"] = aim_type + if props: + properties.update(parse_json_dict_or_exit(props)) + + params = _normalize_cam_params({ + "action": "set_aim", + "target": target, + "searchMethod": search_method, + "properties": properties if properties else None, + }) + result = run_command(config, "manage_camera", params) + format_output(result, config) + + +@camera.command("set-noise") +@click.argument("target") +@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None) +@click.option("--amplitude", type=float, default=None, help="Amplitude gain.") +@click.option("--frequency", type=float, default=None, help="Frequency gain.") +@handle_unity_errors +def set_noise(target, search_method, amplitude, frequency): + """Configure Noise on CinemachineCamera. + + \b + Examples: + unity-mcp camera set-noise "CM Camera" --amplitude 0.5 --frequency 1.0 + """ + config = get_config() + props: dict[str, Any] = {} + if amplitude is not None: + props["amplitudeGain"] = amplitude + if frequency is not None: + props["frequencyGain"] = frequency + + params = _normalize_cam_params({ + "action": "set_noise", + "target": target, + "searchMethod": search_method, + "properties": props if props else None, + }) + result = run_command(config, "manage_camera", params) + format_output(result, config) + + +# ============================================================================= +# Extensions +# ============================================================================= + +@camera.command("add-extension") +@click.argument("target") +@click.argument("extension_type") +@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None) +@click.option("--props", default=None, help="Extension properties as JSON.") +@handle_unity_errors +def add_extension(target, extension_type, search_method, props): + """Add extension to CinemachineCamera. + + \b + Examples: + unity-mcp camera add-extension "CM Camera" CinemachineDeoccluder + unity-mcp camera add-extension "CM Camera" CinemachineImpulseListener + """ + config = get_config() + properties: dict[str, Any] = {"extensionType": extension_type} + if props: + properties.update(parse_json_dict_or_exit(props)) + + params = _normalize_cam_params({ + "action": "add_extension", + "target": target, + "searchMethod": search_method, + "properties": properties, + }) + result = run_command(config, "manage_camera", params) + format_output(result, config) + + +@camera.command("remove-extension") +@click.argument("target") +@click.argument("extension_type") +@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None) +@handle_unity_errors +def remove_extension(target, extension_type, search_method): + """Remove extension from CinemachineCamera. + + \b + Examples: + unity-mcp camera remove-extension "CM Camera" CinemachineDeoccluder + """ + config = get_config() + params = _normalize_cam_params({ + "action": "remove_extension", + "target": target, + "searchMethod": search_method, + "properties": {"extensionType": extension_type}, + }) + result = run_command(config, "manage_camera", params) + format_output(result, config) + + +# ============================================================================= +# Control +# ============================================================================= + +@camera.command("set-blend") +@click.option("--style", default=None, help="Blend style (Cut, EaseInOut, Linear, etc.).") +@click.option("--duration", type=float, default=None, help="Blend duration in seconds.") +@handle_unity_errors +def set_blend(style, duration): + """Configure default blend on CinemachineBrain. + + \b + Examples: + unity-mcp camera set-blend --style EaseInOut --duration 2.0 + """ + config = get_config() + props: dict[str, Any] = {} + if style: + props["style"] = style + if duration is not None: + props["duration"] = duration + + params: dict[str, Any] = {"action": "set_blend"} + if props: + params["properties"] = props + + result = run_command(config, "manage_camera", params) + format_output(result, config) + + +@camera.command("force") +@click.argument("target") +@click.option("--search-method", "-s", type=SEARCH_METHOD_CHOICE_BASIC, default=None) +@handle_unity_errors +def force_camera(target, search_method): + """Force Brain to use a specific camera. + + \b + Examples: + unity-mcp camera force "CM Cinematic" + """ + config = get_config() + params = _normalize_cam_params({ + "action": "force_camera", + "target": target, + "searchMethod": search_method, + }) + result = run_command(config, "manage_camera", params) + format_output(result, config) + + +@camera.command("release") +@handle_unity_errors +def release_override(): + """Release camera override. + + \b + Examples: + unity-mcp camera release + """ + config = get_config() + result = run_command(config, "manage_camera", {"action": "release_override"}) + format_output(result, config) + + +# ============================================================================= +# Capture +# ============================================================================= + +@camera.command("screenshot") +@click.option("--camera-ref", default=None, help="Camera to capture from (name/path/ID).") +@click.option("--file-name", default=None, help="Output file name.") +@click.option("--super-size", type=int, default=None, help="Supersize multiplier.") +@click.option("--include-image/--no-include-image", default=None, help="Return inline base64 PNG.") +@click.option("--max-resolution", type=int, default=None, help="Max resolution for inline image.") +@click.option("--batch", default=None, type=click.Choice(["surround", "orbit"]), + help="Batch capture mode.") +@click.option("--look-at", default=None, help="Target to aim at (name/path/ID or [x,y,z]).") +@handle_unity_errors +def screenshot(camera_ref, file_name, super_size, include_image, max_resolution, batch, look_at): + """Capture a screenshot from a camera. + + \b + Examples: + unity-mcp camera screenshot + unity-mcp camera screenshot --camera-ref "CM FollowCam" --include-image --max-resolution 512 + unity-mcp camera screenshot --batch surround --look-at Player + """ + config = get_config() + params: dict[str, Any] = {"action": "screenshot"} + if camera_ref: + params["camera"] = camera_ref + if file_name: + params["fileName"] = file_name + if super_size is not None: + params["superSize"] = super_size + if include_image is not None: + params["includeImage"] = include_image + if max_resolution is not None: + params["maxResolution"] = max_resolution + if batch: + params["batch"] = batch + if look_at: + params["lookAt"] = look_at + result = run_command(config, "manage_camera", params) + format_output(result, config) + + +@camera.command("screenshot-multiview") +@click.option("--max-resolution", type=int, default=None, help="Max resolution per tile.") +@click.option("--look-at", default=None, help="Center target for the multiview capture.") +@handle_unity_errors +def screenshot_multiview(max_resolution, look_at): + """Capture a 6-angle contact sheet around the scene. + + \b + Examples: + unity-mcp camera screenshot-multiview + unity-mcp camera screenshot-multiview --look-at Player --max-resolution 480 + """ + config = get_config() + params: dict[str, Any] = {"action": "screenshot_multiview"} + if max_resolution is not None: + params["maxResolution"] = max_resolution + if look_at: + params["lookAt"] = look_at + result = run_command(config, "manage_camera", params) + format_output(result, config) diff --git a/Server/src/cli/main.py b/Server/src/cli/main.py index 495517ba3..b9568e1ba 100644 --- a/Server/src/cli/main.py +++ b/Server/src/cli/main.py @@ -267,6 +267,7 @@ def register_optional_command(module_name: str, command_name: str) -> None: ("cli.commands.batch", "batch"), ("cli.commands.texture", "texture"), ("cli.commands.probuilder", "probuilder"), + ("cli.commands.camera", "camera"), ] for module_name, command_name in optional_commands: diff --git a/Server/src/services/resources/cameras.py b/Server/src/services/resources/cameras.py new file mode 100644 index 000000000..7297ef1d1 --- /dev/null +++ b/Server/src/services/resources/cameras.py @@ -0,0 +1,30 @@ +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/cameras", + name="cameras", + description=( + "List all cameras in the scene (Unity Camera + CinemachineCamera) with status. " + "Includes Brain state, Cinemachine camera priorities, pipeline components, " + "follow/lookAt targets, and Unity Camera info.\n\n" + "URI: mcpforunity://scene/cameras" + ), +) +async def get_cameras(ctx: Context) -> MCPResponse: + """Get all cameras in the scene.""" + unity_instance = await get_unity_instance_from_context(ctx) + response = await send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "get_cameras", + {}, + ) + return parse_resource_response(response, MCPResponse) diff --git a/Server/src/services/tools/manage_camera.py b/Server/src/services/tools/manage_camera.py new file mode 100644 index 000000000..035fd2a86 --- /dev/null +++ b/Server/src/services/tools/manage_camera.py @@ -0,0 +1,185 @@ +from typing import Annotated, Any, Literal + +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 build_screenshot_params, extract_screenshot_images +from transport.unity_transport import send_with_unity_instance +from transport.legacy.unity_connection import async_send_command_with_retry + +# All possible actions grouped by category +SETUP_ACTIONS = ["ping", "ensure_brain", "get_brain_status"] + +CREATION_ACTIONS = ["create_camera"] + +CONFIGURATION_ACTIONS = [ + "set_target", "set_priority", "set_lens", + "set_body", "set_aim", "set_noise", +] + +EXTENSION_ACTIONS = ["add_extension", "remove_extension"] + +CONTROL_ACTIONS = [ + "set_blend", "force_camera", "release_override", "list_cameras", +] + +CAPTURE_ACTIONS = ["screenshot", "screenshot_multiview"] + +ALL_ACTIONS = SETUP_ACTIONS + CREATION_ACTIONS + CONFIGURATION_ACTIONS + EXTENSION_ACTIONS + CONTROL_ACTIONS + CAPTURE_ACTIONS + + +@mcp_for_unity_tool( + group="core", + description=( + "Manage cameras (Unity Camera + Cinemachine). Works without Cinemachine using basic Camera; " + "unlocks presets, pipelines, and blending when Cinemachine is installed. " + "Use ping to check Cinemachine availability.\n\n" + "SETUP:\n" + "- ping: Check if Cinemachine is available\n" + "- ensure_brain: Ensure CinemachineBrain exists on main camera\n" + "- get_brain_status: Get Brain state (active camera, blend, etc.)\n\n" + "CAMERA CREATION:\n" + "- create_camera: Create camera with preset (third_person, freelook, " + "follow, dolly, static, top_down, side_scroller). Falls back to basic Camera without Cinemachine.\n\n" + "CAMERA CONFIGURATION:\n" + "- set_target: Set Follow and/or LookAt targets on a camera\n" + "- set_priority: Set camera priority for Brain selection\n" + "- set_lens: Configure lens (fieldOfView, nearClipPlane, farClipPlane, orthographicSize, dutch)\n" + "- set_body: Configure Body component (bodyType to swap, plus component properties)\n" + "- set_aim: Configure Aim component (aimType to swap, plus component properties)\n" + "- set_noise: Configure Noise component (amplitudeGain, frequencyGain)\n\n" + "EXTENSIONS:\n" + "- add_extension: Add extension (extensionType: CinemachineConfiner2D, CinemachineDeoccluder, " + "CinemachineImpulseListener, CinemachineFollowZoom, CinemachineRecomposer, etc.)\n" + "- remove_extension: Remove extension by type\n\n" + "CAMERA CONTROL:\n" + "- set_blend: Configure default blend (style: Cut/EaseInOut/Linear/etc., duration)\n" + "- force_camera: Override Brain to use specific camera\n" + "- release_override: Release camera override\n" + "- list_cameras: List all cameras with status\n\n" + "CAPTURE:\n" + "- screenshot: Capture from a camera. Supports include_image=true for inline base64 PNG, " + "batch='surround' for 6-angle contact sheet, batch='orbit' for configurable grid, " + "look_at/view_position for positioned capture.\n" + "- screenshot_multiview: Shorthand for screenshot with batch='surround' and include_image=true." + ), + annotations=ToolAnnotations( + title="Manage Camera", + destructiveHint=True, + ), +) +async def manage_camera( + ctx: Context, + action: Annotated[str, "The camera action to perform."], + target: Annotated[str | None, "Target camera (name, path, or instance ID)."] = None, + search_method: Annotated[ + Literal["by_id", "by_name", "by_path"] | None, + "How to find target.", + ] = None, + properties: Annotated[ + dict[str, Any] | str | None, + "Action-specific parameters (dict or JSON string).", + ] = None, + # --- screenshot params --- + screenshot_file_name: Annotated[str | None, + "Screenshot file name (optional). Defaults to timestamp."] = None, + screenshot_super_size: Annotated[int | str | None, + "Screenshot supersize multiplier (integer >= 1)."] = None, + camera: Annotated[str | None, + "Camera to capture from (name, path, or instance ID). Defaults to Camera.main."] = None, + include_image: Annotated[bool | str | None, + "If true, return screenshot as inline base64 PNG. Default false."] = None, + max_resolution: Annotated[int | str | None, + "Max resolution (longest edge px) for inline image. Default 640."] = None, + batch: Annotated[str | None, + "Batch capture mode: 'surround' (6 angles) or 'orbit' (configurable grid)."] = None, + look_at: Annotated[str | int | list[float] | None, + "Target to aim camera at. GameObject name/path/ID or [x,y,z]."] = None, + view_position: Annotated[list[float] | str | None, + "World position [x,y,z] to place camera for positioned capture."] = None, + view_rotation: Annotated[list[float] | str | None, + "Euler rotation [x,y,z] for camera. Overrides look_at if both provided."] = None, + orbit_angles: Annotated[int | str | None, + "Number of azimuth samples for batch='orbit' (default 8, max 36)."] = None, + orbit_elevations: Annotated[list[float] | str | None, + "Elevation angles in degrees for batch='orbit' (default [0, 30, -15])."] = None, + orbit_distance: Annotated[float | str | None, + "Camera distance from target for batch='orbit' (default auto)."] = None, + orbit_fov: Annotated[float | str | None, + "Camera FOV in degrees for batch='orbit' (default 60)."] = None, +) -> dict[str, Any] | ToolResult: + """Unified camera management tool (Unity Camera + Cinemachine).""" + + action_normalized = action.lower() + + if action_normalized not in ALL_ACTIONS: + categories = { + "Setup": SETUP_ACTIONS, + "Creation": CREATION_ACTIONS, + "Configuration": CONFIGURATION_ACTIONS, + "Extensions": EXTENSION_ACTIONS, + "Control": CONTROL_ACTIONS, + "Capture": CAPTURE_ACTIONS, + } + category_list = "; ".join( + f"{cat}: {', '.join(actions)}" for cat, actions in categories.items() + ) + return { + "success": False, + "message": ( + f"Unknown action '{action}'. Available actions by category — {category_list}. " + "Run with action='ping' to check Cinemachine availability." + ), + } + + unity_instance = await get_unity_instance_from_context(ctx) + + params_dict: dict[str, Any] = {"action": action_normalized} + if properties is not None: + params_dict["properties"] = properties + if target is not None: + params_dict["target"] = target + if search_method is not None: + params_dict["searchMethod"] = search_method + + # Screenshot params — only relevant for screenshot/screenshot_multiview actions + if action_normalized in CAPTURE_ACTIONS: + err = build_screenshot_params( + params_dict, + 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 err is not None: + return err + + result = await send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "manage_camera", + params_dict, + ) + + if not isinstance(result, dict): + return {"success": False, "message": str(result)} + + # For capture actions, check for inline images to return as ImageContent + if action_normalized in CAPTURE_ACTIONS: + image_result = extract_screenshot_images(result) + if image_result is not None: + return image_result + + return result diff --git a/Server/src/services/tools/manage_scene.py b/Server/src/services/tools/manage_scene.py index 479343867..abfb01705 100644 --- a/Server/src/services/tools/manage_scene.py +++ b/Server/src/services/tools/manage_scene.py @@ -1,69 +1,17 @@ -import json from typing import Annotated, Literal, Any from fastmcp import Context from fastmcp.server.server import ToolResult -from mcp.types import ToolAnnotations, TextContent, ImageContent +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, normalize_vector3 +from services.tools.utils import coerce_int, coerce_bool, build_screenshot_params, extract_screenshot_images 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 -def _extract_images(response: dict[str, Any], action: str) -> ToolResult | None: - """If the Unity response contains inline base64 images, return a ToolResult - with TextContent + ImageContent blocks. Returns None for normal text-only responses.""" - if not isinstance(response, dict) or not response.get("success"): - return None - - data = response.get("data") - if not isinstance(data, dict): - return None - - if action == "screenshot": - # Batch images (surround mode) — multiple screenshots in one response - screenshots = data.get("screenshots") - if screenshots and isinstance(screenshots, list): - blocks: list[TextContent | ImageContent] = [] - summary_screenshots = [] - for s in screenshots: - summary_screenshots.append({k: v for k, v in s.items() if k != "imageBase64"}) - text_result = { - "success": True, - "message": response.get("message", ""), - "data": { - "sceneCenter": data.get("sceneCenter"), - "sceneRadius": data.get("sceneRadius"), - "screenshots": summary_screenshots, - }, - } - blocks.append(TextContent(type="text", text=json.dumps(text_result))) - for s in screenshots: - b64 = s.get("imageBase64") - if b64: - blocks.append(TextContent(type="text", text=f"[Angle: {s.get('angle', '?')}]")) - blocks.append(ImageContent(type="image", data=b64, mimeType="image/png")) - return ToolResult(content=blocks) - - # Single image (include_image or positioned capture) - image_b64 = data.get("imageBase64") - if not image_b64: - return None - text_data = {k: v for k, v in data.items() if k != "imageBase64"} - text_result = {"success": True, "message": response.get("message", ""), "data": text_data} - return ToolResult( - content=[ - TextContent(type="text", text=json.dumps(text_result)), - ImageContent(type="image", data=image_b64, mimeType="image/png"), - ], - ) - - return None - - @mcp_for_unity_tool( description=( "Performs CRUD operations on Unity scenes. " @@ -155,7 +103,6 @@ async def manage_scene( return gate.model_dump() try: coerced_build_index = coerce_int(build_index, default=None) - coerced_super_size = coerce_int(screenshot_super_size, default=None) coerced_page_size = coerce_int(page_size, default=None) coerced_cursor = coerce_int(cursor, default=None) coerced_max_nodes = coerce_int(max_nodes, default=None) @@ -164,10 +111,6 @@ async def manage_scene( max_children_per_node, default=None) coerced_include_transform = coerce_bool( include_transform, default=None) - coerced_include_image = coerce_bool(include_image, default=None) - coerced_max_resolution = coerce_int(max_resolution, default=None) - if coerced_max_resolution is not None and coerced_max_resolution <= 0: - return {"success": False, "message": "max_resolution must be a positive integer greater than zero."} params: dict[str, Any] = {"action": action} if name: @@ -176,60 +119,26 @@ async def manage_scene( params["path"] = path if coerced_build_index is not None: params["buildIndex"] = coerced_build_index - if screenshot_file_name: - params["fileName"] = screenshot_file_name - if coerced_super_size is not None: - params["superSize"] = coerced_super_size - - # screenshot params - if camera: - params["camera"] = camera - if coerced_include_image is not None: - params["includeImage"] = coerced_include_image - if coerced_max_resolution is not None: - params["maxResolution"] = coerced_max_resolution - # screenshot extended params (batch, positioned capture) - if batch: - params["batch"] = batch - if look_at is not None: - params["lookAt"] = look_at - - # orbit batch params - coerced_orbit_angles = coerce_int(orbit_angles, default=None) - if coerced_orbit_angles is not None: - params["orbitAngles"] = coerced_orbit_angles - if orbit_elevations is not None: - if isinstance(orbit_elevations, str): - try: - orbit_elevations = json.loads(orbit_elevations) - except (ValueError, TypeError): - return {"success": False, "message": "orbit_elevations must be a JSON array of floats."} - if not isinstance(orbit_elevations, list) or not all( - isinstance(v, (int, float)) for v in orbit_elevations - ): - return {"success": False, "message": "orbit_elevations must be a list of numbers."} - params["orbitElevations"] = orbit_elevations - if orbit_distance is not None: - try: - params["orbitDistance"] = float(orbit_distance) - except (ValueError, TypeError): - return {"success": False, "message": "orbit_distance must be a number."} - if orbit_fov is not None: - try: - params["orbitFov"] = float(orbit_fov) - except (ValueError, TypeError): - return {"success": False, "message": "orbit_fov must be a number."} - if view_position is not None: - vec, err = normalize_vector3(view_position, "view_position") - if err: - return {"success": False, "message": err} - params["viewPosition"] = vec - if view_rotation is not None: - vec, err = normalize_vector3(view_rotation, "view_rotation") - if err: - return {"success": False, "message": err} - params["viewRotation"] = vec + # 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: @@ -259,9 +168,10 @@ async def manage_scene( 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 - image_result = _extract_images(response, action) - if image_result is not None: - return image_result + 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/src/services/tools/utils.py b/Server/src/services/tools/utils.py index c7e9a4648..b7c1a3e4d 100644 --- a/Server/src/services/tools/utils.py +++ b/Server/src/services/tools/utils.py @@ -400,3 +400,138 @@ def _to_output_range(components: list[float], from_hex: bool = False) -> list: return None, f"Failed to parse color string: {value}" return None, f"color must be a list, dict, hex string, or JSON string, got {type(value).__name__}" + + +def extract_screenshot_images(response: dict[str, Any]) -> "ToolResult | None": + """If a Unity response contains inline base64 images, return a ToolResult + with TextContent + ImageContent blocks. Returns None for normal text-only responses. + + Shared by manage_scene and manage_camera screenshot handling. + """ + from fastmcp.server.server import ToolResult + from mcp.types import TextContent, ImageContent + + if not isinstance(response, dict) or not response.get("success"): + return None + + data = response.get("data") + if not isinstance(data, dict): + return None + + # Batch images (surround/orbit mode) — multiple screenshots in one response + screenshots = data.get("screenshots") + if screenshots and isinstance(screenshots, list): + blocks: list[TextContent | ImageContent] = [] + summary_screenshots = [] + for s in screenshots: + summary_screenshots.append({k: v for k, v in s.items() if k != "imageBase64"}) + text_result = { + "success": True, + "message": response.get("message", ""), + "data": { + "sceneCenter": data.get("sceneCenter"), + "sceneRadius": data.get("sceneRadius"), + "screenshots": summary_screenshots, + }, + } + blocks.append(TextContent(type="text", text=json.dumps(text_result))) + for s in screenshots: + b64 = s.get("imageBase64") + if b64: + blocks.append(TextContent(type="text", text=f"[Angle: {s.get('angle', '?')}]")) + blocks.append(ImageContent(type="image", data=b64, mimeType="image/png")) + return ToolResult(content=blocks) + + # Single image (include_image or positioned capture) or contact sheet + image_b64 = data.get("imageBase64") + if not image_b64: + return None + text_data = {k: v for k, v in data.items() if k != "imageBase64"} + text_result = {"success": True, "message": response.get("message", ""), "data": text_data} + return ToolResult( + content=[ + TextContent(type="text", text=json.dumps(text_result)), + ImageContent(type="image", data=image_b64, mimeType="image/png"), + ], + ) + + +def build_screenshot_params( + params: dict[str, Any], + *, + screenshot_file_name: str | None = None, + screenshot_super_size: int | str | None = None, + camera: str | None = None, + include_image: bool | str | None = None, + max_resolution: int | str | None = None, + batch: str | None = None, + look_at: str | int | list[float] | None = None, + orbit_angles: int | str | None = None, + orbit_elevations: list[float] | str | None = None, + orbit_distance: float | str | None = None, + orbit_fov: float | str | None = None, + view_position: list[float] | str | None = None, + view_rotation: list[float] | str | None = None, +) -> dict[str, Any] | None: + """Populate screenshot-related keys in *params* dict. Returns an error dict + if validation fails, or None on success. + + Shared by manage_scene and manage_camera screenshot handling. + """ + if screenshot_file_name: + params["fileName"] = screenshot_file_name + coerced_super_size = coerce_int(screenshot_super_size, default=None) + if coerced_super_size is not None: + params["superSize"] = coerced_super_size + if camera: + params["camera"] = camera + coerced_include_image = coerce_bool(include_image, default=None) + if coerced_include_image is not None: + params["includeImage"] = coerced_include_image + coerced_max_resolution = coerce_int(max_resolution, default=None) + if coerced_max_resolution is not None: + if coerced_max_resolution <= 0: + return {"success": False, "message": "max_resolution must be a positive integer."} + params["maxResolution"] = coerced_max_resolution + if batch: + params["batch"] = batch + if look_at is not None: + params["lookAt"] = look_at + + # Orbit params + coerced_orbit_angles = coerce_int(orbit_angles, default=None) + if coerced_orbit_angles is not None: + params["orbitAngles"] = coerced_orbit_angles + if orbit_elevations is not None: + if isinstance(orbit_elevations, str): + try: + orbit_elevations = json.loads(orbit_elevations) + except (ValueError, TypeError): + return {"success": False, "message": "orbit_elevations must be a JSON array of floats."} + if not isinstance(orbit_elevations, list) or not all( + isinstance(v, (int, float)) for v in orbit_elevations + ): + return {"success": False, "message": "orbit_elevations must be a list of numbers."} + params["orbitElevations"] = orbit_elevations + coerced_orbit_distance = coerce_float(orbit_distance, default=None) + if orbit_distance is not None and coerced_orbit_distance is None: + return {"success": False, "message": "orbit_distance must be a number."} + if coerced_orbit_distance is not None: + params["orbitDistance"] = coerced_orbit_distance + coerced_orbit_fov = coerce_float(orbit_fov, default=None) + if orbit_fov is not None and coerced_orbit_fov is None: + return {"success": False, "message": "orbit_fov must be a number."} + if coerced_orbit_fov is not None: + params["orbitFov"] = coerced_orbit_fov + if view_position is not None: + vec, err = normalize_vector3(view_position, "view_position") + if err: + return {"success": False, "message": err} + params["viewPosition"] = vec + if view_rotation is not None: + vec, err = normalize_vector3(view_rotation, "view_rotation") + if err: + return {"success": False, "message": err} + params["viewRotation"] = vec + + return None diff --git a/Server/tests/integration/test_manage_scene_screenshot_params.py b/Server/tests/integration/test_manage_scene_screenshot_params.py index dce067717..2966981f4 100644 --- a/Server/tests/integration/test_manage_scene_screenshot_params.py +++ b/Server/tests/integration/test_manage_scene_screenshot_params.py @@ -2,7 +2,7 @@ from .test_helpers import DummyContext import services.tools.manage_scene as manage_scene_mod -from services.tools.manage_scene import _extract_images +from services.tools.utils import extract_screenshot_images # --------------------------------------------------------------------------- @@ -10,16 +10,16 @@ # --------------------------------------------------------------------------- def test_extract_images_returns_none_for_non_dict(): - assert _extract_images("not a dict", "screenshot") is None + assert extract_screenshot_images("not a dict") is None def test_extract_images_returns_none_for_failed_response(): - assert _extract_images({"success": False}, "screenshot") is None + 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_images(resp, "screenshot") is None + assert extract_screenshot_images(resp) is None def test_extract_images_screenshot_returns_tool_result(): @@ -33,7 +33,7 @@ def test_extract_images_screenshot_returns_tool_result(): "imageHeight": 512, }, } - result = _extract_images(resp, "screenshot") + result = extract_screenshot_images(resp) assert result is not None # Should have TextContent + ImageContent assert len(result.content) == 2 @@ -58,7 +58,7 @@ def test_extract_images_batch_surround_returns_tool_result(): ], }, } - result = _extract_images(resp, "screenshot") + result = extract_screenshot_images(resp) assert result is not None # 1 text summary + 2*(label + image) = 5 blocks assert len(result.content) == 5 @@ -75,7 +75,7 @@ def test_extract_images_batch_surround_returns_tool_result(): def test_extract_images_batch_no_screenshots(): resp = {"success": True, "data": {"screenshots": []}} - assert _extract_images(resp, "screenshot") is None + assert extract_screenshot_images(resp) is None def test_extract_images_positioned_returns_tool_result(): @@ -90,15 +90,15 @@ def test_extract_images_positioned_returns_tool_result(): "lookAt": [0, 0, 0], }, } - result = _extract_images(resp, "screenshot") + 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_unknown_action(): - resp = {"success": True, "data": {"imageBase64": "STUFF"}} - assert _extract_images(resp, "get_hierarchy") is None +def test_extract_images_no_data_key(): + resp = {"success": True} + assert extract_screenshot_images(resp) is None # --------------------------------------------------------------------------- diff --git a/Server/tests/test_manage_camera.py b/Server/tests/test_manage_camera.py new file mode 100644 index 000000000..80af04a46 --- /dev/null +++ b/Server/tests/test_manage_camera.py @@ -0,0 +1,535 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from services.tools.manage_camera import ( + manage_camera, + ALL_ACTIONS, + SETUP_ACTIONS, + CREATION_ACTIONS, + CONFIGURATION_ACTIONS, + EXTENSION_ACTIONS, + CONTROL_ACTIONS, + CAPTURE_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_camera.get_unity_instance_from_context", + AsyncMock(return_value="unity-instance-1"), + ) + monkeypatch.setattr( + "services.tools.manage_camera.send_with_unity_instance", + fake_send, + ) + return captured + + +# --------------------------------------------------------------------------- +# Action list completeness +# --------------------------------------------------------------------------- + +def test_all_actions_is_union_of_sub_lists(): + expected = set( + SETUP_ACTIONS + CREATION_ACTIONS + CONFIGURATION_ACTIONS + + EXTENSION_ACTIONS + CONTROL_ACTIONS + CAPTURE_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) == 18 + + +# --------------------------------------------------------------------------- +# Invalid / missing action +# --------------------------------------------------------------------------- + +def test_unknown_action_returns_error(mock_unity): + result = asyncio.run( + manage_camera(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_camera(SimpleNamespace(), action="") + ) + assert result["success"] is False + + +# --------------------------------------------------------------------------- +# Setup actions +# --------------------------------------------------------------------------- + +def test_ping_sends_correct_params(mock_unity): + result = asyncio.run( + manage_camera(SimpleNamespace(), action="ping") + ) + assert result["success"] is True + assert mock_unity["tool_name"] == "manage_camera" + assert mock_unity["params"]["action"] == "ping" + + +def test_ensure_brain_sends_correct_params(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="ensure_brain", + properties={"defaultBlendStyle": "EaseInOut", "defaultBlendDuration": 2.0}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "ensure_brain" + assert mock_unity["params"]["properties"]["defaultBlendStyle"] == "EaseInOut" + + +def test_get_brain_status_sends_correct_params(mock_unity): + result = asyncio.run( + manage_camera(SimpleNamespace(), action="get_brain_status") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "get_brain_status" + + +# --------------------------------------------------------------------------- +# Camera creation +# --------------------------------------------------------------------------- + +def test_create_camera_with_preset(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="create_camera", + properties={ + "name": "CM ThirdPerson", + "preset": "third_person", + "follow": "Player", + "lookAt": "Player", + "priority": 10, + }, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "create_camera" + props = mock_unity["params"]["properties"] + assert props["preset"] == "third_person" + assert props["follow"] == "Player" + assert props["priority"] == 10 + + +def test_create_camera_minimal(mock_unity): + result = asyncio.run( + manage_camera(SimpleNamespace(), action="create_camera") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "create_camera" + + +# --------------------------------------------------------------------------- +# Configuration actions +# --------------------------------------------------------------------------- + +def test_set_target_sends_follow_and_lookat(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="set_target", + target="CM Camera", + properties={"follow": "Player", "lookAt": "Player"}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["target"] == "CM Camera" + assert mock_unity["params"]["properties"]["follow"] == "Player" + + +def test_set_lens_sends_properties(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="set_lens", + target="CM Camera", + properties={"fieldOfView": 40.0, "nearClipPlane": 0.1}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["properties"]["fieldOfView"] == 40.0 + + +def test_set_priority_sends_value(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="set_priority", + target="CM Camera", + properties={"priority": 20}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["properties"]["priority"] == 20 + + +def test_set_body_with_type_swap(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="set_body", + target="CM Camera", + properties={"bodyType": "CinemachineFollow", "cameraDistance": 5.0}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["properties"]["bodyType"] == "CinemachineFollow" + assert mock_unity["params"]["properties"]["cameraDistance"] == 5.0 + + +def test_set_aim_with_type_swap(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="set_aim", + target="CM Camera", + properties={"aimType": "CinemachineHardLookAt"}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["properties"]["aimType"] == "CinemachineHardLookAt" + + +def test_set_noise_sends_amplitude_frequency(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="set_noise", + target="CM Camera", + properties={"amplitudeGain": 0.5, "frequencyGain": 1.0}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["properties"]["amplitudeGain"] == 0.5 + + +# --------------------------------------------------------------------------- +# Extension actions +# --------------------------------------------------------------------------- + +def test_add_extension_sends_type(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="add_extension", + target="CM Camera", + properties={"extensionType": "CinemachineDeoccluder"}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["properties"]["extensionType"] == "CinemachineDeoccluder" + + +def test_remove_extension_sends_type(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="remove_extension", + target="CM Camera", + properties={"extensionType": "CinemachineDeoccluder"}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["properties"]["extensionType"] == "CinemachineDeoccluder" + + +# --------------------------------------------------------------------------- +# Control actions +# --------------------------------------------------------------------------- + +def test_set_blend_sends_style_and_duration(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="set_blend", + properties={"style": "EaseInOut", "duration": 2.0}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["properties"]["style"] == "EaseInOut" + assert mock_unity["params"]["properties"]["duration"] == 2.0 + + +def test_force_camera_sends_target(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="force_camera", + target="CM Cinematic", + ) + ) + assert result["success"] is True + assert mock_unity["params"]["target"] == "CM Cinematic" + + +def test_release_override_sends_no_extra_params(mock_unity): + result = asyncio.run( + manage_camera(SimpleNamespace(), action="release_override") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "release_override" + assert "target" not in mock_unity["params"] + assert "properties" not in mock_unity["params"] + + +def test_list_cameras_sends_no_extra_params(mock_unity): + result = asyncio.run( + manage_camera(SimpleNamespace(), action="list_cameras") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "list_cameras" + + +# --------------------------------------------------------------------------- +# Capture actions +# --------------------------------------------------------------------------- + +def test_screenshot_sends_basic_params(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="screenshot", + camera="Main Camera", + include_image=True, + max_resolution=512, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "screenshot" + assert mock_unity["params"]["camera"] == "Main Camera" + assert mock_unity["params"]["includeImage"] is True + assert mock_unity["params"]["maxResolution"] == 512 + + +def test_screenshot_with_filename(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="screenshot", + screenshot_file_name="test-capture", + screenshot_super_size=2, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["fileName"] == "test-capture" + assert mock_unity["params"]["superSize"] == 2 + + +def test_screenshot_batch_surround(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="screenshot", + batch="surround", + look_at="Player", + include_image=True, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["batch"] == "surround" + assert mock_unity["params"]["lookAt"] == "Player" + + +def test_screenshot_batch_orbit(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="screenshot", + batch="orbit", + orbit_angles=6, + orbit_elevations=[0.0, 30.0], + orbit_distance=10.0, + orbit_fov=50.0, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["batch"] == "orbit" + assert mock_unity["params"]["orbitAngles"] == 6 + assert mock_unity["params"]["orbitElevations"] == [0.0, 30.0] + assert mock_unity["params"]["orbitDistance"] == 10.0 + assert mock_unity["params"]["orbitFov"] == 50.0 + + +def test_screenshot_positioned(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="screenshot", + view_position=[5.0, 3.0, -10.0], + look_at="Player", + include_image=True, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["viewPosition"] == [5.0, 3.0, -10.0] + assert mock_unity["params"]["lookAt"] == "Player" + + +def test_screenshot_multiview_sends_action(mock_unity): + result = asyncio.run( + manage_camera(SimpleNamespace(), action="screenshot_multiview") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "screenshot_multiview" + + +def test_screenshot_params_not_sent_for_non_capture_actions(mock_unity): + """Screenshot params should be ignored for non-capture actions.""" + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="ping", + camera="Main Camera", + include_image=True, + max_resolution=512, + ) + ) + assert result["success"] is True + assert "camera" not in mock_unity["params"] + assert "includeImage" not in mock_unity["params"] + + +def test_screenshot_invalid_max_resolution(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="screenshot", + max_resolution=0, + ) + ) + assert result["success"] is False + assert "max_resolution" in result["message"] + + +def test_screenshot_invalid_orbit_elevations(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="screenshot", + batch="orbit", + orbit_elevations="not-json", + ) + ) + assert result["success"] is False + assert "orbit_elevations" in result["message"] + + +# --------------------------------------------------------------------------- +# Parameter handling +# --------------------------------------------------------------------------- + +def test_search_method_passed_through(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="set_target", + target="12345", + search_method="by_id", + properties={"follow": "Player"}, + ) + ) + assert result["success"] is True + assert mock_unity["params"]["searchMethod"] == "by_id" + + +def test_none_params_omitted(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="ping", + target=None, + search_method=None, + properties=None, + ) + ) + assert result["success"] is True + assert "target" not in mock_unity["params"] + assert "searchMethod" not in mock_unity["params"] + assert "properties" not in mock_unity["params"] + + +def test_string_properties_passed_through(mock_unity): + result = asyncio.run( + manage_camera( + SimpleNamespace(), + action="create_camera", + properties='{"name": "TestCam", "preset": "follow"}', + ) + ) + assert result["success"] is True + assert mock_unity["params"]["properties"] == '{"name": "TestCam", "preset": "follow"}' + + +def test_non_dict_response_wrapped(monkeypatch): + """When Unity returns a non-dict, it should be wrapped.""" + monkeypatch.setattr( + "services.tools.manage_camera.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_camera.send_with_unity_instance", + fake_send, + ) + + result = asyncio.run( + manage_camera(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_camera(SimpleNamespace(), action="Create_Camera") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "create_camera" + + +def test_action_uppercase(mock_unity): + result = asyncio.run( + manage_camera(SimpleNamespace(), action="PING") + ) + assert result["success"] is True + assert mock_unity["params"]["action"] == "ping" diff --git a/Server/uv.lock b/Server/uv.lock index 859814a58..4882bcb0c 100644 --- a/Server/uv.lock +++ b/Server/uv.lock @@ -858,7 +858,7 @@ wheels = [ [[package]] name = "mcpforunityserver" -version = "9.4.7" +version = "9.4.8" source = { editable = "." } dependencies = [ { name = "click" }, diff --git a/docs/i18n/README-zh.md b/docs/i18n/README-zh.md index 9b1e58abf..b8686d4d5 100644 --- a/docs/i18n/README-zh.md +++ b/docs/i18n/README-zh.md @@ -20,15 +20,15 @@
最近更新 -* **v9.4.8 (beta)** — 新编辑器 UI、通过 `manage_tools` 实时切换工具、技能同步窗口、多视图截图、一键 Roslyn 安装器、支持 Qwen Code 与 Gemini CLI 客户端、通过 `manage_probuilder` 进行 ProBuilder 网格编辑。 +* **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 问题、脚本工具的域重载稳定性提升。 * **v9.4.6** — 新增 `manage_animation` 工具、支持 Cline 客户端、失效连接检测、工具状态跨重载持久化。 -* **v9.4.4** — 可配置 `batch_execute` 限制、按会话状态过滤工具、修复 IPv6/IPv4 回环问题。
更早的版本 - +* **v9.4.4** — 可配置 `batch_execute` 限制、按会话状态过滤工具、修复 IPv6/IPv4 回环问题。
@@ -92,10 +92,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_components` • `manage_editor` • `manage_gameobject` • `manage_material` • `manage_prefabs` • `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_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` ### 可用资源 -`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` • `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 35c416e57..f883dee53 100644 --- a/manifest.json +++ b/manifest.json @@ -93,6 +93,10 @@ "name": "manage_gameobject", "description": "Create, modify, transform, and delete GameObjects" }, + { + "name": "manage_camera", + "description": "Manage cameras (Unity Camera + Cinemachine) with presets, pipelines, and blending" + }, { "name": "manage_material", "description": "Create and modify Unity materials and shaders" @@ -101,6 +105,10 @@ "name": "manage_prefabs", "description": "Create, instantiate, unpack, and modify prefabs" }, + { + "name": "manage_probuilder", + "description": "Create and edit ProBuilder meshes, shapes, and geometry operations" + }, { "name": "manage_scene", "description": "Load, save, query hierarchy, and manage Unity scenes" diff --git a/tools/UPDATE_DOCS_PROMPT.md b/tools/UPDATE_DOCS_PROMPT.md index 0ccdc606b..4f247af63 100644 --- a/tools/UPDATE_DOCS_PROMPT.md +++ b/tools/UPDATE_DOCS_PROMPT.md @@ -48,6 +48,18 @@ Here's what you need to do: - Find and update the "可用资源" (Available Resources) section - Keep tool/resource names in English, but you can translate descriptions if helpful + e) **README.md** — "Recent Updates" section + - Add a new entry at the top of the list for the current version + - Format: `* **vX.Y.Z (beta)** — Brief summary of what changed` + - Keep only 4 entries visible; move the oldest to the "Older releases" nested details block + - Remove `(beta)` from the previous entry that was beta + - Update `manifest.json` version field to match + + f) **docs/i18n/README-zh.md** — "最近更新" section + - Mirror the same changes as the English "Recent Updates" section + - Translate the summary text to Chinese + - Same 4-entry rotation rule applies + 3. **Important formatting rules**: - Use backticks around tool/resource names - Separate items with • (bullet point)