diff --git a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs index 9b4df7be8..6df9d47bf 100644 --- a/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs +++ b/MCPForUnity/Editor/Tools/Prefabs/ManagePrefabs.cs @@ -723,9 +723,188 @@ private static (bool modified, ErrorResponse error) ApplyModificationsToPrefabOb } } + // Create child GameObjects (supports single object or array) + JToken createChildToken = @params["createChild"] ?? @params["create_child"]; + if (createChildToken != null) + { + // Handle array of children + if (createChildToken is JArray childArray) + { + foreach (var childToken in childArray) + { + var childResult = CreateSingleChildInPrefab(childToken, targetGo, prefabRoot); + if (childResult.error != null) + { + return (false, childResult.error); + } + if (childResult.created) + { + modified = true; + } + } + } + else + { + // Handle single child object + var childResult = CreateSingleChildInPrefab(createChildToken, targetGo, prefabRoot); + if (childResult.error != null) + { + return (false, childResult.error); + } + if (childResult.created) + { + modified = true; + } + } + } + return (modified, null); } + /// + /// Creates a single child GameObject within the prefab contents. + /// + private static (bool created, ErrorResponse error) CreateSingleChildInPrefab(JToken createChildToken, GameObject defaultParent, GameObject prefabRoot) + { + JObject childParams; + if (createChildToken is JObject obj) + { + childParams = obj; + } + else + { + return (false, new ErrorResponse("'create_child' must be an object with child properties.")); + } + + // Required: name + string childName = childParams["name"]?.ToString(); + if (string.IsNullOrEmpty(childName)) + { + return (false, new ErrorResponse("'create_child.name' is required.")); + } + + // Optional: parent (defaults to the target object) + string parentName = childParams["parent"]?.ToString(); + Transform parentTransform = defaultParent.transform; + if (!string.IsNullOrEmpty(parentName)) + { + GameObject parentGo = FindInPrefabContents(prefabRoot, parentName); + if (parentGo == null) + { + return (false, new ErrorResponse($"Parent '{parentName}' not found in prefab for create_child.")); + } + parentTransform = parentGo.transform; + } + + // Create the GameObject + GameObject newChild; + string primitiveType = childParams["primitiveType"]?.ToString() ?? childParams["primitive_type"]?.ToString(); + if (!string.IsNullOrEmpty(primitiveType)) + { + try + { + PrimitiveType type = (PrimitiveType)Enum.Parse(typeof(PrimitiveType), primitiveType, true); + newChild = GameObject.CreatePrimitive(type); + newChild.name = childName; + } + catch (ArgumentException) + { + return (false, new ErrorResponse($"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}")); + } + } + else + { + newChild = new GameObject(childName); + } + + // Set parent + newChild.transform.SetParent(parentTransform, false); + + // Apply transform properties + Vector3? position = VectorParsing.ParseVector3(childParams["position"]); + Vector3? rotation = VectorParsing.ParseVector3(childParams["rotation"]); + Vector3? scale = VectorParsing.ParseVector3(childParams["scale"]); + + if (position.HasValue) + { + newChild.transform.localPosition = position.Value; + } + if (rotation.HasValue) + { + newChild.transform.localEulerAngles = rotation.Value; + } + if (scale.HasValue) + { + newChild.transform.localScale = scale.Value; + } + + // Add components + JArray componentsToAdd = childParams["componentsToAdd"] as JArray ?? childParams["components_to_add"] as JArray; + if (componentsToAdd != null) + { + for (int i = 0; i < componentsToAdd.Count; i++) + { + var compToken = componentsToAdd[i]; + string typeName = compToken.Type == JTokenType.String + ? compToken.ToString() + : (compToken as JObject)?["typeName"]?.ToString(); + + if (string.IsNullOrEmpty(typeName)) + { + // Clean up partially created child + UnityEngine.Object.DestroyImmediate(newChild); + return (false, new ErrorResponse($"create_child.components_to_add[{i}] must be a string or object with 'typeName' field, got {compToken.Type}")); + } + + if (!ComponentResolver.TryResolve(typeName, out Type componentType, out string error)) + { + // Clean up partially created child + UnityEngine.Object.DestroyImmediate(newChild); + return (false, new ErrorResponse($"Component type '{typeName}' not found for create_child: {error}")); + } + newChild.AddComponent(componentType); + } + } + + // Set tag if specified + string tag = childParams["tag"]?.ToString(); + if (!string.IsNullOrEmpty(tag)) + { + try + { + newChild.tag = tag; + } + catch (Exception ex) + { + UnityEngine.Object.DestroyImmediate(newChild); + return (false, new ErrorResponse($"Failed to set tag '{tag}' on child '{childName}': {ex.Message}")); + } + } + + // Set layer if specified + string layerName = childParams["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) + { + int layerId = LayerMask.NameToLayer(layerName); + if (layerId == -1) + { + UnityEngine.Object.DestroyImmediate(newChild); + return (false, new ErrorResponse($"Invalid layer '{layerName}' for child '{childName}'. Use a valid layer name.")); + } + newChild.layer = layerId; + } + + // Set active state + bool? setActive = childParams["setActive"]?.ToObject() ?? childParams["set_active"]?.ToObject(); + if (setActive.HasValue) + { + newChild.SetActive(setActive.Value); + } + + McpLog.Info($"[ManagePrefabs] Created child '{childName}' under '{parentTransform.name}' in prefab."); + return (true, null); + } + #endregion #region Hierarchy Builder diff --git a/Server/src/services/tools/manage_prefabs.py b/Server/src/services/tools/manage_prefabs.py index 90dd127d7..15df5f803 100644 --- a/Server/src/services/tools/manage_prefabs.py +++ b/Server/src/services/tools/manage_prefabs.py @@ -25,6 +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 " + "(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\"}]. " "Use manage_asset action=search filterType=Prefab to list prefabs." ), annotations=ToolAnnotations( @@ -59,6 +63,7 @@ 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, ) -> 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: @@ -143,6 +148,36 @@ async def manage_prefabs( params["componentsToAdd"] = components_to_add if components_to_remove is not None: params["componentsToRemove"] = components_to_remove + if create_child is not None: + # Normalize vector fields within create_child (handles single object or array) + def normalize_child_params(child: Any, index: int | None = None) -> tuple[dict | None, str | None]: + prefix = f"create_child[{index}]" if index is not None else "create_child" + if not isinstance(child, dict): + return None, f"{prefix} must be a dict with child properties (name, primitive_type, position, etc.), got {type(child).__name__}" + child_params = dict(child) + for vec_field in ("position", "rotation", "scale"): + if vec_field in child_params and child_params[vec_field] is not None: + vec_val, vec_err = normalize_vector3(child_params[vec_field], f"{prefix}.{vec_field}") + if vec_err: + return None, vec_err + child_params[vec_field] = vec_val + return child_params, None + + if isinstance(create_child, list): + # Array of children + normalized_children = [] + for i, child in enumerate(create_child): + child_params, err = normalize_child_params(child, i) + if err: + return {"success": False, "message": err} + normalized_children.append(child_params) + params["createChild"] = normalized_children + else: + # Single child object + child_params, err = normalize_child_params(create_child) + if err: + return {"success": False, "message": err} + params["createChild"] = child_params # Send command to Unity response = await send_with_unity_instance( diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs index da6c5063d..b4949224d 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManagePrefabsCrudTests.cs @@ -532,6 +532,194 @@ public void ModifyContents_PreventsHierarchyLoops() } } + [Test] + public void ModifyContents_CreateChild_AddsSingleChildWithPrimitive() + { + string prefabPath = CreateTestPrefab("CreateChildTest"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["createChild"] = new JObject + { + ["name"] = "NewSphere", + ["primitive_type"] = "Sphere", + ["position"] = new JArray(1f, 2f, 3f), + ["scale"] = new JArray(0.5f, 0.5f, 0.5f) + } + })); + + Assert.IsTrue(result.Value("success")); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Transform child = reloaded.transform.Find("NewSphere"); + Assert.IsNotNull(child, "Child should exist"); + Assert.AreEqual(new Vector3(1f, 2f, 3f), child.localPosition); + Assert.AreEqual(new Vector3(0.5f, 0.5f, 0.5f), child.localScale); + Assert.IsNotNull(child.GetComponent(), "Sphere primitive should have SphereCollider"); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_CreateChild_AddsEmptyGameObject() + { + string prefabPath = CreateTestPrefab("EmptyChildTest"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["createChild"] = new JObject + { + ["name"] = "EmptyChild", + ["position"] = new JArray(0f, 5f, 0f) + } + })); + + Assert.IsTrue(result.Value("success")); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Transform child = reloaded.transform.Find("EmptyChild"); + Assert.IsNotNull(child, "Empty child should exist"); + Assert.AreEqual(new Vector3(0f, 5f, 0f), child.localPosition); + // Empty GO should only have Transform + Assert.AreEqual(1, child.GetComponents().Length, "Empty child should only have Transform"); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_CreateChild_AddsMultipleChildrenFromArray() + { + string prefabPath = CreateTestPrefab("MultiChildTest"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["createChild"] = new JArray + { + new JObject { ["name"] = "Child1", ["primitive_type"] = "Cube", ["position"] = new JArray(1f, 0f, 0f) }, + new JObject { ["name"] = "Child2", ["primitive_type"] = "Sphere", ["position"] = new JArray(-1f, 0f, 0f) }, + new JObject { ["name"] = "Child3", ["position"] = new JArray(0f, 1f, 0f) } + } + })); + + Assert.IsTrue(result.Value("success")); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Assert.IsNotNull(reloaded.transform.Find("Child1"), "Child1 should exist"); + Assert.IsNotNull(reloaded.transform.Find("Child2"), "Child2 should exist"); + Assert.IsNotNull(reloaded.transform.Find("Child3"), "Child3 should exist"); + Assert.IsNotNull(reloaded.transform.Find("Child1").GetComponent(), "Child1 should be Cube"); + Assert.IsNotNull(reloaded.transform.Find("Child2").GetComponent(), "Child2 should be Sphere"); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_CreateChild_SupportsNestedParenting() + { + string prefabPath = CreateNestedTestPrefab("NestedCreateChildTest"); + + try + { + var result = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["createChild"] = new JObject + { + ["name"] = "NewGrandchild", + ["parent"] = "Child1", + ["primitive_type"] = "Capsule" + } + })); + + Assert.IsTrue(result.Value("success")); + + GameObject reloaded = AssetDatabase.LoadAssetAtPath(prefabPath); + Transform newChild = reloaded.transform.Find("Child1/NewGrandchild"); + Assert.IsNotNull(newChild, "NewGrandchild should be under Child1"); + Assert.IsNotNull(newChild.GetComponent(), "Should be Capsule primitive"); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + + [Test] + public void ModifyContents_CreateChild_ReturnsErrorForInvalidInput() + { + string prefabPath = CreateTestPrefab("InvalidChildTest"); + + try + { + // Missing required 'name' field + var missingName = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["createChild"] = new JObject + { + ["primitive_type"] = "Cube" + } + })); + Assert.IsFalse(missingName.Value("success")); + Assert.IsTrue(missingName.Value("error").Contains("name")); + + // Invalid parent + var invalidParent = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["createChild"] = new JObject + { + ["name"] = "TestChild", + ["parent"] = "NonexistentParent" + } + })); + Assert.IsFalse(invalidParent.Value("success")); + Assert.IsTrue(invalidParent.Value("error").Contains("not found")); + + // Invalid primitive type + var invalidPrimitive = ToJObject(ManagePrefabs.HandleCommand(new JObject + { + ["action"] = "modify_contents", + ["prefabPath"] = prefabPath, + ["createChild"] = new JObject + { + ["name"] = "TestChild", + ["primitive_type"] = "InvalidType" + } + })); + Assert.IsFalse(invalidPrimitive.Value("success")); + Assert.IsTrue(invalidPrimitive.Value("error").Contains("Invalid primitive type")); + } + finally + { + SafeDeleteAsset(prefabPath); + } + } + #endregion #region Error Handling