Editor UI builder for road, bridge and tunnel parts. It constructs sidebar sections and controls to edit ArchRoadPart, ArchBridgePart, ArchTunnelPart and crossings, reading types from catalogs and wiring change/refresh callbacks.
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
public static class ArchRoadUi
{
public static void Build( ToolSidebarWidget panel, ArchRoadPart road, ArchKit kit, Action refresh, Action changed )
{
Carriageway( panel, road, changed );
Pavement( panel, road, changed );
Markings( panel, road, kit, changed );
Barriers( panel, road, refresh, changed );
Ground( panel, road, changed );
}
// The section a drag lays down. Camber and precision describe how it is built rather than how wide it is,
// so they fold with the rest of the construction.
static void Carriageway( ToolSidebarWidget panel, ArchRoadPart road, Action changed )
{
ArchSidebarSection.Show( panel, "Road.placement", "Carriageway", group =>
{
group.Add( ArchPartUi.Number( "Width", road.Width, 288f, value => road.Width = value, changed ) );
group.Add( ArchPartUi.Integer( "Lanes", road.Lanes, 2, value => road.Lanes = value, changed ) );
} );
ArchSidebarSection.Disclosure( panel, "Road.build", "Construction", true, group =>
{
group.Add( ArchPartUi.Number( "Camber", road.Camber, 1.5f, value => road.Camber = value, changed ) );
group.Add( ArchPartUi.Number( "Precision", road.Precision, 48f, value => road.Precision = value, changed ) );
} );
}
static void Pavement( ToolSidebarWidget panel, ArchRoadPart road, Action changed )
{
ArchSidebarSection.Show( panel, "Road.pavement", "Pavement", group =>
{
using ( var grid = ArchIconGrid.In( group ) )
{
foreach ( var side in Enum.GetValues<RoadSide>() )
{
var captured = side;
grid.Pick( $"Pavement: {captured}", Slug( captured ), SideGlyph( captured ), road.Pavements == captured,
() => { road.Pavements = captured; changed?.Invoke(); } );
}
}
using ( var grid = ArchIconGrid.In( group ) )
{
foreach ( var style in Enum.GetValues<KerbStyle>() )
{
var captured = style;
grid.Pick( $"Kerb: {captured}", $"kerb_{captured}".ToLowerInvariant(), KerbGlyph( captured ),
road.Kerb == captured, () => { road.Kerb = captured; changed?.Invoke(); } );
}
}
group.Add( ArchPartUi.Number( "Width", road.PavementWidth, 96f, value => road.PavementWidth = value, changed ) );
} );
ArchSidebarSection.Disclosure( panel, "Road.kerb", "Kerb and paving", true, group =>
{
group.Add( ArchPartUi.Number( "Kerb height", road.KerbHeight, 6f, value => road.KerbHeight = value, changed ) );
group.Add( ArchPartUi.Number( "Kerb stone", road.KerbWidth, 5f, value => road.KerbWidth = value, changed ) );
group.Add( ArchPartUi.Integer( "Courses", road.PavementCourses, 2, value => road.PavementCourses = value, changed ) );
group.Add( ArchPartUi.Number( "Paver size", road.PaverSize, 0f, value => road.PaverSize = value, changed ) );
} );
}
static void Markings( ToolSidebarWidget panel, ArchRoadPart road, ArchKit kit, Action changed )
{
ArchSidebarSection.Disclosure( panel, "Road.markings", "Markings", true, group =>
{
group.Add( Line( "Centre", road.CentreLine, kit, value => road.CentreLine = value, changed ) );
group.Add( Line( "Lane", road.LaneLine, kit, value => road.LaneLine = value, changed ) );
group.Add( Line( "Edge", road.EdgeLine, kit, value => road.EdgeLine = value, changed ) );
} );
}
static void Barriers( ToolSidebarWidget panel, ArchRoadPart road, Action refresh, Action changed )
{
ArchSidebarSection.Show( panel, "Road.barrier", "Barrier side", group =>
{
using ( var grid = ArchIconGrid.In( group ) )
{
foreach ( var side in Enum.GetValues<RoadSide>() )
{
var captured = side;
grid.Pick( $"Barrier: {captured}", Slug( captured ), SideGlyph( captured ), road.Barriers == captured,
() => { road.Barriers = captured; refresh?.Invoke(); changed?.Invoke(); } );
}
}
if ( road.Barriers != RoadSide.None )
{
group.Add( ArchPartUi.Number( "Inset", road.BarrierInset, 10f, value => road.BarrierInset = value, changed ) );
}
} );
// No barrier has no inset to set and no bays to divide, so neither is there rather than greyed.
if ( road.Barriers != RoadSide.None )
{
ArchBarrierUi.Build( panel, road.Barrier, refresh, changed );
}
}
static void Ground( ToolSidebarWidget panel, ArchRoadPart road, Action changed )
{
ArchSidebarSection.Show( panel, "Road.ground", "Ground", group =>
{
group.Add( ArchPartUi.Check( "Follow terrain", road.Conform, value => road.Conform = value, changed ) );
group.Add( ArchPartUi.Check( "Stamp terrain", road.Stamp, value => road.Stamp = value, changed ) );
} );
}
// Shared by Road and Select, so the kind grid sits here, not in either.
public static void Kinds( Layout group, CrossingKind current, Action<CrossingKind> assign )
{
using var grid = ArchIconGrid.In( group );
foreach ( var kind in Enum.GetValues<CrossingKind>() )
{
var captured = kind;
grid.Pick( $"Crossing: {captured}", $"crossing_{captured}".ToLowerInvariant(), CrossingGlyph( captured ),
current == captured, () => assign( captured ) );
}
}
public static void Crossing( ToolSidebarWidget panel, ArchRoadCrossing crossing, Action changed )
{
ArchSidebarSection.Show( panel, "Crossing.placement", "Crossing", group =>
{
Kinds( group, crossing.Kind, value => { crossing.Kind = value; changed?.Invoke(); } );
using ( var grid = ArchIconGrid.In( group ) )
{
foreach ( var side in Enum.GetValues<RoadSide>() )
{
var captured = side;
grid.Pick( $"Side: {captured}", Slug( captured ), SideGlyph( captured ), crossing.Side == captured,
() => { crossing.Side = captured; changed?.Invoke(); } );
}
}
group.Add( ArchPartUi.Number( "Width", crossing.Width, 132f, value => crossing.Width = value, changed ) );
group.Add( ArchPartUi.Number( "Along the road", crossing.Distance, 0f, value => crossing.Distance = value, changed ) );
} );
ArchSidebarSection.Disclosure( panel, "Crossing.splay", "Splay and surface", true, group =>
{
group.Add( ArchPartUi.Number( "Splay", crossing.Splay, 36f, value => crossing.Splay = value, changed ) );
group.Add( ArchPartUi.Number( "Splay segments", crossing.SplaySegments, 0f,
value => crossing.SplaySegments = (int)MathF.Round( value ), changed ) );
using var apron = ArchIconGrid.In( group );
apron.Toggle( "Carry the surface across the pavement", "opt_apron", "texture", crossing.Apron,
value => { crossing.Apron = value; changed?.Invoke(); } );
} );
}
// Picking a type replaces the whole structure - a bridge IS its structure.
public static void Types( ToolSidebarWidget panel, ArchTool tool, string current, Action<ArchBridgeType> adopt )
{
var types = ArchRoadCatalogs.Bridges();
if ( types.Count == 0 )
{
panel.Layout.Add( ArchSidebarLayout.Advice( $"No types in Assets/{ArchRoadCatalogs.BridgeDirectoryPath}." ) );
return;
}
ArchSidebarSection.Show( panel, "Bridge.type", "Type", group =>
{
using ( var grid = ArchIconGrid.In( group ) )
{
foreach ( var type in types )
{
var captured = type;
grid.Pick( $"{captured.Title} — {captured.Description}", $"bridge_{captured.Name}", captured.Icon,
string.Equals( current, captured.Name, StringComparison.OrdinalIgnoreCase ), () => adopt( captured ) );
}
}
using var reload = ArchIconGrid.In( group );
reload.Toggle( "Reload bridge types from disk", "reload_bridges", "refresh", false, _ =>
{
if ( ArchRoadCatalogs.Bridge( current ) is { } again )
{
adopt( again );
}
} );
} );
panel.Layout.Add( ArchSidebarLayout.Advice( ArchRoadCatalogs.Bridge( current ) is { } chosen
? $"{chosen.Title} — {chosen.Description}"
: "Hand-tuned — no type." ) );
}
// Road entry REPLACED, not patched - one field list stops a setting silently going missing.
public static ArchBridgePart Retype( ArchTool tool, ArchBridgePart bridge, ArchBridgeType type )
{
if ( tool.Plan.RoadCarrying( bridge ) is not { } road )
{
return bridge;
}
var dressed = type.Spanning( bridge.From, bridge.To );
dressed.Id = bridge.Id;
dressed.Name = bridge.Name;
road.Bridges[road.Bridges.IndexOf( bridge )] = dressed;
return dressed;
}
// A blade IS the gap - no count or width; an empty type gate shows every group.
public static void Bridge( ToolSidebarWidget panel, ArchBridgePart bridge, ArchBridgeType type, Action refresh, Action changed )
{
// Piers and ends decide what the span looks like from the street; the deck's own section is what the
// .bridgetype.json already carries, so it folds.
if ( Wanted( type, ArchBridgeGroup.Spans ) )
{
ArchSidebarSection.Show( panel, "Bridge.spans", "Spans", spans =>
{
spans.Add( ArchPartUi.Number( "Piers", bridge.Bents, 0f, value => bridge.Bents = (int)MathF.Round( value ), changed ) );
spans.Add( ArchPartUi.Number( "Pier spacing", bridge.BentSpacing, 600f, value => bridge.BentSpacing = value, changed ) );
} );
}
if ( Wanted( type, ArchBridgeGroup.Pier ) )
{
ArchSidebarSection.Show( panel, "Bridge.pier", "Pier", group =>
{
using var grid = ArchIconGrid.In( group );
foreach ( var style in Enum.GetValues<PierStyle>() )
{
var captured = style;
grid.Pick( $"Pier: {captured}", $"pier_{captured}".ToLowerInvariant(), PierGlyph( captured ),
bridge.Pier == captured, () => { bridge.Pier = captured; refresh?.Invoke(); changed?.Invoke(); } );
}
} );
}
if ( Wanted( type, ArchBridgeGroup.Ends ) || Wanted( type, ArchBridgeGroup.Parapet ) )
{
ArchSidebarSection.Show( panel, "Bridge.ends", "Ends", group =>
{
using var grid = ArchIconGrid.In( group );
if ( Wanted( type, ArchBridgeGroup.Ends ) )
{
grid.Toggle( "Abutments and wing walls where the deck meets the bank", "opt_abutment", "foundation", bridge.Abutments,
value => { bridge.Abutments = value; refresh?.Invoke(); changed?.Invoke(); } );
}
if ( Wanted( type, ArchBridgeGroup.Parapet ) )
{
grid.Toggle( "Parapet along both deck edges", "opt_parapet", "fence", bridge.Parapet,
value => { bridge.Parapet = value; refresh?.Invoke(); changed?.Invoke(); } );
}
} );
}
if ( Wanted( type, ArchBridgeGroup.Deck ) )
{
ArchSidebarSection.Disclosure( panel, "Bridge.deck", "Deck", true, deck =>
{
deck.Add( ArchPartUi.Number( "Slab", bridge.Slab, 14f, value => bridge.Slab = value, changed ) );
deck.Add( ArchPartUi.Number( "Girder", bridge.Girder, 40f, value => bridge.Girder = value, changed ) );
deck.Add( ArchPartUi.Number( "Edge setback", bridge.EdgeBeam, 8f, value => bridge.EdgeBeam = value, changed ) );
deck.Add( ArchPartUi.Number( "Soffit ribs", bridge.Ribs, 3f, value => bridge.Ribs = (int)MathF.Round( value ), changed ) );
deck.Add( ArchPartUi.Number( "Upstand", bridge.Upstand, 12f, value => bridge.Upstand = value, changed ) );
deck.Add( ArchPartUi.Number( "Upstand width", bridge.UpstandWidth, 16f, value => bridge.UpstandWidth = value, changed ) );
} );
}
if ( Wanted( type, ArchBridgeGroup.Pier ) )
{
ArchSidebarSection.Disclosure( panel, "Bridge.piersize", "Pier size", true, pier =>
{
if ( bridge.Pier != PierStyle.Blade )
{
pier.Add( ArchPartUi.Number( "Columns", bridge.Columns, 2f, value => bridge.Columns = (int)MathF.Round( value ), changed ) );
pier.Add( ArchPartUi.Number( "Width", bridge.PierWidth, 60f, value => bridge.PierWidth = value, changed ) );
}
pier.Add( ArchPartUi.Number( "Depth", bridge.PierDepth, 30f, value => bridge.PierDepth = value, changed ) );
pier.Add( ArchPartUi.Number( "Batter", bridge.Batter, 4f, value => bridge.Batter = value, changed ) );
pier.Add( ArchPartUi.Number( "Headstock", bridge.Headstock, 20f, value => bridge.Headstock = value, changed ) );
} );
}
if ( bridge.Abutments && Wanted( type, ArchBridgeGroup.Ends ) )
{
ArchSidebarSection.Disclosure( panel, "Bridge.abutment", "Abutment", true, ends =>
{
ends.Add( ArchPartUi.Number( "Depth", bridge.AbutmentDepth, 72f, value => bridge.AbutmentDepth = value, changed ) );
ends.Add( ArchPartUi.Number( "Wing wall", bridge.WingWall, 120f, value => bridge.WingWall = value, changed ) );
ends.Add( ArchPartUi.Number( "Wing splay", bridge.WingSplay, 30f, value => bridge.WingSplay = value, changed ) );
} );
}
if ( bridge.Parapet && Wanted( type, ArchBridgeGroup.Parapet ) )
{
ArchBarrierUi.Build( panel, bridge.Barrier, refresh, changed );
}
}
static bool Wanted( ArchBridgeType type, ArchBridgeGroup group ) => type?.Wants( group ) ?? true;
// A tunnel IS its structure - a type replaces it whole, never merges fields.
public static void TunnelTypes( ToolSidebarWidget panel, ArchTool tool, string current, Action<ArchTunnelType> adopt )
{
var types = ArchRoadCatalogs.Tunnels();
if ( types.Count == 0 )
{
panel.Layout.Add( ArchSidebarLayout.Advice( $"No types in Assets/{ArchRoadCatalogs.TunnelDirectoryPath}." ) );
return;
}
ArchSidebarSection.Show( panel, "Tunnel.type", "Type", group =>
{
using ( var grid = ArchIconGrid.In( group ) )
{
foreach ( var type in types )
{
var captured = type;
grid.Pick( $"{captured.Title} — {captured.Description}", $"tunnel_{captured.Name}", captured.Icon,
string.Equals( current, captured.Name, StringComparison.OrdinalIgnoreCase ), () => adopt( captured ) );
}
}
using var reload = ArchIconGrid.In( group );
reload.Toggle( "Reload tunnel types from disk", "reload_tunnels", "refresh", false, _ =>
{
if ( ArchRoadCatalogs.Tunnel( current ) is { } again )
{
adopt( again );
}
} );
} );
panel.Layout.Add( ArchSidebarLayout.Advice( ArchRoadCatalogs.Tunnel( current ) is { } chosen
? $"{chosen.Title} — {chosen.Description}"
: "Hand-tuned — no type." ) );
}
// A bay belongs to the bore, not its kind - re-typing keeps the services carved off it.
public static ArchTunnelPart Retype( ArchTool tool, ArchTunnelPart tunnel, ArchTunnelType type )
{
if ( tool.Plan.RoadHolding( tunnel ) is not { } road )
{
return tunnel;
}
var driven = type.Driving( tunnel.From, tunnel.To );
driven.Id = tunnel.Id;
driven.Name = tunnel.Name;
road.Tunnels[road.Tunnels.IndexOf( tunnel )] = driven;
return driven;
}
// An empty type gate means every group - a hand-tuned bore keeps the whole panel.
public static void Tunnel( ToolSidebarWidget panel, ArchTunnelPart tunnel, ArchTunnelType type, Action refresh, Action changed )
{
// The bore's shape and its mouths read from the street. Its section is what the .tunneltype.json carries,
// and it clears the road's own reach anyway, so it folds.
if ( Wanted( type, ArchTunnelGroup.Bore ) )
{
ArchSidebarSection.Show( panel, "Tunnel.bore", "Bore", group =>
{
using var grid = ArchIconGrid.In( group );
foreach ( var bore in Enum.GetValues<TunnelBore>() )
{
var captured = bore;
grid.Pick( $"Bore: {captured}", $"bore_{captured}".ToLowerInvariant(), BoreGlyph( captured ),
tunnel.Bore == captured, () => { tunnel.Bore = captured; refresh?.Invoke(); changed?.Invoke(); } );
}
} );
}
if ( Wanted( type, ArchTunnelGroup.Walkway ) )
{
ArchSidebarSection.Show( panel, "Tunnel.walkway", "Walkway", group =>
{
using ( var grid = ArchIconGrid.In( group ) )
{
foreach ( var side in Enum.GetValues<RoadSide>() )
{
var captured = side;
grid.Pick( $"Walkway: {captured}", $"walk_{captured}".ToLowerInvariant(), SideGlyph( captured ),
tunnel.Walkways == captured, () => { tunnel.Walkways = captured; changed?.Invoke(); } );
}
}
if ( tunnel.Walkways == RoadSide.None )
{
return;
}
group.Add( ArchPartUi.Number( "Width", tunnel.WalkwayWidth, 48f, value => tunnel.WalkwayWidth = value, changed ) );
group.Add( ArchPartUi.Number( "Height", tunnel.WalkwayHeight, 12f, value => tunnel.WalkwayHeight = value, changed ) );
} );
}
if ( Wanted( type, ArchTunnelGroup.Portal ) )
{
ArchSidebarSection.Show( panel, "Tunnel.ends", "Ends", group =>
{
using var grid = ArchIconGrid.In( group );
grid.Toggle( "Portal headwalls and wing walls at both mouths", "opt_portal", "door_front", tunnel.Portals,
value => { tunnel.Portals = value; refresh?.Invoke(); changed?.Invoke(); } );
} );
}
if ( Wanted( type, ArchTunnelGroup.Ground ) )
{
ArchSidebarSection.Show( panel, "Tunnel.ground", "Ground", group =>
{
using var grid = ArchIconGrid.In( group );
grid.Toggle( "Bore: leave the ground standing over the span instead of carving a cut down to the road",
"opt_bored", "landslide", tunnel.Bored, value => { tunnel.Bored = value; refresh?.Invoke(); changed?.Invoke(); } );
} );
}
if ( Wanted( type, ArchTunnelGroup.Bore ) )
{
ArchSidebarSection.Disclosure( panel, "Tunnel.section", "Section", true, section =>
{
section.Add( ArchPartUi.Number( "Headroom", tunnel.Headroom, 216f, value => tunnel.Headroom = value, changed ) );
section.Add( ArchPartUi.Number( "Springing", tunnel.Springing, 132f, value => tunnel.Springing = value, changed ) );
section.Add( ArchPartUi.Number( "Clearance", tunnel.Clearance, 24f, value => tunnel.Clearance = value, changed ) );
section.Add( ArchPartUi.Number( "Lining", tunnel.Lining, 18f, value => tunnel.Lining = value, changed ) );
section.Add( ArchPartUi.Number( "Invert", tunnel.Invert, 14f, value => tunnel.Invert = value, changed ) );
section.Add( ArchPartUi.Number( "Crown facets", tunnel.Segments, 6f, value => tunnel.Segments = (int)MathF.Round( value ), changed ) );
} );
}
if ( tunnel.Portals && Wanted( type, ArchTunnelGroup.Portal ) )
{
ArchSidebarSection.Disclosure( panel, "Tunnel.portal", "Portal", true, portal =>
{
portal.Add( ArchPartUi.Number( "Depth", tunnel.PortalDepth, 36f, value => tunnel.PortalDepth = value, changed ) );
portal.Add( ArchPartUi.Number( "Collar", tunnel.Collar, 72f, value => tunnel.Collar = value, changed ) );
portal.Add( ArchPartUi.Number( "Rise", tunnel.PortalRise, 42f, value => tunnel.PortalRise = value, changed ) );
portal.Add( ArchPartUi.Number( "Reach", tunnel.PortalReach, 72f, value => tunnel.PortalReach = value, changed ) );
portal.Add( ArchPartUi.Number( "Hood", tunnel.Hood, 54f, value => tunnel.Hood = value, changed ) );
portal.Add( ArchPartUi.Number( "Hood depth", tunnel.HoodDepth, 18f, value => tunnel.HoodDepth = value, changed ) );
portal.Add( ArchPartUi.Number( "Coping", tunnel.Coping, 6f, value => tunnel.Coping = value, changed ) );
portal.Add( ArchPartUi.Number( "Wing wall", tunnel.WingWall, 168f, value => tunnel.WingWall = value, changed ) );
portal.Add( ArchPartUi.Number( "Wing splay", tunnel.WingSplay, 40f, value => tunnel.WingSplay = value, changed ) );
} );
}
if ( Wanted( type, ArchTunnelGroup.Ground ) && tunnel.Bored )
{
panel.Layout.Add( ArchSidebarLayout.Advice( "Bored leaves the hill standing when the terrain is stamped. The hole through it is yours to paint in the terrain editor." ) );
}
}
static bool Wanted( ArchTunnelType type, ArchTunnelGroup group ) => type?.Wants( group ) ?? true;
static Widget Line( string label, string value, ArchKit kit, Action<string> assign, Action changed )
{
var holder = new Widget( null );
holder.Layout = Layout.Row();
holder.Layout.Spacing = 4;
var name = new Label( label );
name.MinimumWidth = 60;
holder.Layout.Add( name );
var combo = new ComboBox();
combo.AddItem( "none", null, () => { assign( "" ); changed?.Invoke(); }, selected: string.IsNullOrWhiteSpace( value ) );
foreach ( var line in kit.RoadLines )
{
var captured = line.Name;
combo.AddItem( captured, null, () => { assign( captured ); changed?.Invoke(); }, selected: captured == value );
}
holder.Layout.Add( combo );
return holder;
}
static string Slug( RoadSide side ) => $"side_{side}".ToLowerInvariant();
static string SideGlyph( RoadSide side ) => side switch
{
RoadSide.None => "block",
RoadSide.Left => "align_horizontal_left",
RoadSide.Right => "align_horizontal_right",
_ => "align_horizontal_center"
};
static string KerbGlyph( KerbStyle style ) => style switch
{
KerbStyle.Square => "crop_square",
KerbStyle.Rolled => "rounded_corner",
_ => "signal_cellular_4_bar"
};
static string PierGlyph( PierStyle style ) => style switch
{
PierStyle.Columns => "circle",
PierStyle.Piers => "view_column",
_ => "vertical_shades"
};
static string BoreGlyph( TunnelBore bore ) => bore switch
{
TunnelBore.Box => "crop_square",
TunnelBore.Arch => "arch",
_ => "vpn_lock"
};
static string CrossingGlyph( CrossingKind kind ) => kind switch
{
CrossingKind.Driveway => "garage",
CrossingKind.Footpath => "directions_walk",
_ => "menu"
};
}