diff --git a/MCPForUnity/Editor/Helpers/ComponentOps.cs b/MCPForUnity/Editor/Helpers/ComponentOps.cs index 1d726dc0a..b1ccdc855 100644 --- a/MCPForUnity/Editor/Helpers/ComponentOps.cs +++ b/MCPForUnity/Editor/Helpers/ComponentOps.cs @@ -592,7 +592,7 @@ private static bool SetSerializedPropertyRecursive(SerializedProperty prop, JTok } } - private static bool SetObjectReference(SerializedProperty prop, JToken value, out string error) + internal static bool SetObjectReference(SerializedProperty prop, JToken value, out string error) { error = null; @@ -647,12 +647,18 @@ private static bool SetObjectReference(SerializedProperty prop, JToken value, ou { string spriteName = spriteNameToken.ToString(); var allAssets = AssetDatabase.LoadAllAssetsAtPath(path); + var originalRef = prop.objectReferenceValue; foreach (var asset in allAssets) { if (asset is Sprite sprite && sprite.name == spriteName) { prop.objectReferenceValue = sprite; - return true; + if (prop.objectReferenceValue != null) + return true; + // Unity rejected the type — restore and report + prop.objectReferenceValue = originalRef; + error = $"Sprite '{spriteName}' found but is not compatible with the property type."; + return false; } } @@ -667,6 +673,7 @@ private static bool SetObjectReference(SerializedProperty prop, JToken value, ou if (targetFileId != 0) { var allAssets = AssetDatabase.LoadAllAssetsAtPath(path); + var originalRef = prop.objectReferenceValue; foreach (var asset in allAssets) { if (asset is Sprite sprite) @@ -675,7 +682,11 @@ private static bool SetObjectReference(SerializedProperty prop, JToken value, ou if (spriteFileId == targetFileId) { prop.objectReferenceValue = sprite; - return true; + if (prop.objectReferenceValue != null) + return true; + prop.objectReferenceValue = originalRef; + error = $"Sprite with fileID '{targetFileId}' found but is not compatible with the property type."; + return false; } } } @@ -785,6 +796,43 @@ private static bool AssignObjectReference(SerializedProperty prop, UnityEngine.O if (prop.objectReferenceValue != null) return true; + // Sub-asset fallback: e.g., Texture2D → Sprite + string subAssetPath = AssetDatabase.GetAssetPath(resolved); + if (!string.IsNullOrEmpty(subAssetPath)) + { + var subAssets = AssetDatabase.LoadAllAssetsAtPath(subAssetPath); + UnityEngine.Object match = null; + int matchCount = 0; + foreach (var sub in subAssets) + { + if (sub == null || sub == resolved) continue; + prop.objectReferenceValue = sub; + if (prop.objectReferenceValue != null) + { + match = sub; + matchCount++; + if (matchCount > 1) break; + } + } + + if (matchCount == 1) + { + prop.objectReferenceValue = match; + return true; + } + + // Clean up: probing may have left the property dirty + prop.objectReferenceValue = null; + + if (matchCount > 1) + { + error = $"Multiple compatible sub-assets found in '{subAssetPath}'. " + + "Use {\"guid\": \"...\", \"spriteName\": \"\"} or " + + "{\"guid\": \"...\", \"fileID\": } for precise selection."; + return false; + } + } + // If the resolved object is a GameObject but the property expects a Component, // try each component on the GameObject until one is accepted. if (resolved is GameObject go) diff --git a/MCPForUnity/Editor/Tools/ManageScriptableObject.cs b/MCPForUnity/Editor/Tools/ManageScriptableObject.cs index cdbec8a65..c508e9fc2 100644 --- a/MCPForUnity/Editor/Tools/ManageScriptableObject.cs +++ b/MCPForUnity/Editor/Tools/ManageScriptableObject.cs @@ -737,64 +737,27 @@ private static object ApplySet(SerializedObject so, string propertyPath, JObject if (prop.propertyType == SerializedPropertyType.ObjectReference) { - var refObj = patchObj["ref"] as JObject; + // Legacy "ref" key takes precedence for backward compatibility. + // Use TryGetValue to preserve non-JObject ref tokens (e.g. string GUID). + patchObj.TryGetValue("ref", out JToken refToken); var objRefValue = patchObj["value"]; - UnityEngine.Object newRef = null; - string refGuid = refObj?["guid"]?.ToString(); - string refPath = refObj?["path"]?.ToString(); - string resolveMethod = "explicit"; + JToken resolveToken = refToken ?? objRefValue; - if (refObj == null && objRefValue?.Type == JTokenType.Null) + if (resolveToken == null) { - // Explicit null - clear the reference - newRef = null; - resolveMethod = "cleared"; + return new { propertyPath, op = "set", ok = false, resolvedPropertyType = prop.propertyType.ToString(), + message = "ObjectReference patch requires a 'ref' or 'value' key." }; } - else if (!string.IsNullOrEmpty(refGuid) || !string.IsNullOrEmpty(refPath)) - { - // Traditional ref object with guid or path - string resolvedPath = !string.IsNullOrEmpty(refGuid) - ? AssetDatabase.GUIDToAssetPath(refGuid) - : AssetPathUtility.SanitizeAssetPath(refPath); - if (!string.IsNullOrEmpty(resolvedPath)) - { - newRef = AssetDatabase.LoadAssetAtPath(resolvedPath); - } - resolveMethod = !string.IsNullOrEmpty(refGuid) ? "ref.guid" : "ref.path"; - } - else if (objRefValue?.Type == JTokenType.String) + if (!ComponentOps.SetObjectReference(prop, resolveToken, out string refError)) { - // Phase 4: GUID shorthand - allow plain string value - string strVal = objRefValue.ToString(); - - // Check if it's a GUID (32 hex characters, no dashes) - if (Regex.IsMatch(strVal, @"^[0-9a-fA-F]{32}$")) - { - string guidPath = AssetDatabase.GUIDToAssetPath(strVal); - if (!string.IsNullOrEmpty(guidPath)) - { - newRef = AssetDatabase.LoadAssetAtPath(guidPath); - resolveMethod = "guid-shorthand"; - } - } - // Check if it looks like an asset path - else if (strVal.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) || - strVal.Contains("/")) - { - string sanitizedPath = AssetPathUtility.SanitizeAssetPath(strVal); - newRef = AssetDatabase.LoadAssetAtPath(sanitizedPath); - resolveMethod = "path-shorthand"; - } + return new { propertyPath, op = "set", ok = false, resolvedPropertyType = prop.propertyType.ToString(), message = refError }; } - if (prop.objectReferenceValue != newRef) - { - prop.objectReferenceValue = newRef; - changed = true; - } - - string refMessage = newRef == null ? "Cleared reference." : $"Set reference ({resolveMethod})."; + changed = true; + string refMessage = prop.objectReferenceValue == null + ? "Cleared reference." + : $"Set reference to '{prop.objectReferenceValue.name}'."; return new { propertyPath, op = "set", ok = true, resolvedPropertyType = prop.propertyType.ToString(), message = refMessage }; } @@ -919,6 +882,20 @@ private static bool TrySetValueRecursive(SerializedProperty prop, JToken valueTo return true; } + // ObjectReference - delegate to shared handler + if (prop.propertyType == SerializedPropertyType.ObjectReference) + { + if (!ComponentOps.SetObjectReference(prop, valueToken, out string refError)) + { + message = refError; + return false; + } + message = prop.objectReferenceValue == null + ? "Cleared reference." + : $"Set reference to '{prop.objectReferenceValue.name}'."; + return true; + } + // Supported Types: Integer, Boolean, Float, String, Enum, Vector2, Vector3, Vector4, Color // Using shared helpers from ParamCoercion and VectorParsing switch (prop.propertyType) diff --git a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs index 9a9069783..b24eee044 100644 --- a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -614,7 +614,7 @@ private static object ModifyContents(JObject @params) } // Apply modifications - var modifyResult = ApplyModificationsToPrefabObject(targetGo, @params, prefabContents); + var modifyResult = ApplyModificationsToPrefabObject(targetGo, @params, prefabContents, sanitizedPath); if (modifyResult.error != null) { return modifyResult.error; @@ -725,7 +725,7 @@ private static GameObject FindInPrefabContents(GameObject prefabContents, string /// Applies modifications to a GameObject within loaded prefab contents. /// Returns (modified: bool, error: ErrorResponse or null). /// - private static (bool modified, ErrorResponse error) ApplyModificationsToPrefabObject(GameObject targetGo, JObject @params, GameObject prefabRoot) + private static (bool modified, ErrorResponse error) ApplyModificationsToPrefabObject(GameObject targetGo, JObject @params, GameObject prefabRoot, string editingPrefabPath) { bool modified = false; @@ -879,7 +879,7 @@ private static (bool modified, ErrorResponse error) ApplyModificationsToPrefabOb { foreach (var childToken in childArray) { - var childResult = CreateSingleChildInPrefab(childToken, targetGo, prefabRoot); + var childResult = CreateSingleChildInPrefab(childToken, targetGo, prefabRoot, editingPrefabPath); if (childResult.error != null) { return (false, childResult.error); @@ -893,7 +893,7 @@ private static (bool modified, ErrorResponse error) ApplyModificationsToPrefabOb else { // Handle single child object - var childResult = CreateSingleChildInPrefab(createChildToken, targetGo, prefabRoot); + var childResult = CreateSingleChildInPrefab(createChildToken, targetGo, prefabRoot, editingPrefabPath); if (childResult.error != null) { return (false, childResult.error); @@ -957,7 +957,7 @@ private static (bool modified, ErrorResponse error) ApplyModificationsToPrefabOb /// /// Creates a single child GameObject within the prefab contents. /// - private static (bool created, ErrorResponse error) CreateSingleChildInPrefab(JToken createChildToken, GameObject defaultParent, GameObject prefabRoot) + private static (bool created, ErrorResponse error) CreateSingleChildInPrefab(JToken createChildToken, GameObject defaultParent, GameObject prefabRoot, string editingPrefabPath) { JObject childParams; if (createChildToken is JObject obj) @@ -991,8 +991,42 @@ private static (bool created, ErrorResponse error) CreateSingleChildInPrefab(JTo // Create the GameObject GameObject newChild; + string sourcePrefabPath = childParams["sourcePrefabPath"]?.ToString() ?? childParams["source_prefab_path"]?.ToString(); string primitiveType = childParams["primitiveType"]?.ToString() ?? childParams["primitive_type"]?.ToString(); - if (!string.IsNullOrEmpty(primitiveType)) + + if (!string.IsNullOrEmpty(sourcePrefabPath) && !string.IsNullOrEmpty(primitiveType)) + { + return (false, new ErrorResponse("'source_prefab_path' and 'primitive_type' are mutually exclusive in create_child.")); + } + + if (!string.IsNullOrEmpty(sourcePrefabPath)) + { + string sanitizedSourcePath = AssetPathUtility.SanitizeAssetPath(sourcePrefabPath); + if (string.IsNullOrEmpty(sanitizedSourcePath)) + { + return (false, new ErrorResponse($"Invalid source_prefab_path '{sourcePrefabPath}'. Path traversal sequences are not allowed.")); + } + + if (!string.IsNullOrEmpty(editingPrefabPath) && + sanitizedSourcePath.Equals(editingPrefabPath, StringComparison.OrdinalIgnoreCase)) + { + return (false, new ErrorResponse($"Cannot nest prefab '{sanitizedSourcePath}' inside itself. This would create a circular reference.")); + } + + GameObject sourcePrefabAsset = AssetDatabase.LoadAssetAtPath(sanitizedSourcePath); + if (sourcePrefabAsset == null) + { + return (false, new ErrorResponse($"Source prefab not found at path: '{sanitizedSourcePath}'.")); + } + + newChild = PrefabUtility.InstantiatePrefab(sourcePrefabAsset, parentTransform) as GameObject; + if (newChild == null) + { + return (false, new ErrorResponse($"Failed to instantiate prefab from '{sanitizedSourcePath}' as nested child.")); + } + newChild.name = childName; + } + else if (!string.IsNullOrEmpty(primitiveType)) { try { @@ -1010,7 +1044,7 @@ private static (bool created, ErrorResponse error) CreateSingleChildInPrefab(JTo newChild = new GameObject(childName); } - // Set parent + // Ensure local-space transform (worldPositionStays=false) for all creation modes newChild.transform.SetParent(parentTransform, false); // Apply transform properties diff --git a/Server/src/services/tools/manage_components.py b/Server/src/services/tools/manage_components.py index 1dc7a9636..f99470c62 100644 --- a/Server/src/services/tools/manage_components.py +++ b/Server/src/services/tools/manage_components.py @@ -43,8 +43,12 @@ async def manage_components( # For set_property action - single property property: Annotated[str, "Property name to set (for set_property action)"] | None = None, - value: Annotated[str | int | float | bool | dict | list , - "Value to set (for set_property action)"] | None = None, + value: Annotated[str | int | float | bool | dict | list, + "Value to set (for set_property action). " + "For object references: instance ID (int), asset path (string), " + "or {\"guid\": \"...\"} / {\"path\": \"...\"}. " + "For Sprite sub-assets: {\"guid\": \"...\", \"spriteName\": \"\"} or " + "{\"guid\": \"...\", \"fileID\": }. Single-sprite textures auto-resolve."] | None = None, # For add/set_property - multiple properties properties: Annotated[ dict[str, Any] | str, diff --git a/Server/src/services/tools/manage_prefabs.py b/Server/src/services/tools/manage_prefabs.py index b737a24b7..bfa2f8cf1 100644 --- a/Server/src/services/tools/manage_prefabs.py +++ b/Server/src/services/tools/manage_prefabs.py @@ -25,10 +25,10 @@ "Manages Unity Prefab assets via headless operations (no UI, no prefab stages). " "Actions: get_info, get_hierarchy, create_from_gameobject, modify_contents. " "Use modify_contents for headless prefab editing - ideal for automated workflows. " - "Use create_child parameter with modify_contents to add child GameObjects to a prefab " + "Use create_child parameter with modify_contents to add child GameObjects or nested prefab instances to a prefab " "(single object or array for batch creation in one save). " "Example: create_child=[{\"name\": \"Child1\", \"primitive_type\": \"Sphere\", \"position\": [1,0,0]}, " - "{\"name\": \"Child2\", \"primitive_type\": \"Cube\", \"parent\": \"Child1\"}]. " + "{\"name\": \"Nested\", \"source_prefab_path\": \"Assets/Prefabs/Bullet.prefab\", \"position\": [0,2,0]}]. " "Use component_properties with modify_contents to set serialized fields on existing components " "(e.g. component_properties={\"Rigidbody\": {\"mass\": 5.0}, \"MyScript\": {\"health\": 100}}). " "Supports object references via {\"guid\": \"...\"}, {\"path\": \"Assets/...\"}, or {\"instanceID\": 123}. " @@ -66,8 +66,8 @@ async def manage_prefabs( parent: Annotated[str, "New parent object name/path within prefab for modify_contents."] | None = None, components_to_add: Annotated[list[str], "Component types to add in modify_contents."] | None = None, components_to_remove: Annotated[list[str], "Component types to remove in modify_contents."] | None = None, - create_child: Annotated[dict[str, Any] | list[dict[str, Any]], "Create child GameObject(s) in the prefab. Single object or array of objects, each with: name (required), parent (optional, defaults to target), primitive_type (optional: Cube, Sphere, Capsule, Cylinder, Plane, Quad), position, rotation, scale, components_to_add, tag, layer, set_active."] | None = None, - component_properties: Annotated[dict[str, dict[str, Any]], "Set properties on existing components in modify_contents. Keys are component type names, values are dicts of property name to value. Example: {\"Rigidbody\": {\"mass\": 5.0}, \"MyScript\": {\"health\": 100}}. Supports object references via {\"guid\": \"...\"}, {\"path\": \"Assets/...\"}, or {\"instanceID\": 123}."] | None = None, + create_child: Annotated[dict[str, Any] | list[dict[str, Any]], "Create child GameObject(s) in the prefab. Single object or array of objects, each with: name (required), parent (optional, defaults to target), source_prefab_path (optional: asset path to instantiate as nested prefab, e.g. 'Assets/Prefabs/Bullet.prefab'), primitive_type (optional: Cube, Sphere, Capsule, Cylinder, Plane, Quad), position, rotation, scale, components_to_add, tag, layer, set_active. source_prefab_path and primitive_type are mutually exclusive."] | None = None, + component_properties: Annotated[dict[str, dict[str, Any]], "Set properties on existing components in modify_contents. Keys are component type names, values are dicts of property name to value. Example: {\"Rigidbody\": {\"mass\": 5.0}, \"MyScript\": {\"health\": 100}}. Supports object references via {\"guid\": \"...\"}, {\"path\": \"Assets/...\"}, or {\"instanceID\": 123}. For Sprite sub-assets: {\"guid\": \"...\", \"spriteName\": \"\"}. Single-sprite textures auto-resolve."] | None = None, ) -> dict[str, Any]: # Back-compat: map 'name' → 'target' for create_from_gameobject (Unity accepts both) if action == "create_from_gameobject" and target is None and name is not None: diff --git a/Server/src/services/tools/manage_scriptable_object.py b/Server/src/services/tools/manage_scriptable_object.py index 19291cf7a..95e309b90 100644 --- a/Server/src/services/tools/manage_scriptable_object.py +++ b/Server/src/services/tools/manage_scriptable_object.py @@ -47,7 +47,10 @@ async def manage_scriptable_object( "Target asset reference {guid|path} (for modify)."] = None, # --- shared --- patches: Annotated[list[dict[str, Any]] | str | None, - "Patch list (or JSON string) to apply."] = None, + "Patch list (or JSON string) to apply. " + "For object references: use {\"ref\": {\"guid\": \"...\"}} or {\"value\": {\"guid\": \"...\"}}. " + "For Sprite sub-assets: include \"spriteName\" in the ref/value object. " + "Single-sprite textures auto-resolve from guid/path alone."] = None, # --- validation --- dry_run: Annotated[bool | str | None, "If true, validate patches without applying (modify only)."] = None,