using System; using System.Collections.Generic; using System.IO; 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; namespace Unity.Pipeline.Editor.Commands.Scenes { /// /// Scene-management authoring commands (CLI-193): create / open / save scenes, inspect open /// scenes, control the active scene, snapshot a scene's hierarchy, and manage the build scene /// list. They build on the same foundation as the asset commands — paths go through the /// sandbox and results come back as the canonical /// envelope so an agent can chain follow-up calls. /// /// WHY the play-mode guard: mutations (new/open/save/setActive) /// are undefined and destructive while the editor is entering or in play mode — they can discard /// in-flight play-mode state or leave the scene manager inconsistent. Rather than risk corrupting /// state, every mutating command refuses up front with a clear, *recoverable* error (an /// , surfaced by the server as a 400 "Command Execution /// Failed" without touching scene state). The caller can exit play mode and retry. /// public static class SceneCommands { private const string SceneExtension = ".unity"; // create_scene --template values. "empty" is the historical default (blank scene); "default" // seeds Unity's built-in "3D" new-scene contents (Main Camera + Directional Light). private const string TemplateEmpty = "empty"; private const string TemplateDefault = "default"; #region Create / Open / Save [CliCommand("create_scene", "Create a new scene and save it to the given path under the authoring root.")] public static AuthoringResult CreateScene( [CliArg("path", "Scene path relative to the authoring root (default Assets/); the Assets/ prefix and the .unity extension are optional. e.g. Scenes/Level1", Required = true)] string path, [CliArg("additive", "Open the new scene additively alongside currently open scenes instead of replacing them.", DefaultValue = false)] bool additive = false, [CliArg("template", "Initial contents: 'empty' (default) for a blank scene, or 'default' to seed a Main Camera + Directional Light matching Unity's built-in 3D template.", DefaultValue = TemplateEmpty)] string template = TemplateEmpty) { GuardNotPlaying("create_scene"); // Validate the template up front (before touching scene state) so an unknown value fails // recoverably with a clear, enumerated error rather than silently doing the wrong thing. var sceneSetup = ResolveSceneSetup(template); var normalized = ResolveScenePath(path); EnsureParentFolder(normalized); // NewSceneMode.Single replaces all open scenes; Additive keeps them. NewSceneSetup.EmptyScene // gives a blank scene (no default camera/light) — predictable for programmatic population; // NewSceneSetup.DefaultGameObjects gives exactly a Main Camera (tagged MainCamera) + a // Directional Light, identical to Unity's built-in "3D" new-scene template. var setup = additive ? NewSceneMode.Additive : NewSceneMode.Single; var scene = EditorSceneManager.NewScene(sceneSetup, setup); if (!EditorSceneManager.SaveScene(scene, normalized)) throw new InvalidOperationException($"Failed to save new scene to '{normalized}'."); AssetDatabase.Refresh(); return DescribeSceneAsset(normalized); } [CliCommand("open_scene", "Open an existing scene from the given path.")] public static AuthoringResult OpenScene( [CliArg("path", "Scene path relative to the authoring root (default Assets/); the Assets/ prefix and the .unity extension are optional.", Required = true)] string path, [CliArg("additive", "Open additively alongside currently open scenes instead of replacing them.", DefaultValue = false)] bool additive = false) { GuardNotPlaying("open_scene"); var normalized = ResolveScenePath(path); if (string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(normalized))) throw new InvalidOperationException($"No scene asset at '{normalized}'."); var mode = additive ? OpenSceneMode.Additive : OpenSceneMode.Single; var scene = EditorSceneManager.OpenScene(normalized, mode); if (!scene.IsValid()) throw new InvalidOperationException($"Failed to open scene '{normalized}'."); return DescribeSceneAsset(normalized); } [CliCommand("save_scene", "Save an open scene. Saves the active scene when no path is given.")] public static AuthoringResult SaveScene( [CliArg("path", "Path of the open scene to save (authoring-root relative; Assets/ prefix and .unity optional). Omit to save the active scene.")] string path = null) { GuardNotPlaying("save_scene"); var scene = string.IsNullOrEmpty(path) ? EditorSceneManager.GetActiveScene() : FindOpenScene(ResolveScenePath(path)); if (!scene.IsValid() || !scene.isLoaded) throw new InvalidOperationException( string.IsNullOrEmpty(path) ? "No valid active scene to save." : $"Scene '{path}' is not open."); // An untitled/never-saved scene has an empty path. SaveScene(scene) with no explicit path // would either trigger a modal "Save Scene" dialog (hangs a headless editor) or fail // non-deterministically. Refuse up front with a recoverable error so the caller picks a path. if (string.IsNullOrEmpty(scene.path)) throw new InvalidOperationException( $"Scene '{scene.name}' has never been saved (no path). " + "Create or save it with an explicit path first (create_scene ) — no scene state was changed."); if (!EditorSceneManager.SaveScene(scene)) throw new InvalidOperationException($"Failed to save scene '{scene.name}'."); AssetDatabase.Refresh(); return DescribeSceneAsset(scene.path); } [CliCommand("save_all", "Save all open scenes that have unsaved changes.")] public static object SaveAll() { GuardNotPlaying("save_all"); // SaveOpenScenes saves every loaded, dirty scene. A dirty untitled scene (empty path) would // make it pop a modal "Save Scene" dialog, which hangs a headless editor. Fail fast with a // recoverable error before saving anything so the caller saves that scene with an explicit path. var dirty = OpenScenes().Where(s => s.isDirty).ToList(); var untitled = dirty.Where(s => string.IsNullOrEmpty(s.path)).Select(s => s.name).ToList(); if (untitled.Count > 0) throw new InvalidOperationException( $"Cannot save all: {untitled.Count} dirty scene(s) have never been saved ({string.Join(", ", untitled)}). " + "Save each with an explicit path first (create_scene ) — no scenes were saved."); // Report which scenes it touched. var dirtyBefore = dirty.Select(s => s.path).ToList(); var saved = EditorSceneManager.SaveOpenScenes(); if (!saved && dirtyBefore.Count > 0) throw new InvalidOperationException("Failed to save one or more open scenes."); AssetDatabase.Refresh(); return new { saved, scenes = dirtyBefore }; } #endregion #region Inspect / Active [CliCommand("list_open_scenes", "List all currently open scenes with their load/active/dirty state.")] public static object ListOpenScenes() { // Read-only; safe in play mode, so no guard here. var active = SceneManager.GetActiveScene(); var scenes = OpenScenes() .Select(s => new { name = s.name, path = s.path, isLoaded = s.isLoaded, isDirty = s.isDirty, isActive = s.handle == active.handle, rootCount = s.isLoaded ? s.rootCount : 0 }) .ToList(); return new { count = scenes.Count, scenes }; } [CliCommand("set_active_scene", "Set which open scene is the active scene (new objects are created in the active scene).")] public static AuthoringResult SetActiveScene( [CliArg("path", "Path of an already-open scene to make active (authoring-root relative; Assets/ prefix and .unity optional).", Required = true)] string path) { GuardNotPlaying("set_active_scene"); var normalized = ResolveScenePath(path); var scene = FindOpenScene(normalized); if (!scene.IsValid() || !scene.isLoaded) throw new InvalidOperationException($"Scene '{path}' is not open; open it before making it active."); if (!SceneManager.SetActiveScene(scene)) throw new InvalidOperationException($"Failed to set '{normalized}' as the active scene."); return DescribeSceneAsset(scene.path); } [CliCommand("get_scene_hierarchy", "Return the GameObject tree of an open scene (or the active scene). Each node carries instanceId + hierarchyPath usable by GameObject commands.")] public static SceneHierarchy GetSceneHierarchy( [CliArg("path", "Path of the open scene to snapshot (authoring-root relative; Assets/ prefix and .unity optional). Omit for the active scene.")] string path = null) { // Read-only; safe in play mode. var scene = string.IsNullOrEmpty(path) ? SceneManager.GetActiveScene() : FindOpenScene(ResolveScenePath(path)); if (!scene.IsValid() || !scene.isLoaded) throw new InvalidOperationException( string.IsNullOrEmpty(path) ? "No valid active scene to read." : $"Scene '{path}' is not open."); var active = SceneManager.GetActiveScene(); var hierarchy = new SceneHierarchy { SceneName = scene.name, ScenePath = scene.path, IsDirty = scene.isDirty, IsActive = scene.handle == active.handle }; foreach (var root in scene.GetRootGameObjects()) hierarchy.Roots.Add(BuildNode(root, "/" + root.name)); return hierarchy; } #endregion #region Build settings [CliCommand("add_scene_to_build", "Add a scene to the Build Settings scene list (idempotent). Optionally enable it.")] public static object AddSceneToBuild( [CliArg("path", "Scene path to add (authoring-root relative; Assets/ prefix and .unity optional).", Required = true)] string path, [CliArg("enabled", "Whether the scene is enabled in the build list.", DefaultValue = true)] bool enabled = true) { GuardNotPlaying("add_scene_to_build"); var normalized = ResolveScenePath(path); if (string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(normalized))) throw new InvalidOperationException($"No scene asset at '{normalized}'."); var scenes = EditorBuildSettings.scenes.ToList(); var existing = scenes.FindIndex(s => PathsEqual(s.path, normalized)); if (existing >= 0) { // Idempotent: just reconcile the enabled flag. if (scenes[existing].enabled != enabled) scenes[existing] = new EditorBuildSettingsScene(normalized, enabled); } else { scenes.Add(new EditorBuildSettingsScene(normalized, enabled)); } EditorBuildSettings.scenes = scenes.ToArray(); return new { path = normalized, enabled, buildIndex = SceneUtility.GetBuildIndexByScenePath(normalized), count = scenes.Count }; } [CliCommand("remove_scene_from_build", "Remove a scene from the Build Settings scene list (idempotent).")] public static object RemoveSceneFromBuild( [CliArg("path", "Scene path to remove (authoring-root relative; Assets/ prefix and .unity optional).", Required = true)] string path) { GuardNotPlaying("remove_scene_from_build"); var normalized = ResolveScenePath(path); var scenes = EditorBuildSettings.scenes.ToList(); var removed = scenes.RemoveAll(s => PathsEqual(s.path, normalized)); EditorBuildSettings.scenes = scenes.ToArray(); return new { path = normalized, removed = removed > 0, count = scenes.Count }; } #endregion #region Helpers /// /// Refuse a mutating scene op while entering or in play mode. We deliberately throw a /// recoverable *before* touching any state, so the /// command surfaces as a clean failure rather than leaving the scene manager inconsistent. /// private static void GuardNotPlaying(string command) { if (EditorApplication.isPlayingOrWillChangePlaymode) throw new InvalidOperationException( $"'{command}' cannot run while the editor is in (or entering) play mode. " + "Exit play mode (editor_stop) and retry — no scene state was changed."); } /// /// Map the create_scene template argument to a . "empty" /// (or a null/blank value) keeps the historical blank-scene behavior; "default" seeds Unity's /// built-in "3D" template (Main Camera tagged MainCamera + Directional Light). An unknown value /// throws a recoverable listing the valid options. /// private static NewSceneSetup ResolveSceneSetup(string template) { // Treat a null/blank template as the default ("empty") for parity with an omitted argument. var value = string.IsNullOrWhiteSpace(template) ? TemplateEmpty : template.Trim(); if (string.Equals(value, TemplateEmpty, StringComparison.OrdinalIgnoreCase)) return NewSceneSetup.EmptyScene; if (string.Equals(value, TemplateDefault, StringComparison.OrdinalIgnoreCase)) return NewSceneSetup.DefaultGameObjects; throw new ArgumentException( $"Unknown template '{template}'. Valid values: '{TemplateEmpty}', '{TemplateDefault}'."); } /// Resolve an agent path through the sandbox and normalize it to a "*.unity" asset path. private static string ResolveScenePath(string path) { var normalized = ProjectPaths.Resolve(path, out var error); if (normalized == null) throw new ArgumentException(error); if (!normalized.EndsWith(SceneExtension, StringComparison.OrdinalIgnoreCase)) normalized += SceneExtension; return normalized; } /// Create the scene's parent folder chain if missing (mirrors FolderCommands). private static void EnsureParentFolder(string scenePath) { var parent = Path.GetDirectoryName(scenePath)?.Replace('\\', '/'); if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent)) return; CreateFolderRecursive(parent); } private static void CreateFolderRecursive(string assetsPath) { if (AssetDatabase.IsValidFolder(assetsPath)) return; var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/'); var name = Path.GetFileName(assetsPath); if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name)) throw new ArgumentException($"Invalid folder path '{assetsPath}'."); if (!AssetDatabase.IsValidFolder(parent)) CreateFolderRecursive(parent); AssetDatabase.CreateFolder(parent, name); } /// Build the canonical identity envelope for a scene asset path. private static AuthoringResult DescribeSceneAsset(string scenePath) { var asset = AssetDatabase.LoadAssetAtPath(scenePath); var result = ObjectResolver.Describe(asset) ?? new AuthoringResult { Type = nameof(SceneAsset) }; result.AssetPath = scenePath; return result; } /// Enumerate every open scene (loaded or not) by index. private static IEnumerable OpenScenes() { for (int i = 0; i < SceneManager.sceneCount; i++) yield return SceneManager.GetSceneAt(i); } /// Find an open scene by its (normalized) asset path. Returns an invalid Scene when not open. private static Scene FindOpenScene(string normalizedPath) { foreach (var scene in OpenScenes()) { if (PathsEqual(scene.path, normalizedPath)) return scene; } return default; } private static bool PathsEqual(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); private static SceneHierarchyNode BuildNode(GameObject go, string hierarchyPath) { var node = new SceneHierarchyNode { Name = go.name, InstanceId = PipelineUtils.GetObjectId(go), HierarchyPath = hierarchyPath, ActiveSelf = go.activeSelf, Components = go.GetComponents() // A missing/broken script serializes as a null component; skip it rather than NRE. .Where(c => c != null) .Select(c => c.GetType().Name) .ToList() }; var transform = go.transform; for (int i = 0; i < transform.childCount; i++) { var child = transform.GetChild(i).gameObject; node.Children.Add(BuildNode(child, hierarchyPath + "/" + child.name)); } return node; } #endregion } }