Editor/InteriorLayoutBuilder/RoomLayoutTool.Persistence.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using Sandbox;
namespace ReusableRoomLayout;
public sealed partial class RoomLayoutTool
{
public void EnsureLayoutLoadedForControls()
{
if ( IsLayoutInteractionActive() )
{
return;
}
EnsureCurrentSceneLayoutLoaded();
if ( HasLayoutContent() )
{
return;
}
if ( !layoutLoadAttempted )
{
LoadLayoutQuiet();
}
}
private bool LoadLayoutQuiet()
{
layoutLoadAttempted = true;
var layoutPath = CurrentLayoutPath();
foreach ( var store in LayoutStores() )
{
if ( !TryReadLayout( store, layoutPath, out var json ) )
{
continue;
}
RestoreLayoutAndResetHistory( json );
loadedLayoutPath = layoutPath;
return true;
}
if ( TryLoadLegacyLayout( layoutPath ) )
{
return true;
}
ResetLayoutDocument();
loadedLayoutPath = layoutPath;
return false;
}
private bool PersistLayoutQuiet( bool allowEmptySave = false, string layoutPath = null )
{
layoutPath ??= CurrentLayoutPath();
document.NormalizeIds();
if ( !allowEmptySave && !HasLayoutContent() && SavedLayoutHasContent( layoutPath ) )
{
Log.Warning( "Interior Layout Builder refused to overwrite a saved non-empty layout with an empty layout." );
return false;
}
var json = SerializeLayout();
var saved = false;
var failures = new List<string>();
foreach ( var store in LayoutStores() )
{
if ( TryWriteLayout( store, layoutPath, json, out var failure ) )
{
saved = true;
continue;
}
failures.Add( failure );
}
if ( saved )
{
return true;
}
Log.Warning( $"Unable to save interior layout to {layoutPath}: {string.Join( "; ", failures )}" );
return false;
}
private bool CommitLayoutChange( bool allowEmptyGeneratedClear = false )
{
var existing = RoomLayoutGeometryBuilder.FindGeneratedRoots( Scene );
if ( existing.Count > 0 && !allowEmptyGeneratedClear && !HasLayoutContent() )
{
if ( !TryReloadNonEmptyLayout() )
{
Log.Warning( "Interior Layout Builder refused to save or rebuild an empty layout over existing generated geometry." );
RollbackPendingLayoutChange();
return false;
}
}
if ( !PersistLayoutQuiet( allowEmptyGeneratedClear ) )
{
RollbackPendingLayoutChange();
return false;
}
RebuildGeneratedGeometryIfPresent( allowEmptyGeneratedClear );
RegisterLayoutEditorUndo( pendingLayoutUndoSnapshot, SerializeLayout() );
ClearPendingLayoutRollback();
return true;
}
private void RollbackPendingLayoutChange()
{
if ( pendingLayoutUndoSnapshot is null )
{
return;
}
var snapshot = pendingLayoutUndoSnapshot;
ClearPendingLayoutRollback();
RestoreLayout( snapshot );
}
private void ClearPendingLayoutRollback()
{
pendingLayoutUndoSnapshot = null;
}
private bool TryReloadNonEmptyLayout()
{
if ( HasLayoutContent() )
{
return true;
}
return LoadLayoutQuiet() && HasLayoutContent();
}
private string SerializeLayout()
{
return JsonSerializer.Serialize( document, JsonOptions );
}
private void EnsureCurrentSceneLayoutLoaded()
{
if ( IsLayoutInteractionActive() )
{
return;
}
var layoutPath = CurrentLayoutPath();
if ( string.Equals( loadedLayoutPath, layoutPath, StringComparison.OrdinalIgnoreCase ) )
{
return;
}
if ( HasLayoutContent() && !string.IsNullOrWhiteSpace( loadedLayoutPath ) )
{
PersistLayoutQuiet( layoutPath: loadedLayoutPath );
}
layoutLoadAttempted = false;
LoadLayoutQuiet();
}
private void RestoreLayout( string json )
{
document = JsonSerializer.Deserialize<RoomLayoutDocument>( json, JsonOptions ) ?? new RoomLayoutDocument();
document.NormalizeIds();
var floors = document.UsedFloors();
if ( !floors.Contains( activeFloor ) )
{
activeFloor = floors[0];
}
selectedRoomId = document.Rooms.FirstOrDefault( IsActiveFloor )?.Id ?? 0;
selectedDoorId = 0;
selectedWindowId = 0;
selectedFloorCutoutId = 0;
selectedCorridorId = 0;
drawingRoom = false;
movingRoom = false;
movingDoor = false;
movingWindow = false;
drawingFloorCutout = false;
movingFloorCutout = false;
selectMovePending = false;
resizingRoom = false;
resizingCorridor = false;
resizingFloorCutout = false;
drawingCorridor = false;
corridorDraftBendPoints.Clear();
corridorDraftForcedHorizontal = null;
activeLayoutEditSnapshot = null;
}
private void ResetLayoutDocument()
{
document = new RoomLayoutDocument();
selectedRoomId = 0;
selectedDoorId = 0;
selectedWindowId = 0;
selectedFloorCutoutId = 0;
selectedCorridorId = 0;
drawingRoom = false;
movingRoom = false;
movingDoor = false;
movingWindow = false;
drawingFloorCutout = false;
movingFloorCutout = false;
selectMovePending = false;
resizingRoom = false;
resizingCorridor = false;
resizingFloorCutout = false;
drawingCorridor = false;
corridorDraftBendPoints.Clear();
corridorDraftForcedHorizontal = null;
activeLayoutEditSnapshot = null;
}
private void RestoreLayoutAndResetHistory( string json )
{
RestoreLayout( json );
}
private bool IsLayoutInteractionActive()
{
return drawingRoom ||
movingRoom ||
movingDoor ||
movingWindow ||
drawingFloorCutout ||
movingFloorCutout ||
selectMovePending ||
resizingRoom ||
resizingCorridor ||
resizingFloorCutout ||
drawingCorridor ||
activeLayoutEditSnapshot is not null;
}
private bool SavedLayoutHasContent( string layoutPath )
{
foreach ( var store in LayoutStores() )
{
if ( TryReadLayout( store, layoutPath, out var json ) && LayoutJsonHasContent( json ) )
{
return true;
}
}
return false;
}
private static bool LayoutJsonHasContent( string json )
{
try
{
var saved = JsonSerializer.Deserialize<RoomLayoutDocument>( json, JsonOptions );
return saved is not null &&
(saved.Rooms.Count > 0 || saved.Doors.Count > 0 || saved.Windows.Count > 0 || saved.Corridors.Count > 0 || saved.FloorCutouts.Count > 0);
}
catch
{
return false;
}
}
private bool TryLoadLegacyLayout( string layoutPath )
{
if ( string.Equals( layoutPath, LegacyLayoutPath, StringComparison.OrdinalIgnoreCase ) )
{
return false;
}
var scene = CurrentSceneForLayout();
if ( scene is null || RoomLayoutGeometryBuilder.FindGeneratedRoots( scene ).Count == 0 )
{
return false;
}
foreach ( var store in LayoutStores() )
{
if ( !TryReadLayout( store, LegacyLayoutPath, out var json ) || !LayoutJsonHasContent( json ) )
{
continue;
}
RestoreLayoutAndResetHistory( json );
loadedLayoutPath = layoutPath;
PersistLayoutQuiet( layoutPath: layoutPath );
return true;
}
return false;
}
private static bool TryReadLayout( LayoutStore store, string layoutPath, out string json )
{
json = null;
try
{
if ( !store.FileSystem.FileExists( layoutPath ) )
{
return false;
}
json = store.FileSystem.ReadAllText( layoutPath );
return !string.IsNullOrWhiteSpace( json );
}
catch ( Exception exception )
{
Log.Warning( $"Unable to read interior layout from {store.Name}: {exception.Message}" );
return false;
}
}
private static bool TryWriteLayout( LayoutStore store, string layoutPath, string json, out string failure )
{
failure = "";
if ( store.FileSystem.IsReadOnly )
{
failure = $"{store.Name} is read-only";
return false;
}
try
{
var directory = LayoutDirectoryForPath( layoutPath );
if ( !string.IsNullOrWhiteSpace( directory ) )
{
store.FileSystem.CreateDirectory( directory );
}
store.FileSystem.WriteAllText( layoutPath, json );
return true;
}
catch ( Exception exception )
{
failure = $"{store.Name}: {exception.Message}";
return false;
}
}
private string CurrentLayoutPath()
{
var scene = CurrentSceneForLayout();
var scenePath = FileSystem.NormalizeFilename( scene?.Source?.ResourcePath ?? "" ).Trim( '/' );
if ( !string.IsNullOrWhiteSpace( scenePath ) )
{
return LayoutPathForScenePath( scenePath );
}
var sceneId = scene?.Id.ToString() ?? "inactive";
return $"{SceneLayoutDirectory}/unsaved/{SanitizeLayoutPathSegment( sceneId )}{LayoutFileExtension}";
}
private Sandbox.Scene CurrentSceneForLayout()
{
var scene = Manager?.CurrentSession?.Scene;
if ( scene is null )
{
return null;
}
return global::Editor.SceneEditorSession.Resolve( scene )?.Scene ?? scene;
}
private static string LayoutPathForScenePath( string scenePath )
{
var normalized = FileSystem.NormalizeFilename( scenePath ?? "" ).Trim( '/' );
if ( normalized.EndsWith( ".scene", StringComparison.OrdinalIgnoreCase ) )
{
normalized = normalized[..^".scene".Length];
}
if ( string.IsNullOrWhiteSpace( normalized ) )
{
normalized = "scene";
}
return $"{SceneLayoutDirectory}/{SanitizeLayoutRelativePath( normalized )}{LayoutFileExtension}";
}
private static string SanitizeLayoutRelativePath( string path )
{
var segments = path.Split( '/', StringSplitOptions.RemoveEmptyEntries )
.Select( SanitizeLayoutPathSegment )
.Where( segment => !string.IsNullOrWhiteSpace( segment ) );
var sanitized = string.Join( "/", segments );
return string.IsNullOrWhiteSpace( sanitized ) ? "scene" : sanitized;
}
private static string SanitizeLayoutPathSegment( string segment )
{
if ( string.IsNullOrWhiteSpace( segment ) )
{
return "scene";
}
var builder = new StringBuilder( segment.Length );
foreach ( var character in segment )
{
if ( char.IsLetterOrDigit( character ) || character is '-' or '_' or '.' )
{
builder.Append( char.ToLowerInvariant( character ) );
continue;
}
builder.Append( '_' );
}
return builder.Length == 0 ? "scene" : builder.ToString();
}
private static string LayoutDirectoryForPath( string layoutPath )
{
var normalized = FileSystem.NormalizeFilename( layoutPath ?? "" );
var slashIndex = normalized.LastIndexOf( '/' );
return slashIndex <= 0 ? "" : normalized[..slashIndex];
}
private static IEnumerable<LayoutStore> LayoutStores()
{
yield return new LayoutStore( "ProjectSettings", global::Editor.FileSystem.ProjectSettings );
yield return new LayoutStore( "Content", global::Editor.FileSystem.Content );
}
private readonly record struct LayoutStore( string Name, BaseFileSystem FileSystem );
}