Editor/Pipe/ArchPipe.cs

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.

File Access
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 )
		};
	}
}