using System; using System.Collections.Generic; using System.Linq; using Unity.Pipeline.Commands; using Unity.Pipeline.Editor.Authoring; using Unity.Pipeline.Models; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; using Object = UnityEngine.Object; namespace Unity.Pipeline.Editor.Commands.GameObjects { /// /// GameObject authoring commands (CLI-192): creating GameObjects, querying the scene hierarchy, /// and mutating the core GameObject/Transform surface (parenting, transform, active state, tag, /// layer, name, deletion). /// /// WHY these go through the same conventions as the rest of the authoring foundation: /// every mutation is wrapped in an and registered with Unity's /// Undo system so an agent's multi-step action reverts as a single collapsible step. Each /// mutated object is marked dirty (and its scene flagged dirty) so the change survives a Save and /// is visible to the rest of the Editor. Results come back as the canonical /// envelope (via ) so the /// agent can address the same object in a follow-up call. /// public static class GameObjectCommands { /// /// Create an empty GameObject or a built-in primitive in the active scene. /// /// Creation goes through for primitives (so the /// matching mesh + collider are attached exactly as the Editor would) and a plain /// new GameObject for the empty case. The object is registered with /// so it can be reverted, and an optional parent /// is resolved by handle so a whole hierarchy can be built in one sequence of calls. /// [CliCommand("create_gameobject", "Create an empty GameObject or a built-in primitive (cube/sphere/capsule/cylinder/plane/quad) in the active scene.")] public static AuthoringResult CreateGameObject( [CliArg("name", "Name for the new GameObject. Defaults to 'GameObject' (or the primitive name).")] string name = null, [CliArg("primitive", "Optional primitive type: cube, sphere, capsule, cylinder, plane, quad. Omit for an empty GameObject.")] string primitive = null, [CliArg("parent", "Optional parent handle (globalId/path/guid/instanceId/hierarchyPath). The new object becomes a child of it.")] ObjectRef parent = null) { // Resolve the parent once, outside the scope, so a bad handle fails before any creation. var parentTransform = ResolveParentTransform(parent); using (new AuthoringUndoScope("Create GameObject")) { var go = CreateOne(primitive, name, parentTransform); MarkDirty(go); return ObjectResolver.Describe(go); } } /// /// Create N GameObjects (empty or a primitive) in one server round-trip (CLI-223). /// /// WHY a separate plural command rather than overloading : the /// single command's contract — one call returns one — is relied /// on by existing callers and tests, so it is left untouched. This plural command is the batch /// surface and returns a envelope (count + per-item /// identities). When > 1 and no explicit name array is supplied, /// each object is suffixed Name1..NameN so they remain individually addressable. /// /// Per-index // /// are arrays of [x,y,z] vectors. When a vectors array is supplied its length MUST equal /// (a mismatch is a validation error). Omitted channels leave the /// object at its creation default (origin / identity / unit scale). The whole batch is wrapped /// in ONE so it reverts as a single Undo step. /// [CliCommand("create_gameobjects", "Batch-create N empty GameObjects or primitives in one call. Optional positions/rotations/scales are arrays of [x,y,z] (length must equal count). Returns the created identities.")] public static CreateGameObjectsResult CreateGameObjects( [CliArg("name", "Base name. With count>1 and no explicit names, objects are suffixed Name1..NameN.")] string name = null, [CliArg("primitive", "Optional primitive type: cube, sphere, capsule, cylinder, plane, quad. Omit for empty GameObjects.")] string primitive = null, [CliArg("parent", "Optional parent handle. Every created object becomes a child of it.")] ObjectRef parent = null, [CliArg("count", "How many GameObjects to create. Default 1.")] int count = 1, [CliArg("positions", "Local positions, one [x,y,z] per object. Length must equal count when supplied.")] float[][] positions = null, [CliArg("rotations", "Local Euler rotations (degrees), one [x,y,z] per object. Length must equal count when supplied.")] float[][] rotations = null, [CliArg("scales", "Local scales, one [x,y,z] per object. Length must equal count when supplied.")] float[][] scales = null) { if (count < 1) throw new ArgumentException($"'count' must be at least 1, got {count}."); ValidateVectorArray(positions, count, "positions"); ValidateVectorArray(rotations, count, "rotations"); ValidateVectorArray(scales, count, "scales"); // Resolve the parent once, outside the scope, so a bad handle fails before any creation. var parentTransform = ResolveParentTransform(parent); var result = new CreateGameObjectsResult(); using (new AuthoringUndoScope("Create GameObjects")) { for (int i = 0; i < count; i++) { // count==1 keeps the bare name (no "1" suffix) so a single-item batch reads // naturally; count>1 suffixes 1..N for individual addressability. var itemName = count > 1 && !string.IsNullOrEmpty(name) ? name + (i + 1) : name; var go = CreateOne(primitive, itemName, parentTransform); // Index the arg name so a null/mis-shaped entry names the offending element // (a JSON array may contain a null entry even when its length matches count). if (positions != null) go.transform.localPosition = ToVector3(positions[i], $"positions[{i}]"); if (rotations != null) go.transform.localEulerAngles = ToVector3(rotations[i], $"rotations[{i}]"); if (scales != null) go.transform.localScale = ToVector3(scales[i], $"scales[{i}]"); MarkDirty(go); result.GameObjects.Add(ObjectResolver.Describe(go)); } } result.Count = result.GameObjects.Count; return result; } /// /// Find GameObjects in the loaded scenes by name, tag, component type, and/or hierarchy path. /// All supplied filters are combined (AND). Returns the canonical identity of each match so an /// agent can act on the results without a second lookup. /// /// This is intentionally a structured query rather than a single-object resolve: agents need /// to discover what exists before they can reference it, and the result set carries the same /// identities used everywhere else. /// [CliCommand("find_gameobjects", "Find GameObjects in loaded scenes by name, tag, component type, and/or hierarchy path (filters are combined). Returns structured identities.")] public static FindGameObjectsResult FindGameObjects( [CliArg("name", "Exact name to match.")] string name = null, [CliArg("tag", "Tag to match (e.g. 'Player').")] string tag = null, [CliArg("type", "Component type name to match (e.g. 'Rigidbody', 'UnityEngine.Camera').")] string type = null, [CliArg("hierarchy_path", "Exact hierarchy path to match (e.g. '/Root/Child').")] string hierarchyPath = null, [CliArg("include_inactive", "Include inactive GameObjects. Default true.")] bool includeInactive = true) { Type componentType = null; if (!string.IsNullOrEmpty(type)) { componentType = TypeResolver.ResolveComponentType(type); if (componentType == null) throw new ArgumentException($"Could not resolve component type '{type}'."); } // Resolve the hierarchy filter once so it is comparable to each object's computed path. var hierarchyFilter = string.IsNullOrEmpty(hierarchyPath) ? null : NormalizeHierarchyPath(hierarchyPath); // Validate the tag up front: comparing against an undefined tag (CompareTag / .tag) would // log a Unity error per object. An unknown tag simply yields no matches. if (!string.IsNullOrEmpty(tag) && !InternalEditorTagExists(tag)) return new FindGameObjectsResult { Count = 0, GameObjects = new List() }; var matches = new List(); foreach (var go in EnumerateSceneGameObjects(includeInactive)) { if (!string.IsNullOrEmpty(name) && go.name != name) continue; if (!string.IsNullOrEmpty(tag) && go.tag != tag) continue; if (componentType != null && go.GetComponent(componentType) == null) continue; if (hierarchyFilter != null && GetHierarchyPath(go) != hierarchyFilter) continue; var described = ObjectResolver.Describe(go); if (described != null) matches.Add(described); } return new FindGameObjectsResult { Count = matches.Count, GameObjects = matches }; } /// /// Set the local transform (position, rotation in Euler degrees, scale) of a GameObject. /// Any omitted channel is left unchanged. Goes through /// on the Transform so the change is reversible and recorded as a prefab override where relevant. /// [CliCommand("set_transform", "Set a GameObject's local position/rotation(euler)/scale. Omitted channels are left unchanged.")] public static AuthoringResult SetTransform( [CliArg("target", "Handle of the GameObject to modify.", Required = true)] ObjectRef target, [CliArg("position", "Local position as [x,y,z].")] float[] position = null, [CliArg("rotation", "Local rotation as Euler angles [x,y,z] in degrees.")] float[] rotation = null, [CliArg("scale", "Local scale as [x,y,z].")] float[] scale = null) { var go = ResolveGameObject(target, "target"); using (new AuthoringUndoScope("Set Transform")) { var transform = go.transform; Undo.RegisterCompleteObjectUndo(transform, "Set Transform"); if (position != null) transform.localPosition = ToVector3(position, "position"); if (rotation != null) transform.localEulerAngles = ToVector3(rotation, "rotation"); if (scale != null) transform.localScale = ToVector3(scale, "scale"); MarkDirty(go); return ObjectResolver.Describe(go); } } /// /// Reparent a GameObject (or clear its parent so it becomes a scene root). Uses /// so the reparent — including the sibling-index change — /// is a single undoable operation. World position is preserved by default to match the /// Editor's drag-to-reparent behavior. /// [CliCommand("set_parent", "Reparent a GameObject under a new parent, or detach it to scene root when no parent is given.")] public static AuthoringResult SetParent( [CliArg("target", "Handle of the GameObject to reparent.", Required = true)] ObjectRef target, [CliArg("parent", "Handle of the new parent. Omit (or empty) to move the object to the scene root.")] ObjectRef parent = null, [CliArg("world_position_stays", "Keep the object's world position when reparenting. Default true.")] bool worldPositionStays = true) { var go = ResolveGameObject(target, "target"); Transform parentTransform = null; if (parent != null && !parent.IsEmpty) parentTransform = ResolveGameObject(parent, "parent").transform; using (new AuthoringUndoScope("Set Parent")) { Undo.SetTransformParent(go.transform, parentTransform, worldPositionStays, "Set Parent"); MarkDirty(go); return ObjectResolver.Describe(go); } } /// Set a GameObject's active self-state. [CliCommand("set_active", "Set a GameObject's active self-state (activeSelf).")] public static AuthoringResult SetActive( [CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target, [CliArg("active", "Desired active state.", Required = true)] bool active) { var go = ResolveGameObject(target, "target"); using (new AuthoringUndoScope("Set Active")) { Undo.RegisterCompleteObjectUndo(go, "Set Active"); go.SetActive(active); MarkDirty(go); return ObjectResolver.Describe(go); } } /// /// Set a GameObject's tag. The tag must already exist in the project's Tag Manager; an unknown /// tag surfaces a clear error rather than silently creating one (tag creation is a project /// settings mutation outside this command's scope). /// [CliCommand("set_tag", "Set a GameObject's tag (the tag must already exist in the project).")] public static AuthoringResult SetTag( [CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target, [CliArg("tag", "Tag to assign (must exist in the Tag Manager).", Required = true)] string tag) { var go = ResolveGameObject(target, "target"); if (!InternalEditorTagExists(tag)) throw new ArgumentException($"Tag '{tag}' does not exist in the project. Add it via the Tag Manager first."); using (new AuthoringUndoScope("Set Tag")) { Undo.RegisterCompleteObjectUndo(go, "Set Tag"); go.tag = tag; MarkDirty(go); return ObjectResolver.Describe(go); } } /// /// Set a GameObject's layer by name or numeric index. A name is resolved via /// ; an out-of-range index or unknown name is rejected. /// [CliCommand("set_layer", "Set a GameObject's layer by name or numeric index (0-31).")] public static AuthoringResult SetLayer( [CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target, [CliArg("layer", "Layer name (e.g. 'UI') or numeric index 0-31.", Required = true)] string layer) { var go = ResolveGameObject(target, "target"); var layerIndex = ResolveLayer(layer); using (new AuthoringUndoScope("Set Layer")) { Undo.RegisterCompleteObjectUndo(go, "Set Layer"); go.layer = layerIndex; MarkDirty(go); return ObjectResolver.Describe(go); } } /// Rename a GameObject. [CliCommand("rename_gameobject", "Rename a GameObject.")] public static AuthoringResult RenameGameObject( [CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target, [CliArg("name", "New name.", Required = true)] string name) { if (string.IsNullOrEmpty(name)) throw new ArgumentException("New name must not be empty."); var go = ResolveGameObject(target, "target"); using (new AuthoringUndoScope("Rename GameObject")) { Undo.RegisterCompleteObjectUndo(go, "Rename GameObject"); go.name = name; MarkDirty(go); return ObjectResolver.Describe(go); } } /// /// Delete a GameObject from the scene. Uses so the /// deletion is reversible. The result describes the object's identity captured *before* /// destruction (the live object is gone afterwards). /// [CliCommand("delete_gameobject", "Delete a GameObject from the scene (reversible via Undo).")] public static AuthoringResult DeleteGameObject( [CliArg("target", "Handle of the GameObject to delete.", Required = true)] ObjectRef target) { var go = ResolveGameObject(target, "target"); // Capture identity before destruction; the live object is invalid afterwards. var described = ObjectResolver.Describe(go); var scene = go.scene; using (new AuthoringUndoScope("Delete GameObject")) { Undo.DestroyObjectImmediate(go); if (scene.IsValid()) EditorSceneManager.MarkSceneDirty(scene); } return described; } #region Helpers /// /// Resolve an to a GameObject. Accepts either a GameObject handle or a /// Component handle (in which case its owning GameObject is used), matching how agents tend to /// hold references. Throws a descriptive error rather than returning null so the command /// surfaces a clear message to the caller. /// internal static GameObject ResolveGameObject(ObjectRef handle, string argName) { if (!ObjectResolver.TryResolve(handle, out var obj, out var error)) throw new ArgumentException($"Could not resolve '{argName}': {error}"); var go = obj as GameObject ?? (obj as Component)?.gameObject; if (go == null) throw new ArgumentException($"'{argName}' did not resolve to a GameObject (got {obj.GetType().Name})."); return go; } /// Mark a GameObject and its scene dirty so changes persist and the Editor refreshes. internal static void MarkDirty(GameObject go) { EditorUtility.SetDirty(go); if (go.scene.IsValid()) EditorSceneManager.MarkSceneDirty(go.scene); } /// /// Create a single GameObject (empty or a primitive), apply an optional name, register it for /// Undo, and parent it under when given. Shared by the /// single and the batch so both /// build objects identically. MUST be called inside an . /// private static GameObject CreateOne(string primitive, string name, Transform parentTransform) { GameObject go; if (!string.IsNullOrEmpty(primitive)) { if (!TryParsePrimitive(primitive, out var primitiveType)) throw new ArgumentException( $"Unknown primitive '{primitive}'. Expected one of: cube, sphere, capsule, cylinder, plane, quad."); go = GameObject.CreatePrimitive(primitiveType); } else { go = new GameObject(); } if (!string.IsNullOrEmpty(name)) go.name = name; Undo.RegisterCreatedObjectUndo(go, "Create GameObject"); if (parentTransform != null) Undo.SetTransformParent(go.transform, parentTransform, "Create GameObject"); return go; } /// /// Resolve an optional parent handle to its Transform, or null when no parent is supplied. /// Resolved before any creation so an invalid handle fails fast (and a batch creates nothing). /// private static Transform ResolveParentTransform(ObjectRef parent) { if (parent == null || parent.IsEmpty) return null; return ResolveGameObject(parent, "parent").transform; } /// /// Validate a per-item vectors array (positions/rotations/scales) up front, before any object is /// created. When supplied it must have exactly one entry per object, and every entry must be a /// non-null [x,y,z] triple (a JSON array can carry a null element even when its length matches /// count). A bad array is an agent error surfaced as an naming /// the offending index, so a batch applies fully or creates nothing rather than throwing an /// NRE part-way through. /// private static void ValidateVectorArray(float[][] vectors, int count, string argName) { if (vectors == null) return; if (vectors.Length != count) throw new ArgumentException( $"'{argName}' has {vectors.Length} entries but count is {count}; they must match."); for (int i = 0; i < vectors.Length; i++) { if (vectors[i] == null) throw new ArgumentException($"'{argName}[{i}]' must be an [x,y,z] array but was null."); if (vectors[i].Length != 3) throw new ArgumentException( $"'{argName}[{i}]' must have exactly 3 components [x,y,z], got {vectors[i].Length}."); } } private static bool TryParsePrimitive(string value, out PrimitiveType primitiveType) { switch (value.Trim().ToLowerInvariant()) { case "cube": primitiveType = PrimitiveType.Cube; return true; case "sphere": primitiveType = PrimitiveType.Sphere; return true; case "capsule": primitiveType = PrimitiveType.Capsule; return true; case "cylinder": primitiveType = PrimitiveType.Cylinder; return true; case "plane": primitiveType = PrimitiveType.Plane; return true; case "quad": primitiveType = PrimitiveType.Quad; return true; default: primitiveType = default; return false; } } private static Vector3 ToVector3(float[] values, string argName) { if (values == null) throw new ArgumentException($"'{argName}' must be an [x,y,z] array but was null."); if (values.Length != 3) throw new ArgumentException($"'{argName}' must have exactly 3 components [x,y,z], got {values.Length}."); return new Vector3(values[0], values[1], values[2]); } private static int ResolveLayer(string layer) { if (int.TryParse(layer, out var index)) { if (index < 0 || index > 31) throw new ArgumentException($"Layer index {index} is out of range (0-31)."); return index; } var named = LayerMask.NameToLayer(layer); if (named < 0) throw new ArgumentException($"Layer '{layer}' does not exist in the project."); return named; } private static bool InternalEditorTagExists(string tag) { return UnityEditorInternal.InternalEditorUtility.tags.Contains(tag); } private static IEnumerable EnumerateSceneGameObjects(bool includeInactive) { for (int s = 0; s < SceneManager.sceneCount; s++) { var scene = SceneManager.GetSceneAt(s); if (!scene.isLoaded) continue; foreach (var root in scene.GetRootGameObjects()) { foreach (var go in EnumerateRecursive(root, includeInactive)) yield return go; } } } private static IEnumerable EnumerateRecursive(GameObject go, bool includeInactive) { if (!includeInactive && !go.activeInHierarchy) yield break; yield return go; var transform = go.transform; for (int i = 0; i < transform.childCount; i++) { foreach (var child in EnumerateRecursive(transform.GetChild(i).gameObject, includeInactive)) yield return child; } } private static string GetHierarchyPath(GameObject go) { var sb = new System.Text.StringBuilder(go.name); var parent = go.transform.parent; while (parent != null) { sb.Insert(0, parent.name + "/"); parent = parent.parent; } return "/" + sb; } private static string NormalizeHierarchyPath(string path) { var trimmed = "/" + path.Trim().Trim('/'); return trimmed; } #endregion } }