Add create_child parameter to manage_prefabs modify_contents (#646)
* Add create_child parameter to manage_prefabs modify_contents Enables adding child GameObjects to existing prefabs via headless editing. Supports single object or array for batch creation in one save operation. Features: - Create children with primitive types (Cube, Sphere, etc.) - Set position, rotation, scale on new children - Add components to children - Specify parent within prefab hierarchy for nested children - Set tag, layer, and active state Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Address code review feedback for create_child validation - Fix type hint to `tuple[dict | None, str | None]` to match actual returns - Add explicit dict validation with clear error message including actual type - Error on invalid component entries instead of silently ignoring them - Return ErrorResponse for invalid tag/layer instead of just logging warnings Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Add unit tests for create_child prefab functionality Tests cover: - Single child with primitive type - Empty GameObject (no primitive_type) - Multiple children from array (batch creation) - Nested parenting within prefab - Error handling for invalid inputs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>main
parent
3d3d45e788
commit
62c015d873
|
|
@ -723,9 +723,188 @@ namespace MCPForUnity.Editor.Tools.Prefabs
|
|||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a single child GameObject within the prefab contents.
|
||||
/// </summary>
|
||||
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<bool?>() ?? childParams["set_active"]?.ToObject<bool?>();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ REQUIRED_PARAMS = {
|
|||
"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(
|
||||
|
|
|
|||
|
|
@ -532,6 +532,194 @@ namespace MCPForUnityTests.Editor.Tools
|
|||
}
|
||||
}
|
||||
|
||||
[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<bool>("success"));
|
||||
|
||||
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(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<SphereCollider>(), "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<bool>("success"));
|
||||
|
||||
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(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<Component>().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<bool>("success"));
|
||||
|
||||
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(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<BoxCollider>(), "Child1 should be Cube");
|
||||
Assert.IsNotNull(reloaded.transform.Find("Child2").GetComponent<SphereCollider>(), "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<bool>("success"));
|
||||
|
||||
GameObject reloaded = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||||
Transform newChild = reloaded.transform.Find("Child1/NewGrandchild");
|
||||
Assert.IsNotNull(newChild, "NewGrandchild should be under Child1");
|
||||
Assert.IsNotNull(newChild.GetComponent<CapsuleCollider>(), "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<bool>("success"));
|
||||
Assert.IsTrue(missingName.Value<string>("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<bool>("success"));
|
||||
Assert.IsTrue(invalidParent.Value<string>("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<bool>("success"));
|
||||
Assert.IsTrue(invalidPrimitive.Value<string>("error").Contains("Invalid primitive type"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
SafeDeleteAsset(prefabPath);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling
|
||||
|
|
|
|||
Loading…
Reference in New Issue