Editor/Designers/ArchOpeningPlacement.cs

Editor tool code for placing openings (windows/doors) in architecture. Tracks drag start/end, computes a placement shape (width, height, sill) constrained to wall runs, grid units, and presets, and finds nearest wall points from cursor or a fixed wall during dragging.

File Access
using System;
using Sandbox;

namespace Sunless.Architecture;

public readonly struct ArchOpeningPoint
{
	public ArchWall Wall { get; init; }
	public ArchRoom Room { get; init; }
	public ArchBuilding Building { get; init; }
	public float Along { get; init; }
	public float Height { get; init; }
	public bool WallPlane { get; init; }
}

// The four answers a drag needs beyond the cursor, held here because both opening tools drag the
// same way and a toggle that lived on one of them would be a second set of rules.
public sealed class ArchOpeningDrag
{
	public bool RestrictStartHeight { get; set; }
	public bool RestrictEndHeight { get; set; }
	public bool PadSides { get; set; } = true;
	public bool ClearCornice { get; set; }
}

public sealed class ArchOpeningPlacement
{
	public ArchOpeningDrag Drag { get; } = new();

	ArchOpeningPoint anchor;
	bool dragging;

	public void Update(
		ArchTool tool,
		bool focused,
		Action<ArchOpeningPoint, ArchOpeningPoint, bool> preview,
		Action<ArchOpeningPoint, ArchOpeningPoint, bool> place )
	{
		if ( !focused || !Point( tool, out var current ) )
		{
			return;
		}

		if ( Gizmo.WasLeftMousePressed )
		{
			anchor = current;
			dragging = true;
			return;
		}

		using ( ArchGhost.Begin() )
		{
			preview( dragging ? anchor : current, current, dragging );
		}

		if ( !dragging || !Gizmo.WasLeftMouseReleased )
		{
			return;
		}

		dragging = false;
		tool.EnsureTarget();

		var unit = tool.Grid.SubgridSize( 4 );
		var resized = MathF.Abs( current.Along - anchor.Along ) >= unit
			|| MathF.Abs( current.Height - anchor.Height ) >= unit;

		place( anchor, current, resized );
	}

	public static ArchOpeningShape Shape(
		ArchOpeningPoint from,
		ArchOpeningPoint to,
		ArchOpeningPreset preset,
		ArchGridService grid,
		ArchKit kit,
		ArchOpeningDrag drag )
	{
		var unit = grid.SubgridSize( 4 );
		var wallHeight = ArchWallSection.Height( from.Wall, from.Room, kit );
		var along = MathF.Abs( to.Along - from.Along );
		var vertical = MathF.Abs( to.Height - from.Height );
		var resizedWidth = along >= unit;
		var resizedHeight = vertical >= unit;
		var resizedVerticalSpan = resizedHeight && (!drag.RestrictStartHeight || !drag.RestrictEndHeight);
		var minimumWidth = resizedWidth ? unit : ArchGridService.FinestSize;
		var minimumHeight = resizedVerticalSpan ? unit : ArchGridService.FinestSize;
		var headroom = drag.ClearCornice
			? ArchOpeningClearance.Head( from.Room, from.Building, from.Wall, kit, wallHeight )
			: wallHeight;

		// Both ends are clamped into the run BEFORE the width is taken from them, so a drag that
		// overshoots a corner lands exactly on the margin instead of leaving whatever the cursor
		// happened to be short by. Measuring the width first and centring it afterwards is what
		// left an uneven sliver at each end of a full-length window.
		ArchOpeningSeats.Usable( from.Wall, preset, kit, drag.PadSides, minimumWidth, out var runFrom, out var runTo );

		float left;
		float right;

		if ( resizedWidth )
		{
			left = Math.Clamp( MathF.Min( from.Along, to.Along ), runFrom, runTo );
			right = Math.Clamp( MathF.Max( from.Along, to.Along ), runFrom, runTo );
		}
		else
		{
			ArchOpeningSeats.Centre( from.Along, ArchGridService.Fine( preset.Width ), runFrom, runTo, out left, out right );
		}

		var width = MathF.Max( minimumWidth, right - left );
		var offset = (left + right) * 0.5f;

		var wallPlaneDrag = resizedHeight && from.WallPlane && to.WallPlane;
		var presetSill = ArchGridService.Fine( preset.SillHeight );
		var presetTop = ArchGridService.Fine( preset.SillHeight + preset.Height );
		var sill = wallPlaneDrag && !drag.RestrictStartHeight
			? grid.Subgrid( MathF.Min( from.Height, to.Height ), 4 )
			: presetSill;
		var top = wallPlaneDrag && !drag.RestrictEndHeight
			? grid.Subgrid( MathF.Max( from.Height, to.Height ), 4 )
			: presetTop;

		sill = Math.Clamp( sill, 0f, MathF.Max( 0f, headroom - minimumHeight ) );
		top = Math.Clamp( top, minimumHeight, headroom );

		if ( top - sill < minimumHeight )
		{
			if ( drag.RestrictEndHeight && !drag.RestrictStartHeight )
			{
				sill = MathF.Max( 0f, top - minimumHeight );
			}
			else
			{
				top = MathF.Min( headroom, sill + minimumHeight );
			}
		}

		return new ArchOpeningShape
		{
			Offset = offset,
			Width = width,
			Height = top - sill,
			SillHeight = sill
		};
	}

	bool Point( ArchTool tool, out ArchOpeningPoint point )
	{
		if ( dragging )
		{
			return FixedWallPoint( tool, anchor, out point );
		}

		return NearestWallPoint( tool, out point );
	}

	static bool NearestWallPoint( ArchTool tool, out ArchOpeningPoint point )
	{
		point = default;

		if ( WallPlanePoint( tool, Gizmo.CurrentRay, null, out point ) )
		{
			return true;
		}

		if ( !tool.GroundPoint( out var ground ) )
		{
			return false;
		}

		var wall = tool.NearestWall( ground, out var room, out var along, out var distance );

		if ( wall is null || room is null || distance > 64f )
		{
			return false;
		}

		point = new ArchOpeningPoint
		{
			Wall = wall,
			Room = room,
			Building = tool.Plan.OwnerOf( room ),
			Along = tool.Grid.Subgrid( along, 4 ),
			Height = 0f,
			WallPlane = false
		};

		return true;
	}

	static bool FixedWallPoint( ArchTool tool, ArchOpeningPoint anchor, out ArchOpeningPoint point )
	{
		if ( WallPlanePoint( tool, Gizmo.CurrentRay, anchor.Wall, out point ) )
		{
			return true;
		}

		if ( !tool.GroundPoint( out var ground ) )
		{
			point = anchor;
			return true;
		}

		var along = Math.Clamp(
			Vector2.Dot( ground - anchor.Wall.Start, anchor.Wall.Direction ),
			0f,
			anchor.Wall.Length );
		var wallPoint = anchor.Wall.PointAt( along );

		point = new ArchOpeningPoint
		{
			Wall = anchor.Wall,
			Room = anchor.Room,
			Building = anchor.Building,
			Along = tool.Grid.Subgrid( along, 4 ),
			Height = tool.Grid.Subgrid( MathF.Abs( Vector2.Dot( ground - wallPoint, anchor.Wall.Normal ) ), 4 ),
			WallPlane = false
		};

		return true;
	}

	static bool WallPlanePoint( ArchTool tool, Ray ray, ArchWall fixedWall, out ArchOpeningPoint point )
	{
		point = default;
		var nearest = float.MaxValue;

		foreach ( var building in tool.Plan.Buildings )
		{
			var lift = ArchAsks.Lift( tool.Plan, building, tool.Kit );

			foreach ( var room in building.Rooms )
			{
				if ( room.Floor != tool.Level )
				{
					continue;
				}

				var baseHeight = room.BaseHeight + lift;
				var wallHeight = room.WallHeight > 0f ? room.WallHeight : tool.Kit.WallHeight;

				foreach ( var wall in room.Walls )
				{
					if ( fixedWall is not null && wall != fixedWall )
					{
						continue;
					}

					var denominator = ray.Forward.x * wall.Normal.x + ray.Forward.y * wall.Normal.y;

					if ( MathF.Abs( denominator ) < 0.0001f )
					{
						continue;
					}

					var offset = new Vector2( ray.Position.x, ray.Position.y ) - wall.Start;
					var distance = -Vector2.Dot( offset, wall.Normal ) / denominator;

					if ( distance < 0f || distance >= nearest )
					{
						continue;
					}

					var hit = ray.Position + ray.Forward * distance;
					var flat = new Vector2( hit.x, hit.y );
					var along = Vector2.Dot( flat - wall.Start, wall.Direction );
					var height = hit.z - baseHeight;

					if ( fixedWall is null
						&& (along < -8f || along > wall.Length + 8f || height < -8f || height > wallHeight + 8f) )
					{
						continue;
					}

					nearest = distance;
					point = new ArchOpeningPoint
					{
						Wall = wall,
						Room = room,
						Building = building,
						Along = tool.Grid.Subgrid( Math.Clamp( along, 0f, wall.Length ), 4 ),
						Height = tool.Grid.Subgrid( Math.Clamp( height, 0f, wallHeight ), 4 ),
						WallPlane = true
					};
				}
			}
		}

		return nearest < float.MaxValue;
	}
}