Editor/Tool/Subtools/ArchTunnelSubtool.cs
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 between two roads to join and bore them, or along one to tunnel it.";
public override void OnEnabled() {
base.OnEnabled();
ArchWorkflow.Restart( Scope( "flow" ) );
}
ArchTunnelPart Placed => placed != 0 && Owner.Plan.NextId == stamp ? Owner.Plan.FindTunnel( placed ) : null;
ArchTunnelPart Edited => Placed ?? draft;
// Advancing the step is the refresh, so this one never calls for a second.
void Adopt( ArchTunnelType type ) {
if ( Placed is { } bore ) {
ArchRoadUi.Retype( Owner, bore, type );
Owner.Touch( "Retype Tunnel" );
return;
}
draft = type.Driving( 0f, 0f );
}
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;
}
// One list: the type first, because a tunnel IS its structure; then the bore's own shape, what walks beside the
// carriageway and whether the hill is left standing over it - all picks - before the typed rows. Seeding is
// hoisted here because a closed step never builds and the ghost is drawn from what it seeds.
protected override void BuildOptions( ToolSidebarWidget panel ) {
Seeded();
var part = Edited;
var live = Placed;
var type = ArchRoadCatalogs.Tunnel( part.Type );
var changed = live is null ? null : (Action)(() => Owner.Touch( $"Retune {live.Name}" ));
if ( live is not null ) {
panel.Layout.Add( ArchSidebarLayout.Advice( $"Editing {live.Name} live." ) );
}
using var flow = ArchWorkflow.In( panel, Scope( "flow" ), Refresh );
flow.Step( "Type", type?.Title ?? "Hand-tuned", type?.Icon ?? "vpn_lock", step =>
ArchRoadUi.TunnelTypes( step.Layout, part.Type, chosen => { Adopt( chosen ); step.Chose(); } ) );
if ( ArchRoadUi.Wanted( type, ArchTunnelGroup.Bore ) ) {
flow.Step( "Bore", part.Bore.ToString(), ArchRoadUi.BoreGlyph( part.Bore ), step =>
ArchRoadUi.Bores( step.Layout, part, step.Chose, changed ) );
}
if ( ArchRoadUi.Wanted( type, ArchTunnelGroup.Walkway ) ) {
flow.Step( "Walkway", part.Walkways.ToString(), ArchRoadUi.SideGlyph( part.Walkways ), step =>
ArchRoadUi.Walkways( step.Layout, part, step.Chose, changed ) );
}
if ( ArchRoadUi.Wanted( type, ArchTunnelGroup.Ground ) ) {
flow.Step( "Ground", part.Bored ? "Bored" : "Cut and cover", part.Bored ? "landslide" : "signal_cellular_null",
step => ArchRoadUi.Bored( step.Layout, part, step.Chose, changed ) );
}
if ( ArchRoadUi.Wanted( type, ArchTunnelGroup.Portal ) ) {
flow.Step( "Portals", part.Portals ? "Both mouths" : "None", part.Portals ? "door_front" : "block", step => {
ArchRoadUi.Portals( step.Layout, part, Refresh, changed );
if ( part.Portals ) {
ArchRoadUi.PortalSizes( step.Layout, part, changed );
}
} );
}
if ( ArchRoadUi.Wanted( type, ArchTunnelGroup.Bore ) ) {
flow.Step( "Section", $"{part.Headroom:0} headroom", "height", step => ArchRoadUi.Section( step.Layout, part, changed ) );
}
}
}