Editor/Tool/Subtools/ArchPipeSubtool.cs
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();
}
public override void OnEnabled() {
base.OnEnabled();
ArchWorkflow.Restart( Scope( "flow" ) );
}
// One list: the gesture first, then the questions that gesture has. A route is asked what runs in it, how fat
// it is, what it does where something crosses and what hangs it - all picks - before the two typed rows that
// can never answer. A stack is three picks and nothing else.
protected override void BuildOptions( ToolSidebarWidget panel ) {
if ( Editing() is { } picked ) {
panel.Layout.Add( ArchSidebarLayout.Advice( $"Editing {picked.Describe()}. What is tuned here is also what the next run is drawn from." ) );
}
using var flow = ArchWorkflow.In( panel, Scope( "flow" ), Refresh );
flow.Step( "Gesture", GestureName, GestureGlyph, PickGesture );
if ( gesture == PipeGesture.Stack ) {
StackSteps( flow );
return;
}
RouteSteps( panel, flow );
}
string GestureName => gesture == PipeGesture.Stack ? "Stack" : "Route";
string GestureGlyph => gesture == PipeGesture.Stack ? "water_drop" : "timeline";
void PickGesture( ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "Route — click a centreline through shared nodes", "pipe_run", "timeline",
gesture == PipeGesture.Route, () => Switch( PipeGesture.Route, step ) );
grid.Pick( "Stack — click an eave for a rainwater downpipe", "pipe_stack", "water_drop",
gesture == PipeGesture.Stack, () => Switch( PipeGesture.Stack, step ) );
}
void Switch( PipeGesture wanted, ArchWorkflowStep step ) {
gesture = wanted;
nodes.Clear();
CancelDraft();
BeginDraft();
step.Chose();
}
// The picked part when it is this gesture's kind, else the seed for the next drag - the same steps either
// way, so a tuned run and the next one drawn after it cannot say different things.
ArchPipePart Routed => Owner.Picked?.Item as ArchPipePart ?? draft;
ArchDownpipePart Stacked => Owner.Picked?.Item as ArchDownpipePart ?? stack;
void StackSteps( ArchWorkflow flow ) {
var pipe = Stacked;
flow.Step( "Run", pipe.Run == PipeRun.Conduit ? "Conduit" : "Rainwater",
pipe.Run == PipeRun.Conduit ? "power_input" : "water_drop", step => PickRun( pipe, step ) );
flow.Step( "Profile", pipe.Profile, ArchIcons.ProfileGlyph, step => PickProfile( pipe, step ) );
flow.Step( "Foot", pipe.Shoe ? "Shoe" : "Square", pipe.Shoe ? "south" : "block", step => PickFoot( pipe, step ) );
}
void PickRun( ArchDownpipePart pipe, ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "Rainwater — taps the gutter and swans over to the wall", "pipe_rainwater", "water_drop",
pipe.Run == PipeRun.Rainwater, () => { pipe.Run = PipeRun.Rainwater; Settle( pipe, step ); } );
grid.Pick( "Conduit — a bracketed service run straight down the elevation", "pipe_conduit", "power_input",
pipe.Run == PipeRun.Conduit, () => { pipe.Run = PipeRun.Conduit; Settle( pipe, step ); } );
}
void PickProfile( ArchDownpipePart pipe, ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
foreach ( var entry in Owner.Kit.Profiles.Where( entry => entry.Name.Contains( "pipe", StringComparison.OrdinalIgnoreCase ) ) ) {
var name = entry.Name;
grid.Pick( name, ArchIcons.ProfileSlug( name ), ArchIcons.ProfileGlyph, pipe.Profile == name,
() => { pipe.Profile = name; Settle( pipe, step ); } );
}
}
// A shoe throws the water clear of the wall; without one the stack ends square. Two states of one answer, so
// the row advances rather than leaving a lone tick at the end of the list.
void PickFoot( ArchDownpipePart pipe, ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "Shoe — a swan neck at the bottom throwing the water clear", "opt_shoe", "south",
pipe.Shoe, () => { pipe.Shoe = true; Settle( pipe, step ); } );
grid.Pick( "Square — the stack ends flush with the ground", "opt_no_shoe", "block",
!pipe.Shoe, () => { pipe.Shoe = false; Settle( pipe, step ); } );
}
void Settle( ArchDownpipePart pipe, ArchWorkflowStep step ) {
if ( !ReferenceEquals( pipe, stack ) ) {
Owner.Touch( "Edit Downpipe" );
}
step.Chose();
}
void RouteSteps( ToolSidebarWidget panel, ArchWorkflow flow ) {
var part = Routed;
var changed = ReferenceEquals( part, draft ) ? null : (Action)(() => Owner.Touch( "Edit Pipe" ));
flow.Step( "Content", part.Content.ToString(), ContentGlyph( part.Content ), step =>
ArchPipeUi.Content( step.Layout, part, step.Chose, changed ) );
flow.Step( "Section", $"{part.Diameter:0.##} in", "radio_button_unchecked", step =>
ArchPipeUi.Section( step.Layout, part, step.Chose, changed ) );
flow.Step( "Crossings", CrossingName( part.Dodge ), CrossingGlyph( part.Dodge ), step =>
ArchPipeUi.Crossings( step.Layout, part, step.Chose, changed ) );
flow.Step( "Supports", part.Supports ? part.SupportKind.ToString() : "None", part.Supports ? "table_rows" : "block", step =>
ArchPipeUi.Supports( step.Layout, part, step.Chose, changed ) );
// The material rows open asset pickers, so they are parented to the SIDEBAR - a dialog hung off a step
// dies with the step on the next refresh.
flow.Step( "Materials", $"{part.Materials.Count} slot{(part.Materials.Count == 1 ? "" : "s")}", "image", step =>
ArchPipeUi.Materials( panel, step.Layout, part, Refresh, changed ) );
flow.Step( "Detail", $"{part.Detail.Sides} sides", "tune", step =>
ArchPipeUi.Detail( step.Layout, part.Detail, Refresh, changed ) );
}
static string ContentGlyph( PipeContent content ) => content switch {
PipeContent.Conduit => "power_input",
PipeContent.Cable => "cable",
PipeContent.Tray => "view_stream",
PipeContent.Duct => "crop_square",
_ => "water_damage"
};
static string CrossingName( PipeDodge dodge ) => dodge switch {
PipeDodge.Away => "Over",
PipeDodge.Toward => "Under",
PipeDodge.Side => "Around",
_ => "Straight through"
};
static string CrossingGlyph( PipeDodge dodge ) => dodge switch {
PipeDodge.Away => "trending_up",
PipeDodge.Toward => "trending_down",
PipeDodge.Side => "swap_horiz",
_ => "close"
};
ArchSelection Editing() {
return Owner.Picked?.Item is ArchPipePart or ArchDownpipePart ? Owner.Picked : null;
}
}