Editor/Tool/ArchBuildingCanvas.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
namespace Sunless.Architecture;
enum ArchLayoutGrip {
Corner,
Edge,
Bevel
}
static class ArchLayoutInk {
public static readonly Color Storey = new( 0.30f, 0.58f, 0.82f );
public static readonly Color Other = new( 0.58f, 0.62f, 0.70f );
public static readonly Color Walls = new( 0.92f, 0.55f, 0.18f );
public static readonly Color Holes = new( 0.96f, 0.92f, 0.74f );
public static readonly Color Roof = new( 0.86f, 0.66f, 0.26f );
public static readonly Color Corner = new( 0.42f, 0.72f, 0.92f );
public static readonly Color Edge = new( 0.46f, 0.86f, 0.46f );
public static readonly Color Bevel = new( 0.88f, 0.46f, 0.72f );
public static readonly Color Dimension = new( 0.80f, 0.88f, 0.96f );
}
public sealed class ArchBuildingCanvas : Widget {
const float Padding = 40f;
const float GripSize = 9f;
const float Elbow = 22f;
readonly ArchTool tool;
readonly Func<ArchBuilding> subject;
readonly Action changed;
readonly Action committed;
readonly List<(ArchLayoutGrip Kind, int Index, Vector2 At)> grips = new();
float scale = 1f;
Vector2 origin;
// Held for whole gesture — re-deriving mid-drag rebinds indices
List<Vector2> ring;
int held = -1;
ArchLayoutGrip heldKind;
bool dragging;
Vector2 hover;
float setback;
ArchBuilding holding;
public int Storey { get; set; }
public bool Others { get; set; } = true;
public bool Roofs { get; set; } = true;
public int Picked { get; set; } = -1;
public int PickedEdge { get; set; } = -1;
public Action Selected { get; set; }
public ArchBuildingCanvas( Widget parent, ArchTool tool, Func<ArchBuilding> subject, Action changed, Action committed ) : base( parent ) {
this.tool = tool;
this.subject = subject;
this.changed = changed;
this.committed = committed;
MinimumSize = new Vector2( 460, 360 );
MouseTracking = true;
FocusMode = FocusMode.Click;
}
public List<Vector2> Ring() {
if ( dragging && ring is { Count: >= 3 } ) {
return ring;
}
if ( subject() is not { } building ) {
return ring = null;
}
return ring = ArchFootprint.Outer( ArchFootprint.Union( Standing( building, Storey )
.Select( ArchFloorGen.Footprint )
.Where( loop => loop.Count >= 3 )
.ToList() ) )
.FirstOrDefault();
}
public void Forget() => ring = null;
static IEnumerable<ArchRoom> Standing( ArchBuilding building, int floor ) {
return building.Rooms.Where( room => room.Floor == floor );
}
Vector2 Screen( Vector2 flat ) => origin + new Vector2( flat.x, -flat.y ) * scale;
Vector2 Local( Vector2 screen ) {
var flat = (screen - origin) / MathF.Max( 0.0001f, scale );
return new Vector2( flat.x, -flat.y );
}
// Framed on whole building so switching storey doesn't re-zoom
void Frame( ArchBuilding building ) {
var points = building.Rooms
.SelectMany( ArchFloorGen.Footprint )
.Concat( building.Roofs.SelectMany( roof => roof.Outline() ) )
.ToList();
if ( points.Count == 0 ) {
scale = 1f;
origin = new Vector2( Width * 0.5f, Height * 0.5f );
return;
}
ArchFootprint.Bounds( points, out var min, out var max );
var span = max - min;
var page = new Vector2( MathF.Max( 1f, Width - Padding * 2f ), MathF.Max( 1f, Height - Padding * 2f ) );
scale = MathF.Min( page.x / MathF.Max( 1f, span.x ), page.y / MathF.Max( 1f, span.y ) );
origin = new Vector2(
Padding + (page.x - span.x * scale) * 0.5f - min.x * scale,
Padding + (page.y - span.y * scale) * 0.5f + max.y * scale );
}
protected override void OnPaint() {
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground.Darken( 0.25f ) );
Paint.DrawRect( LocalRect );
if ( subject() is not { } building || building.Rooms.Count == 0 ) {
Paint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );
Paint.DrawText( LocalRect, "no building", TextFlag.Center );
return;
}
grips.Clear();
Frame( building );
if ( Others ) {
PaintOtherStoreys( building );
}
if ( Roofs ) {
PaintRoofs( building );
}
PaintRooms( building );
PaintWalls( building );
PaintRing();
PaintFacetPreview();
PaintGrips();
PaintLegend( building );
}
void PaintOtherStoreys( ArchBuilding building ) {
foreach ( var room in building.Rooms.Where( room => room.Floor != Storey ) ) {
Outline( ArchFloorGen.Footprint( room ), ArchLayoutInk.Other.WithAlpha( 0.28f ), 1f );
}
}
void PaintRoofs( ArchBuilding building ) {
foreach ( var roof in building.Roofs ) {
Outline( roof.Outline(), ArchLayoutInk.Roof.WithAlpha( 0.45f ), 1f );
}
}
void PaintRooms( ArchBuilding building ) {
foreach ( var room in Standing( building, Storey ) ) {
var loop = ArchFloorGen.Footprint( room );
if ( loop.Count < 3 ) {
continue;
}
Paint.ClearPen();
Paint.SetBrush( ArchLayoutInk.Storey.WithAlpha( 0.10f ) );
Paint.DrawPolygon( loop.Select( Screen ).ToArray() );
}
}
void PaintWalls( ArchBuilding building ) {
var stock = MathF.Max( 1f, tool?.Kit?.WallThickness ?? 8f );
foreach ( var wall in Standing( building, Storey ).SelectMany( room => room.Walls ) ) {
var span = wall.End - wall.Start;
if ( span.IsNearZeroLength ) {
continue;
}
var half = wall.Normal * ((wall.Thickness > 0f ? wall.Thickness : stock) * 0.5f);
Paint.ClearPen();
Paint.SetBrush( ArchLayoutInk.Walls.WithAlpha( 0.55f ) );
Paint.DrawPolygon(
Screen( wall.Start - half ),
Screen( wall.End - half ),
Screen( wall.End + half ),
Screen( wall.Start + half ) );
foreach ( var opening in wall.Openings ) {
var from = wall.PointAt( Math.Clamp( opening.Left, 0f, wall.Length ) );
var to = wall.PointAt( Math.Clamp( opening.Right, 0f, wall.Length ) );
Paint.ClearPen();
Paint.SetBrush( ArchLayoutInk.Holes.WithAlpha( 0.9f ) );
Paint.DrawPolygon(
Screen( from - half ),
Screen( to - half ),
Screen( to + half ),
Screen( from + half ) );
}
}
}
void PaintRing() {
if ( Ring() is not { Count: >= 3 } loop ) {
Paint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );
Paint.DrawText( LocalRect, $"storey {Storey} makes no single outline — a detached wing has no one ring of corners", TextFlag.Center );
return;
}
Paint.SetDefaultFont( 7 );
for ( var index = 0; index < loop.Count; index++ ) {
var next = (index + 1) % loop.Count;
var from = loop[index];
var to = loop[next];
var picked = index == PickedEdge;
Paint.ClearBrush();
Paint.SetPen( picked ? Theme.Primary : ArchLayoutInk.Storey, picked ? 3f : 2f );
Paint.DrawLine( Screen( from ), Screen( to ) );
Dimension( from, to, index );
grips.Add( (ArchLayoutGrip.Edge, index, (from + to) * 0.5f) );
grips.Add( (ArchLayoutGrip.Corner, index, from) );
if ( Bisector( loop, index ) is { } inward ) {
grips.Add( (ArchLayoutGrip.Bevel, index, from + inward * Elbow) );
}
}
}
void Dimension( Vector2 from, Vector2 to, int index ) {
var middle = Screen( (from + to) * 0.5f );
var length = (to - from).Length;
Paint.SetPen( ArchLayoutInk.Dimension.WithAlpha( index == PickedEdge ? 0.95f : 0.55f ) );
Paint.DrawText( new Rect( middle.x - 40f, middle.y - 18f, 80f, 14f ), $"{length:0.#}", TextFlag.Center );
var corner = Screen( from );
Paint.SetPen( (index == Picked ? Theme.Primary : ArchLayoutInk.Corner).WithAlpha( 0.9f ) );
Paint.DrawText( new Rect( corner.x + 8f, corner.y + 6f, 130f, 13f ),
$"{index + 1} {from.x:0.#}, {from.y:0.#}", TextFlag.LeftTop );
}
static Vector2? Bisector( List<Vector2> loop, int index ) {
var at = loop[index];
var back = loop[(index - 1 + loop.Count) % loop.Count] - at;
var forward = loop[(index + 1) % loop.Count] - at;
if ( back.IsNearZeroLength || forward.IsNearZeroLength ) {
return null;
}
var inward = back.Normal + forward.Normal;
return inward.IsNearZeroLength ? null : inward.Normal;
}
void PaintFacetPreview() {
if ( !dragging || heldKind != ArchLayoutGrip.Bevel || Ring() is not { Count: >= 3 } loop || held < 0 || held >= loop.Count ) {
return;
}
if ( ArchChamfer.Facing( loop, loop[held], setback ) is not { Stands: true } facet ) {
return;
}
Paint.ClearBrush();
Paint.SetPen( ArchLayoutInk.Bevel, 3f );
Paint.DrawLine( Screen( facet.From ), Screen( facet.To ) );
var middle = Screen( (facet.From + facet.To) * 0.5f );
Paint.SetDefaultFont( 8 );
Paint.SetPen( ArchLayoutInk.Bevel );
Paint.DrawText( new Rect( middle.x + 10f, middle.y - 7f, 200f, 14f ),
$"{facet.Reach:0.#} back · {(facet.To - facet.From).Length:0.#} facet", TextFlag.LeftCenter );
}
void PaintGrips() {
foreach ( var grip in grips ) {
var at = Screen( grip.At );
var hot = grip.Kind switch {
ArchLayoutGrip.Edge => grip.Index == PickedEdge,
_ => grip.Index == Picked
};
var hue = grip.Kind switch {
ArchLayoutGrip.Edge => ArchLayoutInk.Edge,
ArchLayoutGrip.Bevel => ArchLayoutInk.Bevel,
_ => ArchLayoutInk.Corner
};
Paint.ClearPen();
Paint.SetBrush( hue.WithAlpha( hot ? 1f : 0.6f ) );
if ( grip.Kind == ArchLayoutGrip.Bevel ) {
Paint.DrawRect( Box( at ).Grow( -1.5f ), GripSize );
continue;
}
Paint.DrawRect( Box( at ), 2 );
}
}
static Rect Box( Vector2 at ) => new( at.x - GripSize * 0.5f, at.y - GripSize * 0.5f, GripSize, GripSize );
void Outline( IReadOnlyList<Vector2> loop, Color hue, float width ) {
if ( loop.Count < 3 ) {
return;
}
Paint.ClearBrush();
Paint.SetPen( hue, width );
for ( var index = 0; index < loop.Count; index++ ) {
Paint.DrawLine( Screen( loop[index] ), Screen( loop[(index + 1) % loop.Count] ) );
}
}
static readonly (Color Hue, string Name)[] Keys =
{
(ArchLayoutInk.Storey, "this storey"),
(ArchLayoutInk.Other, "other storeys"),
(ArchLayoutInk.Walls, "walls"),
(ArchLayoutInk.Roof, "roof"),
(ArchLayoutInk.Corner, "corner"),
(ArchLayoutInk.Edge, "run"),
(ArchLayoutInk.Bevel, "chamfer")
};
void PaintLegend( ArchBuilding building ) {
var top = 8f;
Paint.SetDefaultFont( 7 );
foreach ( var key in Keys ) {
Paint.ClearPen();
Paint.SetBrush( key.Hue.WithAlpha( 0.85f ) );
Paint.DrawRect( new Rect( Width - 92f, top + 3f, 8f, 8f ), 2 );
Paint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );
Paint.DrawText( new Rect( Width - 80f, top, 74f, 14f ), key.Name, TextFlag.LeftCenter );
top += 13f;
}
Paint.SetPen( Theme.TextControl.WithAlpha( 0.55f ) );
Paint.DrawText( new Rect( 8f, Height - 18f, Width - 16f, 14f ),
$"{building.Name} · storey {Storey} · {Ring()?.Count ?? 0} corners · {Standing( building, Storey ).Sum( room => room.Walls.Count )} walls",
TextFlag.LeftCenter );
}
protected override void OnMousePress( MouseEvent e ) {
base.OnMousePress( e );
held = -1;
dragging = false;
if ( subject() is not { } building ) {
return;
}
foreach ( var grip in grips ) {
if ( !Box( Screen( grip.At ) ).Grow( 3f ).IsInside( e.LocalPosition ) ) {
continue;
}
held = grip.Index;
heldKind = grip.Kind;
holding = building;
dragging = true;
setback = 0f;
if ( grip.Kind == ArchLayoutGrip.Edge ) {
PickedEdge = grip.Index;
} else {
Picked = grip.Index;
}
Selected?.Invoke();
Update();
return;
}
if ( Nearest( e.LocalPosition ) is var corner && corner >= 0 ) {
Picked = corner;
Selected?.Invoke();
Update();
}
}
int Nearest( Vector2 screen ) {
if ( Ring() is not { Count: >= 3 } loop ) {
return -1;
}
var found = -1;
var best = 40f;
for ( var index = 0; index < loop.Count; index++ ) {
var away = (Screen( loop[index] ) - screen).Length;
if ( away >= best ) {
continue;
}
best = away;
found = index;
}
return found;
}
protected override void OnMouseMove( MouseEvent e ) {
base.OnMouseMove( e );
hover = e.LocalPosition;
if ( !dragging || held < 0 || holding is null || Ring() is not { Count: >= 3 } loop || held >= loop.Count ) {
return;
}
var landed = Rested( Local( e.LocalPosition ) );
if ( heldKind == ArchLayoutGrip.Bevel ) {
setback = Reached( loop, held, landed );
Update();
return;
}
if ( heldKind == ArchLayoutGrip.Corner ) {
if ( !ArchCarry.Corner( holding, loop[held], landed ) ) {
return;
}
loop[held] = landed;
} else {
var next = (held + 1) % loop.Count;
var span = loop[next] - loop[held];
if ( span.IsNearZeroLength ) {
return;
}
var normal = new Vector2( -span.Normal.y, span.Normal.x );
var reach = normal * ArchGridService.Fine( Vector2.Dot( landed - (loop[held] + loop[next]) * 0.5f, normal ) );
if ( reach.IsNearZeroLength || !ArchCarry.Along( holding, loop[held], loop[next], reach ) ) {
return;
}
loop[held] += reach;
loop[next] += reach;
}
changed?.Invoke();
Update();
}
// Projected onto bisector then divided back by half-angle
static float Reached( List<Vector2> loop, int index, Vector2 landed ) {
if ( Bisector( loop, index ) is not { } inward ) {
return 0f;
}
var back = (loop[(index - 1 + loop.Count) % loop.Count] - loop[index]).Normal;
var travel = MathF.Max( 0f, Vector2.Dot( landed - loop[index], inward ) );
return ArchGridService.Fine( travel / MathF.Max( 0.2f, Vector2.Dot( inward, back ) ) );
}
// Snaps to walls first, then grid
Vector2 Rested( Vector2 local ) {
if ( tool is not null && tool.Walled( local, out var wall, Storey ) ) {
return wall;
}
return ArchGridService.Fine( local );
}
protected override void OnMouseReleased( MouseEvent e ) {
base.OnMouseReleased( e );
var bevelled = dragging && heldKind == ArchLayoutGrip.Bevel;
var cut = setback;
dragging = false;
held = -1;
holding = null;
setback = 0f;
if ( bevelled ) {
Chamfer( Picked, cut );
return;
}
committed?.Invoke();
}
public void Chamfer( int index, float back ) {
if ( tool?.Plan is null || subject() is not { } building || Ring() is not { Count: >= 3 } loop
|| index < 0 || index >= loop.Count ) {
return;
}
var report = ArchChamfer.Corner( tool.Plan, building, loop[index], back );
if ( !report.Chamfered ) {
Log.Info( "Architecture: nothing was cut — the drag left no facet to stand." );
return;
}
Stranded( report.Stranded );
Settle();
}
static void Stranded( int holes ) {
if ( holes > 0 ) {
Log.Info( $"Architecture: {holes} opening{(holes == 1 ? "" : "s")} stood in the stretch the edit took away and went with it." );
}
}
void Settle() {
ring = null;
Picked = -1;
PickedEdge = -1;
committed?.Invoke();
}
}