Editor/Tool/Subtools/ArchBracketSubtool.cs

Editor subtool for placing bracket rows (hangars) in the architecture editor. It sketches a drag volume, previews hangers and counts, resolves a bracket part, and on commit places a bracket part into the plan and updates UI/options.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

// A row of hangers, dragged as a volume like everything else here. Nothing about it is typed twice: the box
// says where the row runs and how often, and every member is sized off the runs actually crossing it - so a
// pipe added to the bundle widens the cross-piece with no second edit, and moving the bundle moves the
// hangers with it.
[Title( "Brackets" ), Icon( "build" ), Group( "12" )]
public sealed class ArchBracketSubtool( ArchTool owner ) : ArchSubtool( owner )
{
	public override ArchSurface[] Surfaces => new[] { ArchSurface.Bracket, ArchSurface.Pipework, ArchSurface.Cabling };

	readonly ArchPipeBracketPart draft = new();

	PipeMount? aim;
	ArchCursorFace face = ArchCursorFace.Ceiling;
	float depth = ArchPipe.DefaultDepth;

	protected override bool Adjusts => true;

	protected override ArchKind? DraftKind => ArchKind.Bracket;

	protected override string Title() => "Brackets";

	protected override string Advice() =>
		"Drag a box across the runs to hold. It fills with hangers at the spacing below, each one sized to whatever crosses it — a box holding nothing yet builds to its own extents.";

	PipeMount Mount() => ArchPipe.Aimed( aim, face );

	void Aiming()
	{
		if ( Owner.Cursor( out var cursor ) )
		{
			face = cursor.Face;
		}
	}

	ArchPipeDrag Sketch( Vector2 from, Vector2 to )
	{
		var floor = Owner.LevelHeight;
		var min = Min( from, to );
		var max = Max( from, to );

		return ArchPipe.Sketch( Owner.Grid, Owner.Plan, from, to, Mount(), floor, Soffit( min, max, floor ), depth );
	}

	float Soffit( Vector2 min, Vector2 max, float floor )
	{
		var centre = (min + max) * 0.5f;

		if ( Owner.RoomOnLevel( centre ) is { } room && Owner.Plan.OwnerOf( room ) is { } building )
		{
			return ArchFloorGen.Overhead( Owner.Plan, building, room, Owner.Kit, centre );
		}

		return floor + Owner.Kit.WallHeight - ArchFloorGen.CeilingDepth( Owner.Kit );
	}

	protected override void DrawHover( Vector2 point )
	{
		Aiming();

		base.DrawHover( point );
	}

	protected override void DrawPreview()
	{
		Aiming();

		var drag = Sketch( DragStart, DragCurrent );
		var shape = ArchPipeBracket.Resolve( Owner.Plan, Previewed( drag ) );

		ArchGhost.Volume( drag.Min, drag.Max, drag.Bottom, drag.Top );

		foreach ( var station in shape.Stations )
		{
			var holds = shape.Holding( station );
			var low = holds.Min( hold => hold.Offset - hold.Radius ) - shape.Spec.Overhang;
			var high = holds.Max( hold => hold.Offset + hold.Radius ) + shape.Spec.Overhang;
			var lift = holds.Max( hold => hold.Lift + hold.Radius ) + shape.Spec.Clearance;

			ArchGhost.Path( new List<Vector3>
			{
				shape.Frame.At( station, low, 0f ),
				shape.Frame.At( station, low, lift ),
				shape.Frame.At( station, high, lift ),
				shape.Frame.At( station, high, 0f )
			}, false );
		}

		ArchGhost.Note( new Vector3( drag.Max.x, drag.Max.y, drag.Top ),
			$"{shape.Count} hangers · holds {shape.Held.Count} runs" );
	}

	// Standing nowhere in the plan yet, so the overlap pass reads every corridor as already standing - which
	// is what a bracket about to be placed at the top of the stack actually is.
	ArchPipeBracketPart Previewed( ArchPipeDrag drag )
	{
		return new ArchPipeBracketPart
		{
			Id = 0,
			Kind = draft.Kind,
			Mount = drag.Mount,
			Min = drag.Min,
			Max = drag.Max,
			BaseHeight = drag.Bottom,
			TopHeight = drag.Top,
			AlongX = drag.AlongX,
			Outward = drag.Outward,
			Spacing = draft.Spacing,
			MemberWidth = draft.MemberWidth,
			Clearance = draft.Clearance,
			Overhang = draft.Overhang,
			Detail = draft.Detail
		};
	}

	protected override void OnDrag( Vector2 from, Vector2 to )
	{
		var building = Owner.ActiveBuilding();
		var drag = Sketch( from, to );

		if ( ArchPipe.Hang( Owner.Plan, building, Owner.Level, drag, draft ) is not { } placed )
		{
			Log.Info( $"Architecture: that drag is shorter than {ArchPipe.MinSize} inches either way, so there is no row in it." );

			return;
		}

		FinishDraft( placed.Id );

		Owner.Picked = new ArchSelection { Item = placed, Building = building };

		Drew( placed );

		Owner.Commit( "Place Brackets" );
	}

	protected override void BuildOptions( ToolSidebarWidget panel )
	{
		var editing = Editing();

		ArchPipeUi.Mount( panel, aim, chosen => { aim = chosen; Refresh(); } );

		panel.AddGroup( "Row" ).Add( ArchPartUi.Number( "Depth off the surface", depth, ArchPipe.DefaultDepth,
			value => depth = MathF.Max( 2f, value ) ) );

		if ( editing is null )
		{
			ArchPipeUi.Brackets( panel, draft, Refresh, null );
		}

		BuildPicked( panel, editing );
	}

	void BuildPicked( ToolSidebarWidget panel, ArchSelection editing )
	{
		if ( editing is null )
		{
			return;
		}

		ArchSidebarSection.Show( panel, Scope( "selected" ), $"Selected — {editing.Describe()}", group =>
			group.Add( new Button( "Deselect", "close" ) { Clicked = () => { Owner.Select( null ); Refresh(); } } ) );

		ArchPartUi.Sheet( panel, Owner, editing, Refresh );
	}

	ArchSelection Editing()
	{
		return Owner.Picked?.Item is ArchPipeBracketPart ? Owner.Picked : null;
	}
}