Editor/Tool/Subtools/ArchPartUi.cs
using System;
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
// One layout per kind, read by both creator and Select subtool.
public static class ArchPartUi {
public const float PanelWidth = 208f;
public static void Sheet( ToolSidebarWidget panel, ArchTool tool, ArchSelection picked, Action refresh ) {
Action changed = () => tool.Touch( $"Edit {picked.Describe()}" );
var sheet = ArchSheets.Load().For( picked.Item );
if ( sheet is not null ) {
sheet.Build( panel, tool, picked, refresh, changed );
return;
}
switch ( picked.Item ) {
case ArchBuilding building:
Building( panel, building, tool?.Plan, refresh, changed );
break;
case ArchOpening opening:
Opening( panel, opening, picked.Room, refresh, changed );
break;
case ArchFencePart fence:
Fence( panel, fence, refresh, changed );
break;
case ArchDownpipePart pipe:
Downpipe( panel, pipe, tool.Kit, changed );
break;
case ArchTrimPart trim:
Trim( panel, trim, tool.Kit, changed );
break;
case ArchRoom room:
Room( panel, room, tool, refresh, changed );
break;
}
}
static void Building( ToolSidebarWidget panel, ArchBuilding building, ArchPlan plan, Action refresh, Action changed ) {
ArchSidebarSection.Show( panel, "Building.shell", "Shell", group => {
var name = new LineEdit( building.Name );
name.TextEdited += text => {
building.Name = text;
changed?.Invoke();
};
group.Add( name );
} );
ArchSidebarSection.Show( panel, "Building.rotation", "Rotation", group => {
group.Add( Angle( "Angle", building.Facing, degrees => Turn( building, degrees, plan ), changed ) );
group.Add( Increment( refresh ) );
} );
}
static void Opening( ToolSidebarWidget panel, ArchOpening opening, ArchRoom room, Action refresh, Action changed ) {
ArchSidebarSection.Show( panel, "Opening.hole", "Hole", size => {
size.Add( Number( "Along the wall", opening.Offset, 0f, value => opening.Offset = value, changed ) );
size.Add( Number( "Width", opening.Width, 0f, value => opening.Width = value, changed ) );
size.Add( Number( "Start height", opening.SillHeight, 0f, value => SetOpeningStart( opening, value ), changed ) );
size.Add( Number( "End height", opening.Top, 0f, value => SetOpeningEnd( opening, value ), changed ) );
} );
// Only what this kind actually generates - controls the generator never reads would do nothing.
ArchSidebarSection.Show( panel, "Opening.unit", "Unit", group => {
using var grid = ArchIconGrid.In( group );
grid.Toggle( "Casing round the opening", "opt_cased", "filter_frames", opening.Cased,
value => { opening.Cased = value; changed?.Invoke(); } );
if ( opening.Kind.Sills() ) {
grid.Toggle( "Sill board under it", "opt_sill", "border_bottom", opening.HasSill,
value => { opening.HasSill = value; changed?.Invoke(); } );
}
if ( opening.Kind.Hangs() ) {
grid.Toggle( "Hang a leaf in it", "opt_leaf", "door_front", opening.Leaf,
value => { opening.Leaf = value; refresh?.Invoke(); changed?.Invoke(); } );
}
if ( opening.IsHung ) {
grid.Toggle( "Hinge on the other side", "opt_flip_hinge", "flip", opening.FlipHinge,
value => { opening.FlipHinge = value; changed?.Invoke(); } );
grid.Toggle( "Standing open when the level starts", "opt_start_open", "door_open", opening.StartOpen,
value => { opening.StartOpen = value; changed?.Invoke(); } );
}
if ( opening.Kind.Glazes() ) {
grid.Toggle( "Glazed — sashes and panes in the hole", "opt_glazed", "window", opening.Glazed,
value => { opening.Glazed = value; refresh?.Invoke(); changed?.Invoke(); } );
}
ArchOpeningUi.Furniture( grid, opening.Kind, opening.Furniture,
value => { opening.Furniture = value; changed?.Invoke(); } );
ArchOpeningUi.Hood( grid, opening.Kind, opening.Hood,
value => { opening.Hood = value; changed?.Invoke(); } );
} );
if ( !opening.IsGlazed ) {
BasementWell( panel, opening, room, changed );
return;
}
// The sash arrangement is authored on the preset and rarely re-cut on one standing unit, so it folds.
ArchSidebarSection.Disclosure( panel, "Opening.glazing", "Glazing", true, glazing => {
using ( var grid = ArchIconGrid.In( glazing ) ) {
grid.Toggle( "Fit mullions and sashes to the resized frame", "opt_window_auto_layout", "auto_awesome", opening.AutoLayout,
value => { opening.AutoLayout = value; changed?.Invoke(); } );
grid.Toggle( "Already broken", "opt_broken", "broken_image", opening.Glass == GlassState.Broken,
value => { opening.Glass = value ? GlassState.Broken : GlassState.Intact; changed?.Invoke(); } );
grid.Toggle( "Breakable — panes on their own object", "opt_breakable", "shield", opening.Breakable,
value => { opening.Breakable = value; changed?.Invoke(); } );
}
glazing.Add( Integer( "Lights", opening.Lights, 1,
value => { opening.Lights = value; opening.AutoLayout = false; }, changed ) );
glazing.Add( Integer( "Sashes", opening.Sashes, 2,
value => { opening.Sashes = value; opening.AutoLayout = false; }, changed ) );
glazing.Add( Integer( "Pane columns", opening.PanesAcross, 2, value => opening.PanesAcross = value, changed ) );
glazing.Add( Integer( "Pane rows", opening.PanesDown, 3, value => opening.PanesDown = value, changed ) );
} );
BasementWell( panel, opening, room, changed );
}
static void BasementWell( ToolSidebarWidget panel, ArchOpening opening, ArchRoom room, Action changed ) {
if ( room?.Floor >= 0 || opening.Kind != OpeningKind.Window ) {
return;
}
ArchSidebarSection.Show( panel, "Opening.basement", "Basement", group => {
group.Add( Check( "Foundation light well", opening.BasementWell, value => opening.BasementWell = value, changed ) );
if ( opening.BasementWell ) {
group.Add( Number( "Well depth", opening.BasementWellDepth, 36f, value => opening.BasementWellDepth = MathF.Max( 18f, value ), changed ) );
}
} );
}
static void SetOpeningStart( ArchOpening opening, float value ) {
var top = opening.Top;
opening.SillHeight = MathF.Min( value, top - ArchGridService.FinestSize );
opening.Height = MathF.Max( ArchGridService.FinestSize, top - opening.SillHeight );
}
static void SetOpeningEnd( ArchOpening opening, float value ) {
opening.Height = MathF.Max( ArchGridService.FinestSize, value - opening.SillHeight );
}
static void Fence( ToolSidebarWidget panel, ArchFencePart fence, Action refresh, Action changed ) {
ArchSidebarSection.Show( panel, "Fence.run", "Run", group => {
using var grid = ArchIconGrid.In( group );
grid.Toggle( "Closed loop", "opt_closed", "crop_square", fence.Closed,
value => { fence.Closed = value; changed?.Invoke(); } );
} );
ArchBarrierUi.Build( panel, fence.Barrier, refresh, changed );
}
public static void Downpipe( ToolSidebarWidget panel, ArchDownpipePart pipe, ArchKit kit, Action changed ) {
ArchSidebarSection.Show( panel, "Downpipe.section", "Section", section => {
using ( var kinds = ArchIconGrid.In( section ) ) {
kinds.Pick( "Rainwater — taps the gutter and swans over to the wall", "pipe_rainwater", "water_drop",
pipe.Run == PipeRun.Rainwater, () => { pipe.Run = PipeRun.Rainwater; changed?.Invoke(); } );
kinds.Pick( "Conduit — a bracketed service run straight down the elevation", "pipe_conduit", "power_input",
pipe.Run == PipeRun.Conduit, () => { pipe.Run = PipeRun.Conduit; changed?.Invoke(); } );
}
using ( var grid = ArchIconGrid.In( section ) ) {
foreach ( var entry in kit.Profiles.Where( entry => entry.Name.Contains( "pipe", StringComparison.OrdinalIgnoreCase ) ) ) {
var name = entry.Name;
grid.Pick( name, ArchIcons.ProfileSlug( name ), ArchIcons.ProfileGlyph, pipe.Profile == name,
() => { pipe.Profile = name; changed?.Invoke(); } );
}
}
using var fittings = ArchIconGrid.In( section );
fittings.Toggle( "Shoe at the bottom", "opt_shoe", "south", pipe.Shoe,
value => { pipe.Shoe = value; changed?.Invoke(); } );
} );
}
public static void Trim( ToolSidebarWidget panel, ArchTrimPart trim, ArchKit kit, Action changed ) {
ArchSidebarSection.Show( panel, "Trim.profile", "Profile", group => {
using ( var grid = ArchIconGrid.In( group ) ) {
foreach ( var entry in kit.Profiles ) {
var name = entry.Name;
grid.Pick( name, ArchIcons.ProfileSlug( name ), ArchIcons.ProfileGlyph, trim.Profile == name,
() => { trim.Profile = name; changed?.Invoke(); } );
}
}
if ( !trim.Follows ) {
return;
}
using var reach = ArchIconGrid.In( group );
reach.Pick( "Floor edge only", "trim_floor", "horizontal_rule", trim.Reach == ArchTrimReach.Floor,
() => { trim.Reach = ArchTrimReach.Floor; changed?.Invoke(); } );
reach.Pick( "Wall jambs only", "trim_walls", "vertical_align_center", trim.Reach == ArchTrimReach.Walls,
() => { trim.Reach = ArchTrimReach.Walls; changed?.Invoke(); } );
reach.Pick( "One continuous run over the floor edge and wall jambs", "trim_both", "polyline", trim.Reach == ArchTrimReach.Both,
() => { trim.Reach = ArchTrimReach.Both; changed?.Invoke(); } );
} );
ArchSidebarSection.Disclosure( panel, "Trim.role", "Material role", true, group => {
using var roles = ArchIconGrid.In( group );
foreach ( var value in new[] { ArchSurface.Trim, ArchSurface.Gutter, ArchSurface.Baseboard, ArchSurface.WallCap, ArchSurface.Pillar } ) {
var captured = value;
roles.Pick( captured.ToString(), $"surface_{captured}".ToLowerInvariant(), TrimRoleGlyph( captured ),
trim.Surface == captured, () => { trim.Surface = captured; changed?.Invoke(); } );
}
} );
}
// A room has no tool of its own - a shell stands one and a wall sections one - so this sheet is the only
// place its own numbers live, and everything it had stays reachable here.
static void Room( ToolSidebarWidget panel, ArchRoom room, ArchTool tool, Action refresh, Action changed ) {
ArchSidebarSection.Show( panel, "Room.include", "Include", group => {
using var grid = ArchIconGrid.In( group );
grid.Toggle( "Own floor slab", "opt_own_floor", "layers", room.HasFloor,
value => { room.HasFloor = value; changed?.Invoke(); } );
grid.Toggle( "Ceiling body under the plate", "opt_ceiling", "border_top", room.HasCeiling,
value => { room.HasCeiling = value; changed?.Invoke(); } );
grid.Toggle( "Boards over the slab", "opt_floor_boards", "view_stream", room.FloorBoards,
value => { room.FloorBoards = value; changed?.Invoke(); } );
grid.Toggle( "Stands on a raised foundation", "opt_raised", "foundation", room.RaisedFoundation,
value => { room.RaisedFoundation = value; changed?.Invoke(); } );
grid.Toggle( "Facade — no inside, so no far face on any of its walls", "opt_facade", "filter_1", room.Facade,
value => { room.Facade = value; changed?.Invoke(); } );
grid.Toggle( "Posts under whatever this storey oversails", "opt_overhang_posts", "view_column", room.OverhangPosts,
value => { room.OverhangPosts = value; refresh?.Invoke(); changed?.Invoke(); } );
} );
OverhangPosts( panel, room, tool, refresh, changed );
ArchSidebarSection.Show( panel, "Room.storey", "Storey", group => {
group.Add( Number( "Wall height", room.WallHeight, 0f, value => room.WallHeight = value, changed ) );
group.Add( Number( "Wall thickness", room.Walls.FirstOrDefault()?.Thickness ?? 0f, tool?.Kit.WallThickness ?? 8f, value => {
foreach ( var wall in room.Walls ) { wall.Thickness = MathF.Max( 1f, value ); }
}, changed ) );
group.Add( Number( "Board angle", room.FloorBoardYaw, 0f, value => room.FloorBoardYaw = value, changed ) );
if ( room.HasCeiling ) {
group.Add( Number( "Ceiling depth", room.CeilingDepth, 0f, value => room.CeilingDepth = value, changed ) );
}
} );
}
// A prop under an overhang IS a pillar, so it is dressed by the Pillars tool's own forms - the type shelf, the
// section, the footing. No span: nothing bridges between two props holding one storey up.
static void OverhangPosts( ToolSidebarWidget panel, ArchRoom room, ArchTool tool, Action refresh, Action changed ) {
if ( !room.OverhangPosts || tool is null ) {
return;
}
room.OverhangPost ??= new ArchPillarPart();
var offered = tool.PillarTypes;
var type = offered.FirstOrDefault( standing => string.Equals( standing.Name, room.OverhangPost.Type, StringComparison.OrdinalIgnoreCase ) );
ArchPillarUi.Kinds( panel, offered, room.OverhangPost.Type, adopted => {
room.OverhangPost = adopted.Standing( Vector2.Zero, 0f, 0f );
refresh?.Invoke();
changed?.Invoke();
} );
ArchPillarUi.Build( panel, room.OverhangPost, type, refresh, changed, spans: false );
}
static string TrimRoleGlyph( ArchSurface value ) => value switch {
ArchSurface.Gutter => "water_damage",
ArchSurface.Baseboard => "border_bottom",
ArchSurface.WallCap => "border_top",
ArchSurface.Pillar => "view_column",
_ => "timeline"
};
// Value lands per keystroke; `changed` only applies - it debounces.
// An absolute bearing, where the plan holds only turned coordinates: the box says where the shell faces and
// assigning one turns it by the DIFFERENCE, so naming an angle and nudging it are the same edit.
static void Turn( ArchBuilding building, float degrees, ArchPlan plan ) {
if ( ArchHandles.Pivot( building, out var about ) ) {
ArchCarry.Turn( building, about, degrees - building.Facing, plan );
}
}
// Dragged in whole steps of the angle ladder, and slowly - a bearing is aimed, not flicked. Free swing
// (a ladder of zero) still drags in whole degrees, because a box with no step at all cannot be pulled.
public static Widget Angle( string label, float value, Action<float> assign, Action changed = null ) {
var step = ArchGridService.AngleStep;
// An unreadable box is left ALONE rather than read as zero: this is an absolute bearing, so clearing it
// to retype would swing the whole house back to north on the way.
return Pull( label, value != 0f ? value.ToString( "0.##" ) : "", "0",
step > 0f ? step : 1f, 0.5f, "0.##",
text => {
if ( !float.TryParse( text, out var parsed ) ) {
return;
}
assign( parsed );
changed?.Invoke();
} );
}
// The ladder itself, beside what it steps: it is the editor's, so every turn in the tool ratchets onto it.
// The sheet is rebuilt behind it, or the angle box beside it keeps dragging on the step it was built with.
public static Widget Increment( Action refresh ) {
return Pull( "Increment", ArchGridService.AngleStep.ToString( "0.##" ), "0",
1f, 0.25f, "0.##",
text => {
ArchGridService.AngleStep = float.TryParse( text, out var parsed ) ? parsed : 0f;
refresh?.Invoke();
} );
}
public static Widget Number( string label, float value, float placeholder, Action<float> assign, Action changed = null ) {
return Pull( label, value != 0f ? value.ToString( "0.##" ) : "", placeholder != 0f ? placeholder.ToString( "0.##" ) : "0",
ArchGridService.FinestSize, 1f, "0.##",
text => { assign( float.TryParse( text, out var parsed ) ? parsed : 0f ); changed?.Invoke(); } );
}
// Whole numbers only - a count that jumps several at a flick can't be aimed.
public static Widget Integer( string label, int value, int placeholder, Action<int> assign, Action changed = null ) {
return Pull( label, value != 0 ? value.ToString() : "", placeholder != 0 ? placeholder.ToString() : "0",
1f, 0.25f, "0",
text => { assign( int.TryParse( text, out var parsed ) ? parsed : 0 ); changed?.Invoke(); } );
}
public static Widget OptionalNumber( string label, float? value, float placeholder, Action<float?> assign, Action changed = null ) {
return Pull( label, value?.ToString( "0.##" ) ?? "", placeholder.ToString( "0.##" ),
ArchGridService.FinestSize, 1f, "0.##",
text => { assign( float.TryParse( text, out var parsed ) ? parsed : null ); changed?.Invoke(); } );
}
static Widget Pull( string label, string text, string placeholder, float snap, float perPixel, string format, Action<string> edited ) {
var holder = new Widget( null );
holder.Layout = Layout.Row();
holder.Layout.Spacing = 4;
var name = new Label( label );
name.MinimumWidth = 92;
holder.Layout.Add( name );
var edit = new ArchNumberEdit( text, snap, perPixel, format, value => edited( value.ToString( format ) ) ) {
PlaceholderText = placeholder
};
edit.TextEdited += edited;
holder.Layout.Add( edit );
return holder;
}
// A named thing rather than a measured one - an asset a part points at. Committed on edit, so nothing is lost
// by clicking away from it.
public static Widget Text( string label, string value, string placeholder, Action<string> assign ) {
var holder = new Widget( null );
holder.Layout = Layout.Row();
holder.Layout.Spacing = 4;
var name = new Label( label );
name.MinimumWidth = 92;
holder.Layout.Add( name );
var edit = new LineEdit( value ?? "" ) { PlaceholderText = placeholder };
edit.TextEdited += assign;
holder.Layout.Add( edit );
return holder;
}
public static Widget Check( string label, bool value, Action<bool> assign, Action changed = null ) {
var box = new Checkbox( label ) { Value = value };
box.Toggled += () => {
assign( box.Value );
changed?.Invoke();
};
return box;
}
// Qt won't size wrappable labels; estimate and pin the line count.
public static Widget Wrapped( string text ) {
var label = new Label( text );
label.WordWrap = true;
label.FixedWidth = PanelWidth;
label.MinimumHeight = MathF.Ceiling( text.Length / 32f ) * 16f;
return label;
}
// The answer a workflow row prints for a step that is a row of independent toggles: what is on, in order.
public static string Listed( params (bool On, string Name)[] parts ) {
var standing = parts.Where( part => part.On ).Select( part => part.Name ).ToList();
return standing.Count == 0 ? "None" : string.Join( ", ", standing );
}
}