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.
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;
}
}