Editor/Services/ArchBoundaryPlacementService.cs

Editor service that computes valid rectangular placements and plots for architecture building placement. It aligns and trims a requested rectangle against occupied cells, checks usability, finds abutting host buildings, and grows/adjusts plots to meet minimum spans.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

public sealed class ArchBoundaryPlacementService
{
	readonly ArchPlan plan;
	readonly ArchKit kit;
	readonly ArchGridService grid;

	public ArchBoundaryPlacementService( ArchPlan plan, ArchKit kit )
	{
		this.plan = plan ?? throw new ArgumentNullException( nameof( plan ) );
		this.kit = kit ?? throw new ArgumentNullException( nameof( kit ) );
		grid = new ArchGridService();
	}

	public ArchRectanglePlacement Outside( int level, Vector2 from, Vector2 to, ArchBuilding staged = null )
	{
		from = grid.Base( from );
		to = grid.Base( to );

		var requested = grid.Rectangle( from, to );
		var blockers = OccupiedCells( level, staged );
		var aligned = Align( requested, blockers );
		var resolved = Trim( aligned, from, to, blockers );
		var usable = IsUsable( resolved );
		var host = usable ? Abutted( level, resolved, staged ) : null;

		return new ArchRectanglePlacement
		{
			Min = resolved.Min,
			Max = resolved.Max,
			Adjusted = resolved.Min != requested.Min || resolved.Max != requested.Max,
			Touches = usable && blockers.Any( blocker => Touches( resolved, blocker ) ),
			TouchesHost = host is not null,
			IsUsable = usable,
			Host = host
		};
	}

	// Staged leads only when the drag reaches it - pointing at a building is how you say which you meant.
	ArchBuilding Abutted( int level, ArchBox resolved, ArchBuilding staged )
	{
		if ( staged is not null && Abuts( level, resolved, staged ) )
		{
			return staged;
		}

		return plan.Buildings.FirstOrDefault( building => !ReferenceEquals( building, staged ) && Abuts( level, resolved, building ) );
	}

	bool Abuts( int level, ArchBox resolved, ArchBuilding building )
	{
		return OccupiedCells( level, new[] { building } ).Any( blocker => Touches( resolved, blocker ) );
	}

	public ArchPlot PlotOutside(
		int level,
		Vector2 from,
		Vector2 to,
		Vector2 minimum,
		bool flipX,
		bool flipY,
		out bool usable )
	{
		from = grid.Base( from );
		to = grid.Base( to );

		var required = new Vector2( MinimumSpan( minimum.x ), MinimumSpan( minimum.y ) );
		var min = Vector2.Min( from, to );
		var max = Vector2.Max( from, to );
		var x = Grow( min.x, max.x, required.x, from.x, to.x );
		var y = Grow( min.y, max.y, required.y, from.y, to.y );

		min = new Vector2( x.Min, y.Min );
		max = new Vector2( x.Max, y.Max );

		var requested = new ArchPlot { Min = min, Max = max, FlipX = flipX, FlipY = flipY };
		var placement = Outside( level, min, max );
		var size = placement.Max - placement.Min;

		usable = placement.IsUsable && size.x >= required.x && size.y >= required.y;

		return usable
			? new ArchPlot { Min = placement.Min, Max = placement.Max, FlipX = flipX, FlipY = flipY }
			: requested;
	}

	float MinimumSpan( float value )
	{
		return MathF.Ceiling( MathF.Max( grid.BaseSize, value ) / grid.BaseSize ) * grid.BaseSize;
	}

	static (float Min, float Max) Grow( float min, float max, float required, float from, float to )
	{
		if ( max - min >= required )
		{
			return (min, max);
		}

		if ( to < from )
		{
			return (max - required, max);
		}

		return (min, min + required);
	}

	List<ArchBox> OccupiedCells( int level, ArchBuilding staged )
	{
		var buildings = staged is null
			? plan.Buildings
			: plan.Buildings.Concat( new[] { staged } ).Distinct();

		return OccupiedCells( level, buildings );
	}

	static List<ArchBox> OccupiedCells( int level, IEnumerable<ArchBuilding> buildings )
	{
		var footprints = buildings
			.SelectMany( building => building.Rooms )
			.Where( room => room.Floor == level && room.HasFootprint )
			.Select( ArchFloorGen.Footprint )
			.Where( footprint => footprint.Count >= 3 )
			.ToList();

		if ( footprints.Count == 0 )
		{
			return new List<ArchBox>();
		}

		return ArchFootprint.Cells( ArchFootprint.Union( footprints ), null );
	}

	ArchBox Align( (Vector2 Min, Vector2 Max) requested, IReadOnlyList<ArchBox> blockers )
	{
		var candidate = new ArchBox { Min = requested.Min, Max = requested.Max };
		var reach = MathF.Max( grid.BaseSize, kit.WallThickness );

		foreach ( var blocker in blockers )
		{
			var min = candidate.Min;
			var max = candidate.Max;

			if ( Overlaps( min.y, max.y, blocker.Min.y, blocker.Max.y ) )
			{
				min.x = Nearby( min.x, blocker.Min.x, blocker.Max.x, reach );
				max.x = Nearby( max.x, blocker.Min.x, blocker.Max.x, reach );
			}

			if ( Overlaps( min.x, max.x, blocker.Min.x, blocker.Max.x ) )
			{
				min.y = Nearby( min.y, blocker.Min.y, blocker.Max.y, reach );
				max.y = Nearby( max.y, blocker.Min.y, blocker.Max.y, reach );
			}

			if ( max.x - min.x >= grid.BaseSize && max.y - min.y >= grid.BaseSize )
			{
				candidate = new ArchBox { Min = min, Max = max };
			}
		}

		return candidate;
	}

	ArchBox Trim( ArchBox requested, Vector2 from, Vector2 to, IReadOnlyList<ArchBox> blockers )
	{
		var candidate = requested;
		var attempts = Math.Max( 1, blockers.Count * 4 );

		for ( var attempt = 0; attempt < attempts; attempt++ )
		{
			var blocker = blockers
				.Where( entry => OverlapArea( candidate, entry ) > 0f )
				.OrderByDescending( entry => OverlapArea( candidate, entry ) )
				.FirstOrDefault();

			if ( OverlapArea( candidate, blocker ) <= 0f )
			{
				return candidate;
			}

			var choices = Cuts( candidate, blocker )
				.Where( IsUsable )
				.OrderByDescending( choice => Contains( choice, to ) ? 2 : Contains( choice, from ) ? 1 : 0 )
				.ThenBy( choice => blockers.Sum( entry => OverlapArea( choice, entry ) ) )
				.ThenByDescending( Area )
				.ToList();

			if ( choices.Count == 0 )
			{
				return new ArchBox();
			}

			candidate = choices[0];
		}

		return blockers.Any( blocker => OverlapArea( candidate, blocker ) > 0f )
			? new ArchBox()
			: candidate;
	}

	IEnumerable<ArchBox> Cuts( ArchBox candidate, ArchBox blocker )
	{
		if ( candidate.Min.x < blocker.Min.x )
		{
			yield return new ArchBox { Min = candidate.Min, Max = new Vector2( blocker.Min.x, candidate.Max.y ) };
		}

		if ( candidate.Max.x > blocker.Max.x )
		{
			yield return new ArchBox { Min = new Vector2( blocker.Max.x, candidate.Min.y ), Max = candidate.Max };
		}

		if ( candidate.Min.y < blocker.Min.y )
		{
			yield return new ArchBox { Min = candidate.Min, Max = new Vector2( candidate.Max.x, blocker.Min.y ) };
		}

		if ( candidate.Max.y > blocker.Max.y )
		{
			yield return new ArchBox { Min = new Vector2( candidate.Min.x, blocker.Max.y ), Max = candidate.Max };
		}
	}

	bool IsUsable( ArchBox candidate )
	{
		return candidate.Max.x - candidate.Min.x >= grid.BaseSize
			&& candidate.Max.y - candidate.Min.y >= grid.BaseSize;
	}

	static float Nearby( float value, float first, float second, float reach )
	{
		var firstGap = MathF.Abs( value - first );
		var secondGap = MathF.Abs( value - second );

		if ( firstGap <= secondGap && firstGap <= reach )
		{
			return first;
		}

		return secondGap <= reach ? second : value;
	}

	static bool Overlaps( float firstMin, float firstMax, float secondMin, float secondMax )
	{
		return MathF.Min( firstMax, secondMax ) - MathF.Max( firstMin, secondMin ) > 0f;
	}

	static float OverlapArea( ArchBox first, ArchBox second )
	{
		var width = MathF.Min( first.Max.x, second.Max.x ) - MathF.Max( first.Min.x, second.Min.x );
		var height = MathF.Min( first.Max.y, second.Max.y ) - MathF.Max( first.Min.y, second.Min.y );

		return MathF.Max( 0f, width ) * MathF.Max( 0f, height );
	}

	static float Area( ArchBox box )
	{
		return MathF.Max( 0f, box.Max.x - box.Min.x ) * MathF.Max( 0f, box.Max.y - box.Min.y );
	}

	static bool Contains( ArchBox box, Vector2 point )
	{
		return point.x >= box.Min.x && point.x <= box.Max.x
			&& point.y >= box.Min.y && point.y <= box.Max.y;
	}

	static bool Touches( ArchBox first, ArchBox second )
	{
		var vertical = (first.Max.x == second.Min.x || first.Min.x == second.Max.x)
			&& Overlaps( first.Min.y, first.Max.y, second.Min.y, second.Max.y );
		var horizontal = (first.Max.y == second.Min.y || first.Min.y == second.Max.y)
			&& Overlaps( first.Min.x, first.Max.x, second.Min.x, second.Max.x );

		return vertical || horizontal;
	}
}