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
}
}