Files
sbox-public/engine/Sandbox.Tools/Scene/Session/SceneEditorSession.cs
James King e24c2b592f Movie Maker Fix Mix (#4254)
* Support types like Curve, Gradient, ParticleGradient in movies
* Fix TransformOperation.OnReduce: old implementation was nonsense, could stack overflow
* Add optional filePath parameter to GenerateScreenshotFilename
* Force movie project to save even if we don't think it's changed
* ITemporaryEffect.IsActive pseudo-property for movie recordings
* Add ITrack.GetPathString() extension
* Safety when trying to compress a sample block
* Skip updating playback rate on renderers with a bone merge target
* Move PropertySignal<T>.FromSamples to PropertySignal
* Fix view not always updating when zooming
* Enabled tracks defaults to false when applying
* Movie export: manually call Scene.EditorTick, pause updates elsewhere (fixes Facepunch/sbox-public#10137)
* CanMakeTrackFromProperties: default to only allowing user types
2026-03-10 11:03:42 +00:00

567 lines
14 KiB
C#

using Facepunch.ActionGraphs;
using Sandbox.ActionGraphs;
using System;
using System.IO;
namespace Editor;
/// <summary>
/// A SceneEditorSession holds a Scene that is open in the editor.
/// It creates a widget, has a selection and undo system.
/// </summary>
public partial class SceneEditorSession : Scene.ISceneEditorSession
{
/// <summary>
/// All open editor sessions
/// </summary>
public static List<SceneEditorSession> All { get; } = new();
/// <summary>
/// The editor session that is currently active
/// </summary>
public static SceneEditorSession Active
{
get;
private set
{
if ( field == value ) return;
field = value;
field?.UpdateEditorTitle();
}
}
// scenes that have been edited, but waiting for a set interval to react
// just to debounce changes.
private static HashSet<SceneEditorSession> editedScenes = new();
/// <summary>
/// Returns true if this session is editing a prefab
/// </summary>
public bool IsPrefabSession => this is PrefabEditorSession;
public Scene Scene { get; private set; }
internal Widget SceneDock { get; set; }
/// <summary>
/// Should we call <see cref="Scene.EditorTick"/> while this session is visible?
/// </summary>
public bool ShouldUpdate { get; set; } = true;
protected SceneEditorSession( Scene scene )
{
ArgumentNullException.ThrowIfNull( scene );
Scene = scene;
Scene.Editor = this;
All.Add( this );
InitUndo();
timeSinceSavedState = 0;
if ( this is not GameEditorSession )
{
// create dock - but not for game sessions, those are built into the parent session widget
CreateSceneDock();
}
EditorEvent.Register( this );
}
/// <summary>
/// Create the tabbed dock widget that holds the scene view
/// </summary>
void CreateSceneDock()
{
SceneDock = EditorTypeLibrary.Create<Widget>( "SceneDock", new object[] { this } );
SceneDock.Name = $"SceneDock:{(Scene.Source?.ResourcePath ?? "untitled")}";
SceneDock.Parent = EditorWindow;
SceneDock.Visible = true;
UpdateEditorTitle();
Dock();
}
internal static void OnEditorWindowRestoreLayout()
{
// When we restore a layout it hides all windows and restores a default layout
// Our currently open scenes are going to be in limbo with no area
// So we need to show and dock them
// Restoring will open a blank SceneDock as an area for the others to dock on
var dummy = All.Where( x => x.Scene.Source is null && EditorWindow.DockManager.IsDockOpen( x.SceneDock ) ).FirstOrDefault();
foreach ( var entry in All )
{
if ( EditorWindow.DockManager.IsDockOpen( entry.SceneDock ) )
continue;
entry.Dock();
}
// Remove our dummy dock, unless it's the only one open somehow
if ( All.Count > 1 )
dummy?.Destroy();
}
void Dock()
{
// Don't try to dock if we're being made by the DockManager (it will dock us after)
if ( EditorWindow.DockManager._creatingDock )
return;
// Dock inside the same area as other scenes (must be open)
var siblingDock = All.Where( x => x != this && EditorWindow.DockManager.IsDockOpen( x.SceneDock ) ).FirstOrDefault();
if ( siblingDock is not null )
{
EditorWindow.DockManager.AddDock( siblingDock.SceneDock, SceneDock, DockArea.Inside );
return;
}
// It should be impossible to have no scenes open, fail safe
EditorWindow.DockManager.AddDock( null, SceneDock, DockArea.LastUsed );
}
bool _destroyed;
public virtual void Destroy()
{
if ( _destroyed )
return;
_destroyed = true;
// If this is the active scene
// switch away to a sibling
if ( this == Active )
{
Active = null;
var index = All.IndexOf( this );
if ( index >= 0 && All.Count > 1 )
{
if ( index > 0 ) index--;
else index++;
Active = All[index];
}
}
All.Remove( this );
EditorEvent.Unregister( this );
Scene?.Destroy();
Scene = null;
GameSession?.Destroy();
GameSession = null;
SceneDock?.Destroy();
SceneDock = default;
}
/// <summary>
/// Makes this scene active and brings it to the front
/// </summary>
public void MakeActive( bool bringToFront = true )
{
Active = this;
if ( bringToFront && EditorWindow is not null )
{
BringToFront();
}
}
/// <summary>
/// Bring this scene tab to the front
/// </summary>
public void BringToFront()
{
if ( EditorWindow.DockManager.IsDockOpen( SceneDock, false ) )
{
EditorWindow.DockManager.RaiseDock( SceneDock );
}
UpdateEditorTitle();
}
RealTimeSince timeSinceSavedState;
public void Tick()
{
//
// If this is an editor scene, tick it to flush deleted objects etc
//
Scene.ProcessDeletes();
// Save camera state to disk
if ( timeSinceSavedState > 1.0f )
{
timeSinceSavedState = 0;
EditorEvent.Run( "scene.session.save" );
}
}
internal void UpdateEditorTitle()
{
if ( !SceneDock.IsValid() )
return;
var name = Scene.Name.ToTitleCase().Trim();
if ( Scene.Editor?.HasUnsavedChanges ?? false ) name += "*";
EditorWindow?.UpdateEditorTitle( name );
if ( SceneDock is not null )
{
SceneDock.Name = $"SceneDock:{(Scene.Source?.ResourcePath ?? "untitled")}";
if ( IsPrefabSession )
{
SceneDock.SetWindowIcon( "home_repair_service" );
SceneDock.WindowTitle = $"Prefab: {name}";
}
else
{
SceneDock.SetWindowIcon( "grid_4x4" );
SceneDock.WindowTitle = name;
}
}
}
protected virtual void OnEdited()
{
}
static RealTimeSince timeSinceLastUpdatePrefabs;
internal static void ProcessSceneEdits()
{
if ( timeSinceLastUpdatePrefabs < 0.1 ) return;
timeSinceLastUpdatePrefabs = 0;
foreach ( var session in editedScenes.ToArray() )
{
session.OnEdited();
}
editedScenes.Clear();
}
/// <summary>
/// Pushes the active scene to the current scope
/// </summary>
public static IDisposable Scope()
{
return Active.Scene.Push();
}
[Obsolete]
void Scene.ISceneEditorSession.AddSelectionUndo()
{
PushUndoSelection();
}
public Action<BBox> OnFrameTo { get; set; }
/// <summary>
/// Zoom the scene to view this bbox
/// </summary>
public virtual void FrameTo( in BBox box )
{
BringToFront();
OnFrameTo?.Invoke( box );
}
bool unsavedChanges;
public bool HasUnsavedChanges
{
get => unsavedChanges;
set
{
editedScenes.Add( this );
if ( unsavedChanges == value )
return;
unsavedChanges = value;
UpdateEditorTitle();
}
}
BaseFileSystem Scene.ISceneEditorSession.TransientFilesystem => FileSystem.Transient;
public void Reload()
{
if ( Scene.Source is null )
return;
InitUndo();
Scene.Load( Scene.Source );
Selection.Clear();
}
public void Save( bool saveAs )
{
bool isPrefab = Scene is PrefabScene;
string extension = isPrefab ? "prefab" : "scene";
string fileType = isPrefab ? "Prefab" : "Scene";
var saveLocation = string.Empty;
if ( !saveAs && Scene.Source is not null && AssetSystem.FindByPath( Scene.Source.ResourcePath ) is Asset sourceAsset )
{
saveLocation = sourceAsset.AbsolutePath;
}
else
{
saveLocation = ProjectCookie.GetString( $"LastSaveLocation.{extension}", string.Empty );
if ( !Directory.Exists( saveLocation ) )
{
saveLocation = Project.Current.GetAssetsPath();
}
saveLocation = EditorUtility.SaveFileDialog( $"Save {fileType} As..", extension,
Path.Combine( saveLocation, $"untitled.{extension}" ) );
if ( saveLocation is null )
return;
if ( !saveLocation.NormalizeFilename( false ).StartsWith( Project.Current.GetAssetsPath().NormalizeFilename( false ) ) )
{
// enforce saving inside Assets/ - if it's outside, complain and let them retry
EditorUtility.DisplayDialog( $"Save Failed: Invalid location",
$"{fileType}s must be saved inside the Assets folder of your project", "Cancel", "Retry",
() => Save( true ) );
return;
}
ProjectCookie.SetString( $"LastSaveLocation.{extension}", Path.GetDirectoryName( saveLocation ) );
}
EditorEvent.Run( "scene.beforesave", Active.Scene );
var asset = AssetSystem.CreateResource( extension, saveLocation );
Assert.NotNull( asset, $"Failed to CreateResource for {fileType} at {saveLocation}" );
GameResource resource = Scene is PrefabScene prefabScene ? prefabScene.ToPrefabFile() : Scene.CreateSceneFile();
asset.SaveToDisk( resource );
// Update this scene's path
Scene.Source = resource;
Scene.Name = System.IO.Path.GetFileNameWithoutExtension( saveLocation );
HasUnsavedChanges = false;
EditorEvent.Run( "scene.saved", Active.Scene );
UpdateEditorTitle();
}
/// <summary>
/// Resolve a scene to an editor session. If it's a game scene, resolves the parent editor session.
/// </summary>
public static SceneEditorSession Resolve( Scene scene )
{
if ( scene.Editor is SceneEditorSession session )
{
if ( session is GameEditorSession gs )
return gs.Parent; // we want the editor session, not the game session
return session;
}
return All.FirstOrDefault( x => x.Scene == scene );
}
/// <summary>
/// Resolve a Component to an editor session.
/// </summary>
public static Scene.ISceneEditorSession Resolve( Component component ) => Resolve( component?.GameObject );
/// <summary>
/// Resolve a GameObject to an editor session.
/// </summary>
public static Scene.ISceneEditorSession Resolve( GameObject go )
{
if ( go is null ) return null;
var session = go.Scene.Editor;
if ( session is null )
{
Log.Error( $"Failed to resolve session for GameObject: {go}" );
}
return session;
}
/// <summary>
/// Resolve a scene file to an editor session.
/// </summary>
public static SceneEditorSession Resolve( SceneFile sceneFile )
{
return All.FirstOrDefault( x => x is not null && !x.IsPrefabSession && x is not GameEditorSession
&& string.Equals( sceneFile.ResourcePath, x.Scene.Source?.ResourcePath, StringComparison.OrdinalIgnoreCase ) );
}
/// <summary>
/// Resolve a prefab file to an editor session.
/// </summary>
public static SceneEditorSession Resolve( PrefabFile prefabFile )
{
return All.FirstOrDefault( x => x is not null && x.IsPrefabSession && x is not GameEditorSession
&& string.Equals( prefabFile.ResourcePath, x.Scene.Source?.ResourcePath, StringComparison.OrdinalIgnoreCase ) );
}
/// <summary>
/// Resolve an action graph source location to an editor session.
/// </summary>
public static SceneEditorSession Resolve( ISourceLocation sourceLocation )
{
return sourceLocation switch
{
GameResourceSourceLocation { Resource: SceneFile sceneFile } => Resolve( sceneFile ),
GameResourceSourceLocation { Resource: PrefabFile prefabFile } => Resolve( prefabFile ),
_ => null
};
}
[Obsolete]
public void RecordChange( SerializedProperty property )
{
}
/// <summary>
/// Make a new SceneEditorSession with a default scene
/// </summary>
public static SceneEditorSession CreateDefault()
{
var scene = Scene.CreateEditorScene();
using var _ = scene.Push();
scene.Name = "Untitled Scene";
{
var go = scene.CreateObject();
go.Name = "Main Camera";
go.LocalTransform = new Transform( Vector3.Up * 100 + Vector3.Backward * 300 );
go.Components.Create<CameraComponent>();
}
{
var go = scene.CreateObject();
go.Name = "Directional Light";
go.LocalTransform = new Transform( Vector3.Up * 200, Rotation.From( 80, 45, 0 ) );
go.Components.Create<DirectionalLight>();
}
return new SceneEditorSession( scene );
}
/// <summary>
/// Opens an editor session from an existing scene or prefab
/// </summary>
public static SceneEditorSession CreateFromPath( string path )
{
var resource = ResourceLibrary.Get<Resource>( path );
if ( resource is SceneFile sceneFile )
{
if ( SceneEditorSession.Resolve( sceneFile ) is SceneEditorSession existingSession )
{
existingSession.MakeActive();
return existingSession;
}
var openingScene = Scene.CreateEditorScene();
using var _ = openingScene.Push();
openingScene.Load( sceneFile );
var session = new SceneEditorSession( openingScene );
return session;
}
if ( resource is PrefabFile prefabFile )
{
if ( PrefabEditorSession.Resolve( prefabFile ) is PrefabEditorSession existingSession )
{
existingSession.MakeActive();
return existingSession;
}
var openingScene = PrefabScene.CreateForEditing();
using var _ = openingScene.Push();
openingScene.Load( prefabFile );
var session = new PrefabEditorSession( openingScene );
return session;
}
return null;
}
public IEnumerable<object> GetSelection()
{
foreach ( var obj in Selection )
{
yield return obj;
}
}
public Editor.SceneFolder GetSceneFolder()
{
if ( Scene?.Source?.ResourcePath == null )
return default;
if ( AssetSystem.FindByPath( Scene.Source.ResourcePath ) is Asset sourceAsset )
{
return new AssetFolderInstance( sourceAsset );
}
return default;
}
}
file class AssetFolderInstance : SceneFolder
{
string _folder;
string _relativeFolder;
BaseFileSystem _fs;
public AssetFolderInstance( Asset sourceAsset )
{
var relativePath = sourceAsset.GetSourceFile( false );
var assetPath = sourceAsset.GetSourceFile( true );
var extension = System.IO.Path.GetExtension( assetPath ).Replace( ".", "_" );
_folder = System.IO.Path.ChangeExtension( assetPath, null );
_folder = $"{_folder}{extension}_data";
_relativeFolder = System.IO.Path.ChangeExtension( relativePath, null );
_relativeFolder = $"{_relativeFolder}{extension}_data".NormalizeFilename( false );
System.IO.Directory.CreateDirectory( _folder );
_fs = new LocalFileSystem( _folder );
}
~AssetFolderInstance()
{
MainThread.Queue( () => _fs?.Dispose() );
}
public override string WriteFile( string filename, byte[] data )
{
_fs.WriteAllBytes( filename, data );
if ( filename.StartsWith( '/' ) ) filename = filename[1..];
return System.IO.Path.Combine( _relativeFolder, filename ).NormalizeFilename( false );
}
}