Editor/Tool/Subtools/ArchTunnelSubtool.cs

Editor subtool for creating and editing tunnel features in the architecture tool. Handles dragging to join two roads or tunnel along a single road, previews the tunnel shape, creates/commits tunnel parts in the plan, and exposes UI options for tunnel types and parameters.

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

namespace Sunless.Architecture;

[Title( "Tunnel" ), Icon( "vpn_lock" ), Group( "05" )]
public sealed class ArchTunnelSubtool( ArchTool owner ) : ArchSubtool( owner )
{
	protected override ArchKind? DraftKind => ArchKind.Tunnel;

	public override ArchSurface[] Surfaces => new[]
	{
		ArchSurface.Lining, ArchSurface.Portal, ArchSurface.Pavement, ArchSurface.Kerb, ArchSurface.Road
	};

	ArchTunnelPart draft = new();

	// Re-dresses the newest bore in the plan; authoring anything else moves the counter.
	int placed;
	int stamp;

	protected override string Title() => "Drive Tunnel";

	protected override string Advice() =>
		"Drag from one road to another to join them and bore through the ground between, or drag along a single road to tunnel a stretch of it. Both ends walk inward to where the hill actually starts, so the portals stand in the bank rather than in the open - and the terrain is bored along the span instead of carved down to the road.";

	ArchTunnelPart Placed => placed != 0 && Owner.Plan.NextId == stamp ? Owner.Plan.FindTunnel( placed ) : null;

	ArchTunnelPart Edited => Placed ?? draft;

	void Adopt( ArchTunnelType type )
	{
		if ( Placed is { } bore )
		{
			ArchRoadUi.Retype( Owner, bore, type );
			Owner.Touch( "Retype Tunnel" );
			Refresh();

			return;
		}

		draft = type.Driving( 0f, 0f );

		Refresh();
	}

	void Seeded()
	{
		if ( draft.Type.Length > 0 || ArchRoadCatalogs.Tunnels().FirstOrDefault() is not { } first )
		{
			return;
		}

		draft = first.Driving( 0f, 0f );
	}

	protected override void OnDrag( Vector2 from, Vector2 to )
	{
		Seeded();

		if ( !Reached( from, out var near, out _ ) )
		{
			return;
		}

		if ( Reached( to, out var far, out _ ) && far != near )
		{
			Join( near, far );

			return;
		}

		if ( Spanned( from, to, out var road, out var span ) )
		{
			Add( road, span );
		}
	}

	// Two dead ends become ONE run - near keeps name and section, far is absorbed.
	void Join( ArchRoadPart near, ArchRoadPart far )
	{
		var join = ArchJoin.Across( near, far, ArchTunnel.ShortestBore );

		if ( !join.IsUsable )
		{
			Log.Info( $"Architecture: {join.Refused}." );

			return;
		}

		join.Apply( Owner.Plan, near, far );
		Add( near, join.Span );
	}

	void Add( ArchRoadPart road, ArchRoadSpan span )
	{
		var tunnel = Edited.Copy( span.From, span.To );

		tunnel.Id = Owner.Plan.AllocateId();
		tunnel.Name = $"Tunnel{road.Tunnels.Count + 1}";

		road.Tunnels.Add( tunnel );
		FinishDraft( tunnel.Id );
		Owner.Commit( "Drive Tunnel" );

		placed = tunnel.Id;
		stamp = Owner.Plan.NextId;

		Refresh();
	}

	protected override void DrawHover( Vector2 point )
	{
		base.DrawHover( point );

		if ( !Reached( point, out var road, out var frame ) )
		{
			ArchGhost.Note( new Vector3( point.x, point.y, Owner.LevelHeight ), "drag along a road" );

			return;
		}

		var reach = road.Reach();

		Gizmo.Draw.Color = ArchGhost.Accent;
		Gizmo.Draw.Line( frame.Side( -reach ), frame.Side( reach ) );

		ArchGhost.Note( frame.Position, $"{road.Name}  {frame.Distance / 12f:0} ft along" );
	}

	protected override void DrawPreview()
	{
		if ( !Reached( DragStart, out var near, out _ ) )
		{
			base.DrawPreview();

			return;
		}

		if ( Reached( DragCurrent, out var far, out _ ) && far != near )
		{
			Joining( near, far );

			return;
		}

		if ( !Spanned( DragStart, DragCurrent, out var road, out var span ) )
		{
			base.DrawPreview();

			return;
		}

		// A sketch - Show would edit the plan from inside a preview.
		var alone = road.Copy();

		alone.Nodes = road.Nodes;

		Show( alone, Owner.Resolved( road.Id, road.Curve ), span, road.Name );
	}

	// The drag shows the joined run - the easing through the join is in neither centreline.
	void Joining( ArchRoadPart near, ArchRoadPart far )
	{
		var join = ArchJoin.Across( near, far, ArchTunnel.ShortestBore );

		if ( !join.IsUsable )
		{
			ArchGhost.Note( new Vector3( DragCurrent.x, DragCurrent.y, Owner.LevelHeight ), join.Refused );

			return;
		}

		var sketch = near.Copy();

		sketch.Nodes = join.Nodes;

		var curve = ArchCurve.Of( join.Nodes );

		Gizmo.Draw.Color = ArchGhost.Line;
		ArchGhost.Curve( curve );

		Show( sketch, curve, join.Span, $"{near.Name} + {far.Name}" );
	}

	void Show( ArchRoadPart road, ArchCurve curve, ArchRoadSpan span, string what )
	{
		var tunnel = Edited.Copy( span.From, span.To );

		road.Tunnels = new List<ArchTunnelPart> { tunnel };

		var shape = ArchTunnel.Resolve( road, tunnel, curve, ArchRoadGen.Frames( road, curve ), Owner.Kit,
			new ArchGround( Owner.Scene ) );

		ArchRoadPreviewDraw.Tunnel( shape, false );

		if ( !shape.IsUsable )
		{
			ArchGhost.Note( new Vector3( DragCurrent.x, DragCurrent.y, Owner.LevelHeight ),
				$"{span.Length / 12f:0} ft - too short to bore" );

			return;
		}

		// Cover over the crown is what makes it a tunnel, not a box in a cutting.
		var cover = shape.Buried > 0f
			? $"{shape.Buried / 12f:0.#} ft of cover"
			: "no cover - cut and cover";

		ArchGhost.Note( shape.Far.Frame.Position,
			$"{what}: {shape.Span.Length / 12f:0} ft bore, {cover}" );
	}

	// Both ends on ONE road - the drag's own; too-short spans are refused outright.
	bool Spanned( Vector2 from, Vector2 to, out ArchRoadPart road, out ArchRoadSpan span )
	{
		span = default;

		if ( !Reached( from, out road, out var start ) || !Owner.Resolved( road.Id, road.Curve ).Nearest( to, out var end, out _ ) )
		{
			return false;
		}

		span = new ArchRoadSpan
		{
			From = MathF.Min( start.Distance, end.Distance ),
			To = MathF.Max( start.Distance, end.Distance )
		};

		return span.Length >= ArchTunnel.ShortestBore;
	}

	bool Reached( Vector2 point, out ArchRoadPart road, out ArchFrame frame )
	{
		road = Owner.RoadAt( point, out frame );

		return road is not null;
	}

	protected override void BuildOptions( ToolSidebarWidget panel )
	{
		Seeded();

		var part = Edited;
		var live = Placed;

		if ( live is not null )
		{
			panel.Layout.Add( ArchSidebarLayout.Advice( $"Editing {live.Name} live. Drive anything else and these go back to seeding the next drag." ) );
		}

		ArchRoadUi.TunnelTypes( panel, Owner, part.Type, Adopt );
		ArchRoadUi.Tunnel( panel, part, ArchRoadCatalogs.Tunnel( part.Type ),
			Refresh, live is null ? null : () => Owner.Touch( $"Retune {live.Name}" ) );
	}
}