Editor/Pipe/ArchPipeUi.cs

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.

File Access
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 );
	}
}