Editor UI for architectural parts. Builds sidebar sheets and controls for different ArchPart types (building, opening, fence, downpipe, trim, room) and utility widgets for numeric/boolean inputs.
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, changed );
break;
case ArchOpening opening:
Opening( panel, opening, 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, changed );
break;
}
}
static void Building( ToolSidebarWidget panel, ArchBuilding building, 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 );
} );
}
static void Opening( ToolSidebarWidget panel, ArchOpening opening, 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(); } );
} );
if ( !opening.IsGlazed )
{
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 ) );
} );
}
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, 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(); } );
} );
ArchSidebarSection.Show( panel, "Room.storey", "Storey", group =>
{
group.Add( Number( "Wall height", room.WallHeight, 0f, value => room.WallHeight = 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 ) );
}
} );
}
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.
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;
}
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;
}
}