Editor/Tool/Subtools/ArchPipeSubtool.cs

Editor subtool for placing and editing pipes and downpipes in the architecture editor. It handles two gestures: routing a pipe run by clicking control points and placing a stacked downpipe by clicking an eave, previews routes, computes mounting/facing, and commits placed parts to the building.

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

namespace Sunless.Architecture;

[Title( "Pipes & Cables" ), Icon( "plumbing" ), Group( "12" )]
public sealed class ArchPipeSubtool( ArchTool owner ) : ArchSubtool( owner )
{
	public override ArchSurface[] Surfaces => new[]
	{
		ArchSurface.Pipework, ArchSurface.Cabling, ArchSurface.Bracket, ArchSurface.Gutter, ArchSurface.Trim
	};

	public override ArchViewAxis[] Works => new[] { ArchViewAxis.Free, ArchViewAxis.Top, ArchViewAxis.Front, ArchViewAxis.Side };

	enum PipeGesture
	{
		Route,
		Stack
	}

	readonly ArchDownpipePart stack = new();
	readonly ArchPipePart draft = new();
	readonly List<ArchCurveNode> nodes = new();

	PipeGesture gesture = PipeGesture.Route;

	protected override bool UsesDrag => false;

	protected override bool TakesFreeClick => gesture == PipeGesture.Route;

	protected override bool Adjusts => true;

	protected override ArchKind? DraftKind => gesture == PipeGesture.Route ? ArchKind.Pipe : ArchKind.Downpipe;

	protected override string Title() => "Pipes & Cables";

	protected override string Advice() => gesture switch
	{
		PipeGesture.Stack => "Click near an eave. The stack taps the nearest gutter and hugs the wall below; a conduit skips the tap and drops on its brackets.",
		_ => "Click each control point of the route. Finish the run to build it; clicking an existing pipe node joins the runs and resolves the fitting there."
	};

	protected override ArchRunState Run() => new( nodes.Count > 0, $"{nodes.Count} node{(nodes.Count == 1 ? "" : "s")}" );

	protected override void FinishRun()
	{
		if ( gesture == PipeGesture.Route )
		{
			FinishRoute();
		}
	}

	protected override void CancelRun() => nodes.Clear();

	ArchCurveNode Node( Vector3 point, Vector3 normal )
	{
		return new ArchCurveNode
		{
			Position = point,
			Normal = normal,
			Mode = ArchTangentMode.Linear
		};
	}

	protected override void DrawHover( Vector2 point )
	{
		if ( gesture == PipeGesture.Stack )
		{
			DrawStack( point );

			return;
		}
	}

	protected override void DrawFreeHover()
	{
		if ( !Surface( out var point, out var normal ) )
		{
			return;
		}

		var line = new List<ArchCurveNode>( nodes ) { Node( point, normal ) };
		var connected = ArchPipe.Connected( Owner.Plan, line );
		var preview = Previewed( connected );
		var shape = ArchPipeShape.Resolve( Owner.Plan, preview );

		ArchGhost.Path( shape.Path(), false );

		foreach ( var node in connected )
		{
			Gizmo.Draw.LineSphere( node.Position, MathF.Max( 3f, draft.Radius ) );
		}

		if ( shape.Stands )
		{
			ArchGhost.Note( shape.Path()[^1], $"{draft.Content.ToString().ToLowerInvariant()} · {shape.Length / 12f:0.#} ft{Stepping( shape )}" );
		}
	}

	protected override void OnFreeClick()
	{
		if ( !Surface( out var point, out var normal ) )
		{
			return;
		}

		Owner.EnsureTarget();
		nodes.Add( Node( point, normal ) );
	}

	bool Surface( out Vector3 point, out Vector3 normal )
	{
		var ray = Gizmo.CurrentRay;
		var face = Owner.FacesUnder( ray ).FirstOrDefault();

		if ( face is not null )
		{
			var facing = Vector3.Dot( face.Normal, ray.Forward );
			var along = Vector3.Dot( face.Centre - ray.Position, face.Normal ) / facing;

			point = ray.Position + ray.Forward * along;
			normal = facing < 0f ? face.Normal : -face.Normal;

			return SeatOnSurface( ref point, normal );
		}

		var trace = Owner.Scene.Trace.Ray( ray.Position, ray.Position + ray.Forward * 32768f ).Run();

		if ( !trace.Hit )
		{
			point = default;
			normal = default;

			return false;
		}

		point = trace.EndPosition;
		normal = trace.Normal;

		return SeatOnSurface( ref point, normal );
	}

	bool SeatOnSurface( ref Vector3 point, Vector3 normal )
	{
		var snapped = Owner.Grid.CurveControl( point );

		point = snapped - normal * Vector3.Dot( snapped - point, normal );

		return !normal.IsNearZeroLength;
	}

	void DrawStack( Vector2 point )
	{
		base.DrawHover( point );

		var sketch = ArchAsks.PlaceDownpipe( Owner.Plan, Owner.ActiveBuilding(), Owner.Kit, point, stack.Profile, stack.Shoe, stack.Run );

		if ( sketch is null )
		{
			return;
		}

		var outlet = new Vector3( sketch.Outlet.x, sketch.Outlet.y, sketch.TopHeight );
		var foot = new Vector3( sketch.Wall.x, sketch.Wall.y, sketch.BaseHeight );

		Gizmo.Draw.Line( outlet, new Vector3( sketch.Wall.x, sketch.Wall.y, sketch.TopHeight - 8f ) );
		Gizmo.Draw.Line( new Vector3( sketch.Wall.x, sketch.Wall.y, sketch.TopHeight - 8f ), foot );
		Gizmo.Draw.LineCircle( outlet, Vector3.Up, 4f );
		ArchGhost.Note( outlet + Vector3.Up * 8f, stack.Profile );
	}

	string Stepping( ArchPipeShape shape ) => shape.Blocks.Count == 0 ? "" : $" · avoids {shape.Blocks.Count}";

	ArchPipePart Previewed( List<ArchCurveNode> route )
	{
		var along = route.Count >= 2 ? route[^1].Position - route[0].Position : Vector3.Forward;

		return new ArchPipePart
		{
			Content = draft.Content,
			Mount = Mounted( route ),
			Nodes = route,
			Outward = Facing( route, along ),
			Diameter = draft.Diameter,
			Materials = draft.Materials,
			RunMaterialIndex = draft.RunMaterialIndex,
			FittingMaterialIndex = draft.FittingMaterialIndex,
			SupportMaterialIndex = draft.SupportMaterialIndex,
			Dodge = draft.Dodge,
			Clearance = draft.Clearance,
			Supports = false,
			Detail = draft.Detail
		};
	}

	static PipeMount Mounted( IReadOnlyList<ArchCurveNode> route )
	{
		var normal = route.Count == 0 ? Vector3.Up : route[0].Normal;

		if ( MathF.Abs( normal.z ) < 0.5f )
		{
			return PipeMount.Wall;
		}

		return normal.z < 0f ? PipeMount.Ceiling : PipeMount.Floor;
	}

	Vector2 Facing( IReadOnlyList<ArchCurveNode> route, Vector3 along )
	{
		var normal = route.Select( node => node.Normal ).Aggregate( Vector3.Zero, ( total, entry ) => total + entry ).Normal;

		if ( MathF.Abs( normal.z ) < 0.5f )
		{
			return new Vector2( normal.x, normal.y ).Normal;
		}

		var centre = route.Count == 0
			? Vector2.Zero
			: route.Select( node => new Vector2( node.Position.x, node.Position.y ) ).Aggregate( Vector2.Zero, ( total, point ) => total + point ) / route.Count;

		return ArchPipe.Facing( Owner.Plan, centre, MathF.Abs( along.x ) >= MathF.Abs( along.y ) );
	}

	protected override void OnClick( Vector2 point )
	{
		if ( gesture == PipeGesture.Route )
		{
			return;
		}

		var building = Owner.ActiveBuilding();
		var pipe = ArchAsks.PlaceDownpipe( Owner.Plan, building, Owner.Kit, point, stack.Profile, stack.Shoe, stack.Run );

		if ( pipe is null )
		{
			Log.Info( "Architecture: no eave near that click — a stack taps a gutter run, so there has to be a roof over it." );

			return;
		}

		building.Downpipes.Add( pipe );
		FinishDraft( pipe.Id );
		Owner.Commit( "Place Downpipe" );
	}

	void FinishRoute()
	{
		var building = Owner.ActiveBuilding();
		var placed = Previewed( nodes );

		if ( ArchPipe.PlaceRoute( Owner.Plan, building, Owner.Level, nodes, placed ) is not { } route )
		{
			nodes.Clear();

			return;
		}

		FinishDraft( route.Id );
		Owner.Picked = new ArchSelection { Item = route, Building = building };
		Drew( route );
		Owner.Commit( "Place Pipe Route" );
		nodes.Clear();
	}

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

		BuildGesture( panel );

		if ( gesture == PipeGesture.Stack )
		{
			ArchPartUi.Downpipe( panel, stack, Owner.Kit, Refresh );
		}
		else
		{
			if ( editing is null )
			{
				ArchAnswers.Load().Asks<IArchPipeForms>()?.Run( panel, draft, Refresh, null );
			}
		}

		BuildPicked( panel, editing );
	}

	void BuildGesture( ToolSidebarWidget panel )
	{
		ArchSidebarSection.Show( panel, Scope( "gesture" ), "Gesture", group =>
		{
			using var grid = ArchIconGrid.In( group );

			grid.Pick( "Route — click a centreline through shared nodes", "pipe_run", "timeline",
				gesture == PipeGesture.Route, () => { gesture = PipeGesture.Route; Restart(); } );

			grid.Pick( "Stack — click an eave for a rainwater downpipe", "pipe_stack", "water_drop",
				gesture == PipeGesture.Stack, () => { gesture = PipeGesture.Stack; Restart(); } );
		} );
	}

	void Restart()
	{
		nodes.Clear();
		CancelDraft();
		BeginDraft();
		Refresh();
	}

	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 ArchPipePart or ArchDownpipePart ? Owner.Picked : null;
	}
}