Editor utility for placing and editing service 'pipe' corridors in an architectural plan. Computes drag previews (ArchPipeDrag), converts grid drags into pipe bands, determines facing/outward direction, snaps and shares nodes, places pipe runs and brackets into buildings, and provides utilities like finding all pipes and computing surface normals along a run.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// One resolve, shared by the ghost, the commit, the handles and the MCP tools - a drag preview that worked
// out its own band would show a corridor the rebuild seated somewhere else.
public sealed class ArchPipeDrag
{
public Vector2 Min { get; init; }
public Vector2 Max { get; init; }
public float Bottom { get; init; }
public float Top { get; init; }
public bool AlongX { get; init; }
public Vector2 Outward { get; init; } = new( 1f, 0f );
public PipeMount Mount { get; init; }
public bool Stands => Max.x - Min.x >= ArchPipe.MinSize || Max.y - Min.y >= ArchPipe.MinSize;
public float Rise => MathF.Max( 1f, Top - Bottom );
public List<Vector2> Loop() => ArchFootprint.Rect( Min, Max );
}
// Where a services corridor stands, resolved ONCE. The box is the whole authored shape - what runs in it,
// how it fills and which crossing it gives way to are all read off the part afterwards, so the gesture is
// the same drag whether it lands a rainwater bundle or a cable tray.
public static class ArchPipe
{
public const float MinSize = 8f;
// How deep a corridor hangs off the surface it was drawn against before anybody drags its faces. Small on
// purpose: the box widget is the authoring surface, and a default that filled the storey would bury the
// very ceiling the author is looking at.
public const float DefaultDepth = 14f;
// What the author pointed at, resolved once: the picker's own answer where they made one, else the face
// the cursor came to rest on. Everything downstream reads this and never the picker.
public static PipeMount Aimed( PipeMount? picked, ArchCursorFace face )
{
if ( picked is { } chosen )
{
return chosen;
}
return face == ArchCursorFace.Ceiling ? PipeMount.Ceiling : PipeMount.Floor;
}
// The bounds snap; the run direction is taken from the LONGER side of what was dragged and then kept on
// the part, or pulling the box square through its own handles would spin the whole run under the author's
// hand. A drag too short to have a direction takes the plan's x.
public static ArchPipeDrag Sketch(
ArchGridService grid,
ArchPlan plan,
Vector2 from,
Vector2 to,
PipeMount mount,
float floor,
float soffit,
float depth )
{
var start = grid.Base( from );
var finish = grid.Base( to );
var min = new Vector2( MathF.Min( start.x, finish.x ), MathF.Min( start.y, finish.y ) );
var max = new Vector2( MathF.Max( start.x, finish.x ), MathF.Max( start.y, finish.y ) );
var alongX = max.x - min.x >= max.y - min.y;
var (bottom, top) = Band( mount, floor, soffit, MathF.Max( 2f, depth ) );
return new ArchPipeDrag
{
Min = min,
Max = max,
Bottom = bottom,
Top = top,
AlongX = alongX,
Outward = Facing( plan, (min + max) * 0.5f, alongX ),
Mount = mount
};
}
// A ceiling corridor hangs UNDER the soffit, a floor one stands ON the slab, and a wall one is a band up
// the elevation. Measured off the surface each is mounted on, so a run stays put when the storey moves.
static (float Bottom, float Top) Band( PipeMount mount, float floor, float soffit, float depth ) => mount switch
{
PipeMount.Ceiling => (soffit - depth, soffit),
PipeMount.Wall => (floor + depth, floor + depth * 3f),
_ => (floor, floor + depth)
};
// Which way is out of the wall a corridor is strapped to. Snapped onto the drag's own across axis, because
// a box drawn on the grid has no third direction to lean in, and flipped so it points at the side the
// author was standing on rather than into the masonry.
public static Vector2 Facing( ArchPlan plan, Vector2 centre, bool alongX )
{
var across = alongX ? new Vector2( 0f, 1f ) : new Vector2( 1f, 0f );
if ( plan is null )
{
return across;
}
ArchWall nearest = null;
var closest = float.MaxValue;
foreach ( var room in plan.AllRooms() )
{
var wall = ArchTool.NearestWall( room, centre, out _, out var distance );
if ( wall is null || distance >= closest )
{
continue;
}
closest = distance;
nearest = wall;
}
if ( nearest is null )
{
return across;
}
var outward = Vector2.Dot( centre - nearest.Start, nearest.Normal ) < 0f ? -nearest.Normal : nearest.Normal;
return Vector2.Dot( outward, across ) >= 0f ? across : -across;
}
public static ArchPipePart PlaceRoute(
ArchPlan plan,
ArchBuilding building,
int level,
IReadOnlyList<ArchCurveNode> nodes,
ArchPipePart draft,
float snap = MinSize )
{
if ( plan is null || building is null || nodes is not { Count: >= 2 } )
{
return null;
}
var route = nodes.Select( node => Shared( plan, node, snap, true ) ).ToList();
if ( route.Zip( route.Skip( 1 ) ).All( pair => (pair.First.Position - pair.Second.Position).Length < MinSize ) )
{
return null;
}
var part = new ArchPipePart
{
Id = plan.AllocateId(),
Name = $"{draft.Content} Run {building.Pipes.Count + 1}",
Level = level,
Content = draft.Content,
Mount = draft.Mount,
Nodes = route,
Outward = draft.Outward,
Diameter = draft.Diameter,
Materials = new List<string>( draft.Materials ),
RunMaterialIndex = draft.RunMaterialIndex,
FittingMaterialIndex = draft.FittingMaterialIndex,
SupportMaterialIndex = draft.SupportMaterialIndex,
Dodge = draft.Dodge,
Clearance = draft.Clearance,
Supports = draft.Supports,
SupportSpacing = draft.SupportSpacing,
SupportKind = draft.SupportKind,
Detail = draft.Detail.Copy()
};
building.Pipes.Add( part );
return part;
}
public static List<ArchCurveNode> Connected( ArchPlan plan, IReadOnlyList<ArchCurveNode> nodes, float snap = MinSize )
{
return nodes.Select( node => Shared( plan, node, snap, false ) ).ToList();
}
static ArchCurveNode Shared( ArchPlan plan, ArchCurveNode asked, float snap, bool allocate )
{
var nearest = All( plan )
.SelectMany( run => run.Nodes )
.Where( node => node.Id != 0 )
.Select( node => new { Node = node, Gap = (node.Position - asked.Position).Length } )
.Where( candidate => candidate.Gap <= MathF.Max( 0.5f, snap ) )
.OrderBy( candidate => candidate.Gap )
.FirstOrDefault();
if ( nearest is not null )
{
return nearest.Node.Copy();
}
var node = asked.Copy();
if ( allocate )
{
node.Id = plan.AllocateId();
}
return node;
}
public static ArchPipeBracketPart Hang( ArchPlan plan, ArchBuilding building, int level, ArchPipeDrag drag, ArchPipeBracketPart draft )
{
if ( plan is null || building is null || !drag.Stands )
{
return null;
}
var part = new ArchPipeBracketPart
{
Id = plan.AllocateId(),
Name = $"Brackets {building.Brackets.Count + 1}",
Level = level,
Kind = draft.Kind,
Mount = drag.Mount,
Min = drag.Min,
Max = drag.Max,
BaseHeight = drag.Bottom,
TopHeight = drag.Top,
AlongX = drag.AlongX,
Outward = drag.Outward,
Spacing = draft.Spacing,
MemberWidth = draft.MemberWidth,
Clearance = draft.Clearance,
Overhang = draft.Overhang,
Detail = draft.Detail.Copy()
};
building.Brackets.Add( part );
return part;
}
// Every service corridor in the plan, in one place - the stack, the audit and the crossing pass all walk it.
public static IEnumerable<ArchPipePart> All( ArchPlan plan )
{
return plan is null ? Array.Empty<ArchPipePart>() : plan.Buildings.SelectMany( building => building.Pipes );
}
public static Vector3 SurfaceNormal( ArchPipePart part, Vector3 point )
{
var nearest = float.MaxValue;
var normal = Vector3.Zero;
for ( var index = 1; index < part.Nodes.Count; index++ )
{
var from = part.Nodes[index - 1];
var to = part.Nodes[index];
var span = to.Position - from.Position;
var length = span.Length;
if ( length < 0.01f )
{
continue;
}
var along = Math.Clamp( Vector3.Dot( point - from.Position, span / length ), 0f, length );
var gap = (point - (from.Position + span / length * along)).Length;
if ( gap >= nearest )
{
continue;
}
nearest = gap;
normal = Vector3.Lerp( from.Normal, to.Normal, along / length ).Normal;
}
if ( !normal.IsNearZeroLength )
{
return normal;
}
return part.Mount switch
{
PipeMount.Ceiling => Vector3.Down,
PipeMount.Floor => Vector3.Up,
_ => new Vector3( part.Facing.x, part.Facing.y, 0f )
};
}
}