Editor UI for pipe and bracket parts. Provides sidebar controls to edit pipe content, mount, section sizes, materials, crossings, supports, kinds, geometry detail and run/bracket sheets, wiring UI actions to model fields and asset pickers.
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
// Read by the Pipes & Cables subtool, by the Brackets subtool and by Select, off the same records - a draft
// and a placed run carry identical controls because they ARE the same records, and a knob that exists in one
// place cannot drift from the other two.
public static class ArchPipeUi
{
// A tick or a picker that GATES the fields under it has to rebuild the sheet as well as commit, or the
// fields it reveals never arrive and the ones it hides stay put.
static Action Both( Action changed, Action refresh )
{
return () =>
{
changed?.Invoke();
refresh?.Invoke();
};
}
public static void Content( ToolSidebarWidget panel, ArchPipePart part, Action refresh, Action changed )
{
var both = Both( changed, refresh );
using var grid = ArchIconGrid.In( panel.AddGroup( "Content" ) );
grid.Pick( "Pipe — round stock, socketed and collared", "pipe_content_pipe", "water_damage",
part.Content == PipeContent.Pipe, () => { part.Content = PipeContent.Pipe; both(); } );
grid.Pick( "Conduit — small round electrical runs", "pipe_content_conduit", "power_input",
part.Content == PipeContent.Conduit, () => { part.Content = PipeContent.Conduit; both(); } );
grid.Pick( "Cable — bundles that loop lazily where they turn", "pipe_content_cable", "cable",
part.Content == PipeContent.Cable, () => { part.Content = PipeContent.Cable; both(); } );
grid.Pick( "Tray — an open channel with the cables lying in it", "pipe_content_tray", "view_stream",
part.Content == PipeContent.Tray, () => { part.Content = PipeContent.Tray; both(); } );
grid.Pick( "Duct — square section, fabricated in lengths", "pipe_content_duct", "crop_square",
part.Content == PipeContent.Duct, () => { part.Content = PipeContent.Duct; both(); } );
}
// Which surface of the volume the lanes seat against. Left on "where you drag", the face the cursor came
// to rest on decides - looking down works the floor, looking up at a ceiling hangs off it.
public static void Mount( ToolSidebarWidget panel, PipeMount? picked, Action<PipeMount?> chosen )
{
using var grid = ArchIconGrid.In( panel.AddGroup( "Mounted on" ) );
grid.Pick( "Where you drag — the floor looking down, the ceiling looking up at it", "pipe_mount_dragged", "ads_click",
picked is null, () => chosen( null ) );
grid.Pick( "Ceiling — hung under the soffit", "pipe_mount_ceiling", "border_top",
picked == PipeMount.Ceiling, () => chosen( PipeMount.Ceiling ) );
grid.Pick( "Floor — laid on the slab", "pipe_mount_floor", "border_bottom",
picked == PipeMount.Floor, () => chosen( PipeMount.Floor ) );
grid.Pick( "Wall — stacked up the nearest elevation", "pipe_mount_wall", "wallpaper",
picked == PipeMount.Wall, () => chosen( PipeMount.Wall ) );
}
// How the box fills. Fill holds the spacing and takes what fits, so widening the corridor adds a pipe;
// Count holds the number and spreads it, so widening the corridor spaces them out. Neither is a station
// list the author has to maintain.
public static void Section( ToolSidebarWidget panel, ArchPipePart part, Action changed )
{
var group = panel.AddGroup( "Section" );
using ( var grid = ArchIconGrid.In( group ) )
{
Diameter( grid, part, 0.5f, "0.5 in", changed );
Diameter( grid, part, 1f, "1 in", changed );
Diameter( grid, part, 2f, "2 in", changed );
Diameter( grid, part, 4f, "4 in", changed );
Diameter( grid, part, 6f, "6 in", changed );
Diameter( grid, part, 8f, "8 in", changed );
Diameter( grid, part, 12f, "12 in", changed );
}
group.Add( ArchPartUi.Number( "Diameter", part.Diameter, 4f, value => part.Diameter = MathF.Max( 0.5f, value ), changed ) );
}
static void Diameter( ArchIconGrid grid, ArchPipePart part, float diameter, string label, Action changed )
{
grid.Pick( $"{label} — outside diameter", $"pipe_diameter_{diameter * 10f:0}", "radio_button_unchecked",
MathF.Abs( part.Diameter - diameter ) < 0.01f, () => { part.Diameter = diameter; changed?.Invoke(); } );
}
public static void Materials( ToolSidebarWidget panel, ArchPipePart part, Action refresh, Action changed )
{
var group = panel.AddGroup( "Material indexes" );
var both = Both( changed, refresh );
for ( var index = 0; index < part.Materials.Count; index++ )
{
group.Add( Material( panel, part, index, both ) );
}
group.Add( new Button( "Add material slot", "add" )
{
Clicked = () =>
{
part.Materials.Add( "" );
both();
}
} );
group.Add( ArchPartUi.Integer( "Run index", part.RunMaterialIndex, 0,
value => part.RunMaterialIndex = MaterialIndex( value, part.Materials.Count ), changed ) );
group.Add( ArchPartUi.Integer( "Fitting index", part.FittingMaterialIndex, 0,
value => part.FittingMaterialIndex = MaterialIndex( value, part.Materials.Count ), changed ) );
group.Add( ArchPartUi.Integer( "Support index", part.SupportMaterialIndex, 0,
value => part.SupportMaterialIndex = MaterialIndex( value, part.Materials.Count ), changed ) );
}
static Widget Material( Widget parent, ArchPipePart part, int index, Action changed )
{
var holder = new Widget( parent );
holder.Layout = Layout.Row();
holder.Layout.Spacing = 4;
var label = new Label( $"[{index}]" );
label.MinimumWidth = 28;
holder.Layout.Add( label );
var path = part.Materials[index];
var value = new Label( string.IsNullOrWhiteSpace( path ) ? "(pick material)" : System.IO.Path.GetFileNameWithoutExtension( path ) );
value.MinimumWidth = 110;
holder.Layout.Add( value );
holder.Layout.Add( new Button( "", "image" )
{
Clicked = () =>
{
var picker = AssetPicker.Create( parent, AssetType.Material );
picker.Window.Title = $"Pipe material [{index}]";
picker.OnAssetPicked = assets =>
{
var asset = assets.FirstOrDefault();
if ( asset is null )
{
return;
}
part.Materials[index] = asset.Path;
ArchStyle.InvalidateCache();
changed?.Invoke();
};
picker.Show();
}
} );
holder.Layout.Add( new Button( "", "close" )
{
Clicked = () =>
{
part.Materials.RemoveAt( index );
part.RunMaterialIndex = ShiftedIndex( part.RunMaterialIndex, index, part.Materials.Count );
part.FittingMaterialIndex = ShiftedIndex( part.FittingMaterialIndex, index, part.Materials.Count );
part.SupportMaterialIndex = ShiftedIndex( part.SupportMaterialIndex, index, part.Materials.Count );
ArchStyle.InvalidateCache();
changed?.Invoke();
}
} );
holder.Layout.AddStretchCell();
return holder;
}
static int ShiftedIndex( int value, int removed, int count )
{
if ( count == 0 )
{
return 0;
}
if ( value > removed )
{
return value - 1;
}
return Math.Min( value, count - 1 );
}
static int MaterialIndex( int value, int count )
{
return Math.Clamp( value, 0, Math.Max( 0, count - 1 ) );
}
// What happens where another corridor is in the way. WHICH of two crossing runs steps is not here and
// never will be - it is the stack's own order, so the answer is to drag one row past the other.
public static void Crossings( ToolSidebarWidget panel, ArchPipePart part, Action refresh, Action changed )
{
var group = panel.AddGroup( "Crossings" );
using ( var grid = ArchIconGrid.In( group ) )
{
grid.Pick( "Over — steps out of the surface to clear it, then comes back", "pipe_dodge_away", "trending_up",
part.Dodge == PipeDodge.Away, () => { part.Dodge = PipeDodge.Away; Both( changed, refresh )(); } );
grid.Pick( "Under — dives back toward the surface instead", "pipe_dodge_toward", "trending_down",
part.Dodge == PipeDodge.Toward, () => { part.Dodge = PipeDodge.Toward; Both( changed, refresh )(); } );
grid.Pick( "Around — steps sideways out of the corridor", "pipe_dodge_side", "swap_horiz",
part.Dodge == PipeDodge.Side, () => { part.Dodge = PipeDodge.Side; Both( changed, refresh )(); } );
grid.Pick( "Straight through — it ignores what it crosses", "pipe_dodge_none", "close",
part.Dodge == PipeDodge.None, () => { part.Dodge = PipeDodge.None; Both( changed, refresh )(); } );
}
group.Add( ArchPartUi.Wrapped( "The LATER run steps over the earlier one. Drag a row past another in Plan Layers to swap which gives way." ) );
if ( part.Dodge == PipeDodge.None )
{
return;
}
group.Add( ArchPartUi.Number( "Clearance", part.Clearance, 2f, value => part.Clearance = MathF.Max( 0f, value ), changed ) );
}
public static void Supports( ToolSidebarWidget panel, ArchPipePart part, Action refresh, Action changed )
{
var group = panel.AddGroup( "Supports" );
group.Add( ArchPartUi.Check( "Hang its own supports", part.Supports, value => part.Supports = value, Both( changed, refresh ) ) );
if ( !part.Supports )
{
return;
}
Kinds( group, part.SupportKind, kind => { part.SupportKind = kind; changed?.Invoke(); } );
group.Add( ArchPartUi.Number( "Spacing", part.SupportSpacing, 96f, value => part.SupportSpacing = MathF.Max( 12f, value ), changed ) );
}
public static void Kinds( Layout group, BracketKind kind, Action<BracketKind> chosen )
{
using var grid = ArchIconGrid.In( group );
grid.Pick( "Trapeze — two drops and a cross-piece under the bundle", "bracket_trapeze", "table_rows",
kind == BracketKind.Trapeze, () => chosen( BracketKind.Trapeze ) );
grid.Pick( "Rod — one centre drop with a short cross-piece", "bracket_rod", "vertical_align_bottom",
kind == BracketKind.Rod, () => chosen( BracketKind.Rod ) );
grid.Pick( "Clamp — a collar round each run and a tab back to the surface", "bracket_clamp", "radio_button_unchecked",
kind == BracketKind.Clamp, () => chosen( BracketKind.Clamp ) );
grid.Pick( "Strap — a flat bar out of the face to each run", "bracket_strap", "horizontal_rule",
kind == BracketKind.Strap, () => chosen( BracketKind.Strap ) );
}
// THE GEOMETRY BUDGET. Every knob in here buys triangles and nothing else - a run detailed all the way
// down still crosses, clears and hangs exactly where the detailed one did, which is what makes it safe to
// turn a whole backdrop block down in one pass.
public static void Detail( ToolSidebarWidget panel, string scope, ArchPipeDetail detail, Action refresh, Action changed )
{
ArchSidebarSection.Disclosure( panel, scope, "Geometry detail", true, group =>
{
group.Add( ArchPartUi.Integer( "Section sides", detail.Sides, 8,
value => detail.Sides = Math.Max( ArchPipeSection.LeastSides, value ), changed ) );
group.Add( ArchPartUi.Integer( "Bend segments", detail.BendSegments, 3,
value => detail.BendSegments = Math.Max( 0, value ), changed ) );
group.Add( ArchPartUi.Wrapped( "Zero mitres every elbow into one edge. The bend RADIUS comes off the section, so a wide step still comes out as two proper elbows joined by a straight." ) );
group.Add( ArchPartUi.Check( "Collars down the run", detail.Couplings, value => detail.Couplings = value, Both( changed, refresh ) ) );
if ( detail.Couplings )
{
group.Add( ArchPartUi.Number( "Collar spacing", detail.CouplingSpacing, 96f,
value => detail.CouplingSpacing = MathF.Max( 8f, value ), changed ) );
}
group.Add( ArchPartUi.Check( "A joint at every turn", detail.BendCollars, value => detail.BendCollars = value, changed ) );
} );
}
// The whole run sheet, which is what Select shows and what the tool seeds a new drag from.
public static void Run( ToolSidebarWidget panel, ArchPipePart part, Action refresh, Action changed )
{
Content( panel, part, refresh, changed );
Section( panel, part, changed );
Materials( panel, part, refresh, changed );
Crossings( panel, part, refresh, changed );
Supports( panel, part, refresh, changed );
Detail( panel, $"Pipe.detail.{part.Id}", part.Detail, refresh, changed );
}
public static void Brackets( ToolSidebarWidget panel, ArchPipeBracketPart part, Action refresh, Action changed )
{
var group = panel.AddGroup( "Hangers" );
Kinds( group, part.Kind, kind => { part.Kind = kind; changed?.Invoke(); refresh?.Invoke(); } );
group.Add( ArchPartUi.Number( "Spacing", part.Spacing, 96f, value => part.Spacing = MathF.Max( 12f, value ), changed ) );
group.Add( ArchPartUi.Number( "Member width", part.MemberWidth, 2f, value => part.MemberWidth = MathF.Max( 0.5f, value ), changed ) );
group.Add( ArchPartUi.Number( "Clearance", part.Clearance, 0.5f, value => part.Clearance = MathF.Max( 0f, value ), changed ) );
group.Add( ArchPartUi.Number( "Overhang", part.Overhang, 3f, value => part.Overhang = MathF.Max( 0f, value ), changed ) );
group.Add( ArchPartUi.Wrapped( "Sized by the runs standing in the box, not by these numbers — the cross-piece spans what actually crosses it. Drag the row below a run in Plan Layers to pick that run up." ) );
Detail( panel, $"Bracket.detail.{part.Id}", part.Detail, refresh, changed );
}
}