Editor/InteriorLayoutBuilder/RoomLayoutTool.cs
using System;
using System.Collections.Generic;
using System.Text.Json;
using Editor;
using Sandbox;
namespace ReusableRoomLayout;
[EditorTool]
[Title( "Interior Layout Builder" )]
[Icon( "dashboard_customize" )]
[Alias( "roomlayout" )]
[Group( "Scene" )]
public sealed partial class RoomLayoutTool : EditorTool
{
private const string DefaultLayoutDirectory = "room-layouts";
private const string LegacyLayoutFileName = "default.roomlayout.json";
private const string LegacyLayoutPath = DefaultLayoutDirectory + "/" + LegacyLayoutFileName;
private const string SceneLayoutDirectory = DefaultLayoutDirectory + "/by-scene";
private const string LayoutFileExtension = ".roomlayout.json";
private const float SelectDragThreshold = 4.0f;
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private readonly RoomLayoutGeometryBuilder builder = new();
private RoomLayoutDocument document = new();
private RoomLayoutToolMode mode = RoomLayoutToolMode.Select;
private int activeFloor;
private int selectedRoomId;
private int selectedDoorId;
private int selectedWindowId;
private int selectedFloorCutoutId;
private Vector2 dragStart;
private Vector2 dragCurrent;
private RoomLayoutRect moveStartBounds;
private float moveStartOffset;
private bool selectMovePending;
private RoomLayoutSelectionKind selectMovePendingKind;
private bool drawingRoom;
private bool movingRoom;
private bool movingDoor;
private bool movingWindow;
private bool drawingFloorCutout;
private bool movingFloorCutout;
private bool resizingRoom;
private BBox roomResizeStartBox;
private BBox roomResizeDeltaBox;
private bool resizingFloorCutout;
private BBox floorCutoutResizeStartBox;
private BBox floorCutoutResizeDeltaBox;
private int selectedCorridorId;
private bool resizingCorridor;
private BBox corridorResizeStartBox;
private BBox corridorResizeDeltaBox;
private bool drawingCorridor;
private readonly List<RoomLayoutPoint> corridorDraftBendPoints = new();
private Vector2 corridorDraftCurrent;
private bool? corridorDraftForcedHorizontal;
private string activeLayoutEditSnapshot;
private string pendingLayoutUndoSnapshot;
private bool applyingLayoutHistory;
private bool layoutLoadAttempted;
private string loadedLayoutPath;
public static RoomLayoutTool ActiveTool { get; private set; }
public static RoomLayoutTool LiveTool => ActiveTool is { IsLive: true } tool ? tool : null;
public bool IsLive => Manager?.CurrentTool == this;
public string Summary => $"{document.Rooms.Count} rooms, {document.Doors.Count} doors, {document.Windows.Count} windows, {document.Corridors.Count} corridors, {document.FloorCutouts.Count} cutouts, {document.UsedFloors().Count} floors";
public string ModeName => drawingCorridor
? "Create Corridor (drawing)"
: drawingFloorCutout
? "Create Floor Cutout (drawing)"
: mode switch
{
RoomLayoutToolMode.Select => "Select",
RoomLayoutToolMode.Rooms => "Create Room",
RoomLayoutToolMode.Doors => "Create Door",
RoomLayoutToolMode.Windows => "Create Window",
RoomLayoutToolMode.Corridors => "Create Corridor",
RoomLayoutToolMode.FloorCutouts => "Create Floor Cutout",
_ => mode.ToString()
};
public override void OnEnabled()
{
ActiveTool = this;
AllowGameObjectSelection = true;
layoutLoadAttempted = false;
LoadLayoutQuiet();
}
public override void OnDisabled()
{
CancelLayoutInteraction( restoreActiveEdit: true );
if ( ActiveTool == this )
{
ActiveTool = null;
}
}
public override void OnUpdate()
{
EnsureCurrentSceneLayoutLoaded();
var acceptsInput = AcceptsLayoutInput();
if ( !acceptsInput )
{
CancelLayoutInteraction( restoreActiveEdit: true );
}
DrawLayout();
if ( acceptsInput )
{
DrawSelectedRoomBounds();
DrawSelectedCorridorBounds();
DrawSelectedFloorCutoutBounds();
HandleMouse();
}
}
public void SetMode( RoomLayoutToolMode nextMode )
{
CancelLayoutInteraction( restoreActiveEdit: true );
mode = nextMode;
}
public void ClearLayout()
{
CancelLayoutInteraction( restoreActiveEdit: true );
PushLayoutUndo();
document = new RoomLayoutDocument();
selectedRoomId = 0;
selectedDoorId = 0;
selectedWindowId = 0;
selectedFloorCutoutId = 0;
selectedCorridorId = 0;
drawingCorridor = false;
drawingFloorCutout = false;
movingDoor = false;
movingWindow = false;
movingFloorCutout = false;
resizingFloorCutout = false;
selectMovePending = false;
corridorDraftBendPoints.Clear();
corridorDraftForcedHorizontal = null;
CommitLayoutChange( true );
}
public void RebuildGeneratedGeometry()
{
document.GeneratedGeometryEnabled = HasLayoutContent();
if ( !PersistLayoutQuiet() )
{
return;
}
var existing = RoomLayoutGeometryBuilder.FindGeneratedRoots( Scene );
RebuildGeneratedGeometry( existing, true, true );
}
public void ClearGeneratedGeometry()
{
document.GeneratedGeometryEnabled = false;
if ( !PersistLayoutQuiet() )
{
return;
}
var existing = RoomLayoutGeometryBuilder.FindGeneratedRoots( Scene );
using ( SceneEditorSession.Active.UndoScope( "Clear Generated Interior Layout" )
.WithGameObjectDestructions( existing )
.Push() )
{
foreach ( var root in existing )
{
root.Destroy();
}
}
}
public void RefreshGeneratedGeometry()
{
RebuildGeneratedGeometryIfPresent();
}
private void RebuildGeneratedGeometryIfPresent( bool allowEmptyGeneratedClear = false )
{
var existing = RoomLayoutGeometryBuilder.FindGeneratedRoots( Scene );
if ( existing.Count == 0 && (!document.GeneratedGeometryEnabled || !HasLayoutContent()) )
{
return;
}
if ( !allowEmptyGeneratedClear && !HasLayoutContent() )
{
Log.Warning( "Interior Layout Builder refused to rebuild generated geometry from an empty layout document." );
return;
}
RebuildGeneratedGeometry( existing, HasLayoutContent(), false );
}
private void RebuildGeneratedGeometry( IReadOnlyList<GameObject> existing, bool buildNewRoot, bool recordUndo )
{
if ( !recordUndo )
{
foreach ( var root in existing )
{
root.Destroy();
}
if ( buildNewRoot )
{
builder.Build( Scene, document );
}
return;
}
using ( SceneEditorSession.Active.UndoScope( "Rebuild Interior Layout" )
.WithGameObjectDestructions( existing )
.WithGameObjectCreations()
.Push() )
{
foreach ( var root in existing )
{
root.Destroy();
}
if ( buildNewRoot )
{
builder.Build( Scene, document );
}
}
}
private bool HasLayoutContent()
{
return document.Rooms.Count > 0 ||
document.Doors.Count > 0 ||
document.Windows.Count > 0 ||
document.Corridors.Count > 0 ||
document.FloorCutouts.Count > 0;
}
private void PushLayoutUndo()
{
PushLayoutUndo( SerializeLayout() );
}
private void PushLayoutUndo( string snapshot )
{
pendingLayoutUndoSnapshot = snapshot;
}
private void BeginLayoutEdit()
{
activeLayoutEditSnapshot ??= SerializeLayout();
}
private void CommitLayoutEdit()
{
if ( activeLayoutEditSnapshot is null )
{
return;
}
var current = SerializeLayout();
if ( activeLayoutEditSnapshot != current )
{
PushLayoutUndo( activeLayoutEditSnapshot );
if ( !CommitLayoutChange() )
{
return;
}
}
activeLayoutEditSnapshot = null;
}
private void RegisterLayoutEditorUndo( string before, string after )
{
if ( applyingLayoutHistory ||
string.IsNullOrWhiteSpace( before ) ||
before == after )
{
return;
}
var session = SceneEditorSession.Active;
if ( session is null )
{
return;
}
session.UndoSystem.Insert(
"Interior Layout",
() => ApplyLayoutHistorySnapshot( before ),
() => ApplyLayoutHistorySnapshot( after ) );
}
private void ApplyLayoutHistorySnapshot( string snapshot )
{
var current = SerializeLayout();
applyingLayoutHistory = true;
try
{
RestoreLayout( snapshot );
if ( !CommitLayoutChange( !HasLayoutContent() ) )
{
RestoreLayout( current );
}
}
finally
{
applyingLayoutHistory = false;
ClearPendingLayoutRollback();
}
}
private bool CancelLayoutInteraction( bool restoreActiveEdit )
{
var hadInteraction = drawingRoom ||
movingRoom ||
movingDoor ||
movingWindow ||
drawingFloorCutout ||
movingFloorCutout ||
selectMovePending ||
resizingRoom ||
resizingCorridor ||
resizingFloorCutout ||
drawingCorridor ||
activeLayoutEditSnapshot is not null;
if ( !hadInteraction )
{
return false;
}
var snapshot = activeLayoutEditSnapshot;
activeLayoutEditSnapshot = null;
if ( restoreActiveEdit && snapshot is not null )
{
RestoreLayout( snapshot );
}
ResetLayoutInteractionState();
return true;
}
private void ResetLayoutInteractionState()
{
var wasDrawingCorridor = drawingCorridor;
drawingRoom = false;
movingRoom = false;
movingDoor = false;
movingWindow = false;
drawingFloorCutout = false;
movingFloorCutout = false;
selectMovePending = false;
resizingRoom = false;
resizingCorridor = false;
resizingFloorCutout = false;
if ( wasDrawingCorridor )
{
CancelCorridorDraft();
return;
}
drawingCorridor = false;
corridorDraftBendPoints.Clear();
corridorDraftCurrent = default;
corridorDraftForcedHorizontal = null;
}
private bool AcceptsLayoutInput()
{
return Manager?.IsCurrentViewFocused == true && global::Editor.Application.FocusWidget.IsValid();
}
}
public enum RoomLayoutToolMode
{
Select,
Rooms,
Doors,
Windows,
Corridors,
FloorCutouts
}