Editor/Tool/ArchBuildingWindow.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
namespace Sunless.Architecture;
// The building layout drawing: an orthographic PLAN of ONE building, storey by storey, opened from the button at the
// top of a building's sheet. It exists for the shapes the viewport is bad at - a chamfered corner, an L, a splay -
// where what you need is the numbers and a corner you can take hold of without a wall in front of it.
//
// Not an ArchDesignerDialog: those author a TYPE and hand a template back, where this holds a building that already
// stands in the plan and writes straight through to it, so every change is live in the viewport and on the undo stack
// the moment it is made. Modeless on purpose - you keep working in the scene with it open.
public sealed class ArchBuildingWindow : Window {
static ArchBuildingWindow standing;
readonly ArchTool tool;
readonly int buildingId;
const string LiveKey = "Arch.Layout.Live";
const int PanelWidth = 264;
ArchBuildingCanvas canvas;
Layout storeys;
Layout corner;
ScrollArea panel;
Label caption;
Button build;
// Off, an edit writes the plan and the drawing and the SCENE waits for Build. A whole building's rebuild measures
// in seconds where the plan write measures in nothing, and the drawing is the thing you are looking at.
bool live = EditorCookie.Get( LiveKey, false );
float setback = 96f;
// A press on the building already drawn puts the drawing away; a press on any other RE-POINTS it, or the button
// would read as "close" on every building but the one the window happens to be holding.
public static void Toggle( ArchTool tool, ArchBuilding building ) {
if ( standing is not null && standing.buildingId == building?.Id ) {
standing.Close();
return;
}
Open( tool, building );
}
public static void Open( ArchTool tool, ArchBuilding building ) {
if ( tool is null || building is null ) {
return;
}
// One window, re-pointed. Two drawings of two buildings is two undo stacks racing over one plan.
standing?.Close();
standing = new ArchBuildingWindow( tool, building );
standing.Show();
}
ArchBuildingWindow( ArchTool tool, ArchBuilding building ) {
this.tool = tool;
buildingId = building.Id;
DeleteOnClose = true;
WindowTitle = $"Building Layout — {building.Name}";
Size = new Vector2( 1000, 660 );
MinimumSize = new Vector2( 780, 500 );
Canvas = new Widget( this ) { Layout = Layout.Row() };
Canvas.Layout.Margin = 8;
Canvas.Layout.Spacing = 8;
BuildDrawing();
BuildPanel();
Rebuild();
}
ArchBuilding Subject() => tool?.Plan?.Buildings.FirstOrDefault( unit => unit.Id == buildingId );
void BuildDrawing() {
var column = Canvas.Layout.AddColumn( 1 );
column.Spacing = 6;
var bar = column.AddRow();
bar.Spacing = 4;
var others = new Checkbox( "Storeys under" ) { Value = true };
others.Toggled += () => {
canvas.Others = others.Value;
canvas.Update();
};
bar.Add( others );
var roofs = new Checkbox( "Roof" ) { Value = true };
roofs.Toggled += () => {
canvas.Roofs = roofs.Value;
canvas.Update();
};
bar.Add( roofs );
bar.AddStretchCell();
var living = new Checkbox( "Live" ) { Value = live };
living.Toggled += () => {
live = living.Value;
EditorCookie.Set( LiveKey, live );
if ( live ) {
Build();
}
};
bar.Add( living );
build = new Button.Primary( "Build", "refresh" ) { Clicked = Build };
bar.Add( build );
caption = new Label( "" );
bar.Add( caption );
canvas = new ArchBuildingCanvas( this, tool, Subject, Preview, Commit ) {
Selected = RebuildCorner
};
column.Add( canvas, 1 );
column.Add( new Label( "Drag a blue corner square to move it — every storey, wall, roof outline and parapet on "
+ "that corner follows. Drag a green run square to push the whole elevation out. Drag the pink round grip "
+ "inside a corner to cut it off at an angle: the facet becomes a real wall you can hang a door in." ) {
WordWrap = true
} );
}
void BuildPanel() {
var column = Canvas.Layout.AddColumn();
column.Spacing = 6;
column.Add( new Label( "Storeys" ) );
var scroll = new ScrollArea( this );
scroll.Canvas = new Widget( scroll ) { Layout = Layout.Column() };
scroll.Canvas.Layout.Spacing = 2;
scroll.MinimumWidth = PanelWidth;
scroll.MaximumWidth = PanelWidth;
storeys = scroll.Canvas.Layout;
column.Add( scroll, 1 );
panel = new ScrollArea( this );
panel.Canvas = new Widget( panel ) { Layout = Layout.Column() };
panel.Canvas.Layout.Spacing = 4;
panel.MinimumWidth = PanelWidth;
panel.MaximumWidth = PanelWidth;
corner = panel.Canvas.Layout;
column.Add( panel, 1 );
}
// One row per storey the building actually holds, with what stands on it - the list is the navigation, exactly as
// the climb is in the stair drawing.
void RebuildStoreys() {
storeys.Clear( true );
if ( Subject() is not { } building ) {
return;
}
foreach ( var floor in building.Rooms.Select( room => room.Floor ).Distinct().OrderByDescending( floor => floor ) ) {
storeys.Add( StoreyRow( building, floor ) );
}
storeys.AddStretchCell();
}
Widget StoreyRow( ArchBuilding building, int floor ) {
var rooms = building.Rooms.Where( room => room.Floor == floor ).ToList();
var picked = floor == canvas.Storey;
var row = new Widget( this ) { Layout = Layout.Column() };
row.Layout.Margin = 6;
row.Layout.Spacing = 2;
var head = row.Layout.AddRow();
head.Spacing = 4;
head.Add( new Label( ArchStoreyEdit.Name( floor ) ) );
head.AddStretchCell();
head.Add( new Label( $"{rooms.Count} room{(rooms.Count == 1 ? "" : "s")}" ) );
var walls = rooms.Sum( room => room.Walls.Count );
var holes = rooms.SelectMany( room => room.Walls ).Sum( wall => wall.Openings.Count );
row.Layout.Add( new Label( $"{walls} walls · {holes} openings"
+ (rooms.Any( room => room.Facade ) ? " · facade" : "") ) );
row.MouseLeftPress += () => {
canvas.Storey = floor;
canvas.Picked = -1;
canvas.PickedEdge = -1;
canvas.Forget();
Rebuild();
};
row.SetStyles( picked
? "background-color: #3b6ea5; border-radius: 3px;"
: "background-color: #2a2a2e; border-radius: 3px;" );
return row;
}
// What can be done to the corner or the run held. It is the same set of edits the viewport's own widgets make, put
// where the numbers can be typed instead of dragged.
void RebuildCorner() {
corner.Clear( true );
if ( Subject() is not { } building ) {
return;
}
var ring = canvas.Ring();
if ( ring is not { Count: >= 3 } ) {
corner.Add( new Label( "This storey makes no single ring of corners — a detached wing is two shells, and "
+ "neither one describes the other." ) { WordWrap = true } );
corner.AddStretchCell();
return;
}
if ( canvas.Picked >= 0 && canvas.Picked < ring.Count ) {
CornerPage( building, ring, canvas.Picked );
} else if ( canvas.PickedEdge >= 0 && canvas.PickedEdge < ring.Count ) {
RunPage( building, ring, canvas.PickedEdge );
} else {
corner.Add( new Label( "Click a corner or a run in the drawing." ) { WordWrap = true } );
}
corner.AddStretchCell();
}
// The corner's own two numbers, and the SHAPES it can be turned into. Named operations rather than free corner
// surgery: a chamfer moves eight lists in step across every storey, and a shell edited by adding and merging bare
// points has no invariant left to protect it - two removes past a triangle folded a whole building flat.
void CornerPage( ArchBuilding building, List<Vector2> ring, int index ) {
var at = ring[index];
corner.Add( new Label( $"Corner {index + 1}" ) );
corner.Add( ArchPartUi.Number( "East", at.x, 0f, value => Move( building, ring, index, new Vector2( value, at.y ) ) ) );
corner.Add( ArchPartUi.Number( "North", at.y, 0f, value => Move( building, ring, index, new Vector2( at.x, value ) ) ) );
corner.Add( new Label( "Chamfer" ) );
corner.Add( new Label( "Cut the corner off at an angle and stand a real wall across the gap. The facet is dressed "
+ "off the elevation beside it, so the bands, pilasters and window rhythm carry round — and it takes a door "
+ "like any other wall." ) { WordWrap = true } );
corner.Add( ArchPartUi.Number( "Setback along each run", setback, 96f, value => setback = value ) );
corner.Add( new Button.Primary( "Chamfer this corner", "content_cut" ) {
Clicked = () => canvas.Chamfer( index, setback ),
ToolTip = "Or drag the pink round grip inside the corner in the drawing"
} );
}
void RunPage( ArchBuilding building, List<Vector2> ring, int index ) {
var from = ring[index];
var to = ring[(index + 1) % ring.Count];
var span = to - from;
corner.Add( new Label( $"Run {index + 1} — {span.Length:0.#} long" ) );
corner.Add( new Label( $"{from.x:0.#}, {from.y:0.#} to {to.x:0.#}, {to.y:0.#}" ) { WordWrap = true } );
var walls = On( building, from, to ).ToList();
corner.Add( new Label( walls.Count == 0
? "No wall stands on this run."
: string.Join( ", ", walls.Select( wall => $"wall {wall.Id} · {wall.Length:0.#}" ) ) ) { WordWrap = true } );
corner.Add( ArchPartUi.Number( "Push the elevation out by", 0f, 0f, value => Push( building, ring, index, value ) ) );
}
// Every wall on this storey whose whole span lies along the run, so the panel names the walls the push will carry.
IEnumerable<ArchWall> On( ArchBuilding building, Vector2 from, Vector2 to ) {
var span = to - from;
if ( span.IsNearZeroLength ) {
yield break;
}
var along = span.Normal;
var normal = new Vector2( -along.y, along.x );
foreach ( var wall in building.Rooms.Where( room => room.Floor == canvas.Storey ).SelectMany( room => room.Walls ) ) {
if ( MathF.Abs( Vector2.Dot( wall.Start - from, normal ) ) < ArchCarry.CornerReach
&& MathF.Abs( Vector2.Dot( wall.End - from, normal ) ) < ArchCarry.CornerReach ) {
yield return wall;
}
}
}
void Move( ArchBuilding building, List<Vector2> ring, int index, Vector2 to ) {
if ( !ArchCarry.Corner( building, ring[index], ArchGridService.Fine( to ) ) ) {
return;
}
canvas.Forget();
Commit();
}
void Push( ArchBuilding building, List<Vector2> ring, int index, float distance ) {
var from = ring[index];
var to = ring[(index + 1) % ring.Count];
var span = to - from;
if ( span.IsNearZeroLength || MathF.Abs( distance ) < 0.05f ) {
return;
}
var normal = new Vector2( -span.Normal.y, span.Normal.x );
if ( !ArchCarry.Along( building, from, to, normal * ArchGridService.Fine( distance ) ) ) {
return;
}
canvas.Forget();
Commit();
}
// The whole window: for a change of STRUCTURE - a corner cut, a storey picked, a shape re-traced.
void Rebuild() {
if ( Subject() is null ) {
Close();
return;
}
Retitle();
RebuildStoreys();
RebuildCorner();
canvas.Update();
}
void Retitle() {
if ( Subject() is not { } building ) {
return;
}
var floors = building.Rooms.Select( room => room.Floor ).Distinct().Count();
var waiting = tool is { Unbuilt: true };
caption.Text = $"{floors} storey{(floors == 1 ? "" : "s")}"
+ $" · {building.Roofs.Count} roof{(building.Roofs.Count == 1 ? "" : "s")}"
+ (waiting ? " · unbuilt" : "");
build.Enabled = waiting;
}
void Build() {
tool?.Build();
Retitle();
}
// A drag mid-gesture: the plan is already written, but generated geometry and the undo entry wait for the release.
// It repaints and retitles and NOTHING else - rebuilding the storey list between two mouse-moves
// destroys and recreates a widget per storey, which is what makes a drag crawl.
void Preview() {
Retitle();
canvas.Update();
}
void Commit() {
if ( tool?.Plan is not null ) {
if ( live ) {
tool.Commit( "Edit Building Layout" );
} else {
tool.Bank( "Edit Building Layout" );
}
}
Rebuild();
}
// The drawing writes straight through to the plan, so it has to be as undoable as the viewport is - every edit here
// already lands on the scene session's stack, and this is the key that reaches it without leaving the window.
protected override void OnKeyPress( KeyEvent e ) {
if ( !e.HasCtrl || SceneEditorSession.Active is not { } session ) {
base.OnKeyPress( e );
return;
}
if ( e.Key == KeyCode.Z && !e.HasShift ) {
session.UndoSystem.Undo();
} else if ( e.Key == KeyCode.Y || (e.Key == KeyCode.Z && e.HasShift) ) {
session.UndoSystem.Redo();
} else {
base.OnKeyPress( e );
return;
}
e.Accepted = true;
// The plan is a fresh document after a rewind, and the corner held may not be there any more.
canvas.Picked = -1;
canvas.PickedEdge = -1;
canvas.Forget();
Rebuild();
}
protected override void OnClosed() {
base.OnClosed();
if ( standing == this ) {
standing = null;
}
}
}