Editor/Bands/ArchWallJoins.cs

Editor utility for wall join and opening calculations in an architectural editor. Computes how wall boards end, mitre/cap/abut behavior, landing/wrapping/skirting, shared openings, breaches (cutting openings), alignment checks and other geometric helpers.

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

namespace Sunless.Architecture;

// How a run-like board stops. Mitred continues round a corner and needs no cap; Butted dies on a
// face it meets; Cut dies at an opening's edge; Free is nothing there at all - the defect arch_audit
// hunts for, and the reason this is a vocabulary rather than a bool.
public enum ArchBandStop
{
	Mitred,
	Butted,
	Cut,
	Free,
	Returned
}

public readonly record struct ArchBandEnd( float Inner, float Outer, ArchBandStop Stop, float Return = 0f )
{
	public bool Capped => Stop is not (ArchBandStop.Mitred or ArchBandStop.Returned);
}

public readonly struct ArchWallJoin
{
	public float StartExtend { get; init; }
	public float EndExtend { get; init; }
	public bool StartJoined { get; init; }
	public bool EndJoined { get; init; }
	public float StartMitre { get; init; }
	public float EndMitre { get; init; }
	public bool StartSkirted { get; init; }
	public bool EndSkirted { get; init; }
	public bool StartLanded { get; init; }
	public bool EndLanded { get; init; }
	public bool StartWraps { get; init; }
	public bool EndWraps { get; init; }
	// A board turning a mouth's jamb turns on the FAR face of the wall it lands on, not on its own end:
	// the wall stops at the near face, the reveal past it is the same plane, and the board crosses it to
	// mitre with the skirting standing on the room's own face.
	public float StartCrossing { get; init; }
	public float EndCrossing { get; init; }
	public float Length { get; init; }
	public float HalfThickness { get; init; }
	public IReadOnlyList<float> Landings { get; init; }
	public IReadOnlyList<float> SkirtedLandings { get; init; }

	// However far either face reaches, in or out: a wall that lands on another pulls its cap back to
	// that wall's face, and flooring these at the authored end left the cap behind the field it caps.
	public float From => MathF.Min( StartMitre, StartExtend );

	public float To => MathF.Max( EndMitre, EndExtend );

	public float SkirtFrom => StartMitre;

	public float SkirtTo => EndMitre;

	public float EndMitreCut => EndMitre - Length;

	public float EndExtendCut => EndExtend - Length;

	public bool StartCapped => !StartJoined;

	public bool EndCapped => !EndJoined;

	// An inside corner cuts the FACE short, so the back runs on past by the board's depth to the
	// corner of the two wall faces; wrapping an outside corner is the same mitre the other way up,
	// and it is the face that runs past, round the jamb, to meet the board coming the other way.
	public ArchBandEnd Foot( float depth )
	{
		if ( StartWraps )
		{
			return new( StartCrossing, StartCrossing - depth, ArchBandStop.Mitred );
		}

		if ( StartSkirted )
		{
			return new( SkirtFrom, StartMitre * Splay( depth ), ArchBandStop.Mitred );
		}

		return new( SkirtFrom, SkirtFrom, StartLanded ? ArchBandStop.Butted : ArchBandStop.Free );
	}

	public ArchBandEnd Head( float depth )
	{
		if ( EndWraps )
		{
			return new( EndCrossing, EndCrossing + depth, ArchBandStop.Mitred );
		}

		if ( EndSkirted )
		{
			return new( SkirtTo, Length + (EndMitre - Length) * Splay( depth ), ArchBandStop.Mitred );
		}

		return new( SkirtTo, SkirtTo, EndLanded ? ArchBandStop.Butted : ArchBandStop.Free );
	}

	public ArchBandEnd ExteriorFoot( float depth )
	{
		return new(
			StartExtend,
			StartExtend * Splay( depth ),
			StartJoined ? ArchBandStop.Mitred : ArchBandStop.Free );
	}

	public ArchBandEnd ExteriorHead( float depth )
	{
		return new(
			EndExtend,
			Length + EndExtendCut * Splay( depth ),
			EndJoined ? ArchBandStop.Mitred : ArchBandStop.Free );
	}

	// A board reaching a station another wall lands on turns the corner there instead of dying square -
	// onto that wall's own board. Landings are also where the FIELD splits, which every wall does whether
	// it is skirted or not, so the two lists are not the same one.
	public bool WrapsAt( float station )
	{
		return SkirtedLandings is not null && SkirtedLandings.Any( landing => MathF.Abs( landing - station ) <= 1f );
	}

	float Splay( float depth ) => HalfThickness > 0.01f ? (HalfThickness + depth) / HalfThickness : 1f;
}

public static class ArchWallJoins
{
	const float CornerTolerance = 1.5f;
	const float MaxExtend = 512f;

	public static ArchWallJoin For( ArchWall wall, ArchRoom room, ArchBuilding building, float thickness )
	{
		return For( wall, room, Neighbours( room, building ), thickness );
	}

	public static ArchWallJoin For( ArchWall wall, ArchRoom room, IEnumerable<ArchWall> neighbours, float thickness )
	{
		var nearby = neighbours?.ToList() ?? new List<ArchWall>();
		var half = Half( wall, thickness );

		var start = Resolve( wall, nearby, thickness, true, half );
		var end = Resolve( wall, nearby, thickness, false, half );
		var landings = Landings( wall, nearby, thickness );

		return new ArchWallJoin
		{
			StartExtend = start.Exterior,
			EndExtend = end.Exterior,
			StartJoined = start.Joined,
			EndJoined = end.Joined,
			StartMitre = start.Interior,
			EndMitre = end.Interior,
			StartSkirted = start.Skirted,
			EndSkirted = end.Skirted,
			StartLanded = start.Landed,
			EndLanded = end.Landed,
			StartWraps = start.Wraps,
			EndWraps = end.Wraps,
			StartCrossing = start.Crossing,
			EndCrossing = end.Crossing,
			Length = wall.Length,
			HalfThickness = half,
			Landings = landings.All,
			SkirtedLandings = landings.Skirted
		};
	}

	sealed record EndResolution( float Interior, float Exterior, bool Joined, bool Skirted, bool Landed, bool Wraps, float Crossing );

	static EndResolution Resolve( ArchWall wall, List<ArchWall> neighbours, float thickness, bool atStart, float half )
	{
		var corner = atStart ? wall.Start : wall.End;
		var body = atStart ? wall.Direction : -wall.Direction;
		var outward = -body;
		var joined = false;
		var skirted = false;
		var wraps = false;
		var signedInterior = 0f;
		var signedExterior = 0f;
		float? landing = null;
		var through = 0f;

		foreach ( var other in neighbours )
		{
			if ( ReferenceEquals( other, wall ) || other.Length < 0.5f )
			{
				continue;
			}

			if ( Touches( other, corner, out var otherBody ) )
			{
				var cut = Cut( wall, body, otherBody, half );

				if ( !joined || MathF.Abs( cut.Interior ) > MathF.Abs( signedInterior ) )
				{
					signedInterior = cut.Interior;
					signedExterior = cut.Exterior;
					joined = true;
					skirted = other.Baseboard;
				}

				continue;
			}

			if ( !Butts( wall, other, corner, outward, thickness, out var reach, out var across ) )
			{
				continue;
			}

			if ( landing is null || MathF.Abs( reach ) < MathF.Abs( landing.Value ) )
			{
				landing = reach;
				through = across;
				// Only onto a board: a mitre is two boards meeting, and a wall with no skirting on it
				// leaves the turn hanging open exactly as a corner would.
				wraps = Breached( other, corner, half ) && other.Baseboard;
			}
		}

		if ( joined )
		{
			var limit = wall.Length * 0.45f;
			var interior = Math.Clamp( signedInterior, -limit, MaxExtend );
			var exterior = Math.Clamp( signedExterior, -limit, MaxExtend );

			return new EndResolution(
				At( atStart, wall.Length, interior ),
				At( atStart, wall.Length, exterior ),
				true,
				skirted,
				true,
				false,
				At( atStart, wall.Length, interior ) );
		}

		// Nothing met: the end stays where it was authored, which for the END of a wall is its LENGTH.
		// Returning a bare zero ran every unmet end back to the wall's start and left a board 8 long.
		if ( landing is null )
		{
			var kept = At( atStart, wall.Length, 0f );

			return new EndResolution( kept, kept, false, false, false, false, kept );
		}

		// A reach is measured the way the wall travels out of this end, and at the START that is the way
		// the stations run BACK - unflipped, an end that had to retract drove deeper in instead.
		var at = At( atStart, wall.Length, atStart ? -landing.Value : landing.Value );
		var over = At( atStart, wall.Length, atStart ? -through : through );

		return new EndResolution( at, at, false, false, true, wraps, over );
	}

	// A board dying on a wall face turns the corner instead where THIS wall's own face is the edge of
	// a hole in that one - a walkway mouth's jamb, whether the host wall carries an archway or was
	// stubbed back to nothing. Measured off the faces, not the centreline: a mouth opens between the
	// passage walls' inner faces, so the wall landing on it always stands half a thickness outside.
	static bool Breached( ArchWall other, Vector2 corner, float half )
	{
		var along = Vector2.Dot( corner - other.Start, other.Direction );

		if ( along - half < CornerTolerance || along + half > other.Length - CornerTolerance )
		{
			return true;
		}

		return other.Openings.Any( opening => opening.SillHeight < 1f
			&& (MathF.Abs( along - half - opening.Right ) <= CornerTolerance || MathF.Abs( along + half - opening.Left ) <= CornerTolerance) );
	}

	static float At( bool atStart, float length, float value ) => atStart ? value : length + value;

	static (float Interior, float Exterior) Cut( ArchWall wall, Vector2 body, Vector2 otherBody, float half )
	{
		var sum = body + otherBody;
		var reach = sum.Length;

		if ( reach < 0.01f )
		{
			return (0f, 0f);
		}

		var mitre = sum / reach;
		var direction = wall.Direction;
		var normal = wall.Normal;
		var across = direction.x * mitre.y - direction.y * mitre.x;

		if ( MathF.Abs( across ) < 0.001f )
		{
			return (0f, 0f);
		}

		var lean = normal.x * mitre.y - normal.y * mitre.x;
		var cut = half * lean / across;

		return (-cut, cut);
	}

	static float Half( ArchWall wall, float fallback ) => (wall.Thickness > 0f ? wall.Thickness : fallback) * 0.5f;

	static List<ArchWall> Neighbours( ArchRoom room, ArchBuilding building )
	{
		if ( building is null )
		{
			return room.Walls;
		}

		return building.Rooms
			.Where( other => MathF.Abs( other.BaseHeight - room.BaseHeight ) < 0.5f )
			.SelectMany( other => other.Walls )
			.ToList();
	}

	static bool Touches( ArchWall other, Vector2 corner, out Vector2 outward )
	{
		if ( (other.Start - corner).Length <= CornerTolerance )
		{
			outward = other.Direction;
			return true;
		}

		if ( (other.End - corner).Length <= CornerTolerance )
		{
			outward = -other.Direction;
			return true;
		}

		outward = default;
		return false;
	}

	static bool Butts( ArchWall wall, ArchWall other, Vector2 corner, Vector2 outward, float thickness, out float reach, out float across )
	{
		reach = 0f;
		across = 0f;

		if ( !LandsOn( other, corner, thickness ) )
		{
			return false;
		}

		var normal = new Vector2( -other.Direction.y, other.Direction.x );
		var closing = Vector2.Dot( outward, normal );

		if ( MathF.Abs( closing ) < 0.05f )
		{
			return false;
		}

		var half = Half( other, thickness );
		var offset = Vector2.Dot( corner - other.Start, normal );

		// The face this wall's own travel reaches FIRST, however far behind the authored corner that
		// leaves it. A walkway's wall is drawn through the wall it meets to the far face, and settling
		// for whichever face is closest kept it there - through the whole wall, cap in the room beyond.
		var entering = (-half - offset) / closing;
		var leaving = (half - offset) / closing;
		var limit = wall.Length * 0.45f;

		reach = Math.Clamp( MathF.Min( entering, leaving ), -limit, MaxExtend );
		across = Math.Clamp( MathF.Max( entering, leaving ), -limit, MaxExtend );

		return true;
	}

	static bool LandsOn( ArchWall other, Vector2 point, float thickness )
	{
		var along = Vector2.Dot( point - other.Start, other.Direction );

		if ( along < -CornerTolerance || along > other.Length + CornerTolerance )
		{
			return false;
		}

		var sideways = point - other.Start;
		var distance = MathF.Abs( sideways.x * -other.Direction.y + sideways.y * other.Direction.x );

		return distance <= MathF.Max( thickness, CornerTolerance );
	}

	sealed record LandingStations( List<float> All, List<float> Skirted );

	static LandingStations Landings( ArchWall wall, List<ArchWall> neighbours, float thickness )
	{
		var all = new List<float>();
		var skirted = new List<float>();

		foreach ( var other in neighbours )
		{
			if ( ReferenceEquals( other, wall ) || other.Length < 0.5f )
			{
				continue;
			}

			var sine = MathF.Abs( wall.Direction.x * other.Direction.y - wall.Direction.y * other.Direction.x );

			if ( sine < 0.05f )
			{
				continue;
			}

			var half = Half( other, thickness ) / sine;

			foreach ( var end in new[] { other.Start, other.End } )
			{
				if ( (end - wall.Start).Length <= CornerTolerance || (end - wall.End).Length <= CornerTolerance )
				{
					continue;
				}

				if ( !LandsOn( wall, end, thickness ) )
				{
					continue;
				}

				var along = Vector2.Dot( end - wall.Start, wall.Direction );

				all.Add( along - half );
				all.Add( along + half );

				if ( !other.Baseboard )
				{
					continue;
				}

				skirted.Add( along - half );
				skirted.Add( along + half );
			}
		}

		return new LandingStations( all, skirted );
	}

	public static List<ArchOpening> SharedOpenings( ArchWall wall, ArchBuilding building, ArchRoom owner, float thickness )
	{
		return SharedOpenings( wall, building?.Rooms, owner, thickness );
	}

	public static List<ArchOpening> SharedOpenings( ArchWall wall, IEnumerable<ArchRoom> rooms, ArchRoom owner, float thickness )
	{
		var ghosts = new List<ArchOpening>();
		var direction = wall.Direction;

		foreach ( var room in rooms ?? Array.Empty<ArchRoom>() )
		{
			if ( MathF.Abs( room.BaseHeight - owner.BaseHeight ) > 0.5f )
			{
				continue;
			}

			foreach ( var other in room.Walls )
			{
				if ( ReferenceEquals( other, wall ) || other.Openings.Count == 0 || other.Length < 0.5f )
				{
					continue;
				}

				if ( !Collinear( wall, other, direction, thickness ) )
				{
					continue;
				}

				foreach ( var opening in other.Openings )
				{
					var centre = other.PointAt( opening.Offset );
					var along = Vector2.Dot( centre - wall.Start, direction );

					if ( along < -opening.Width || along > wall.Length + opening.Width )
					{
						continue;
					}

					ghosts.Add( new ArchOpening
					{
						Id = opening.Id,
						Preset = opening.Preset,
						Kind = opening.Kind,
						Offset = along,
						Width = opening.Width,
						Height = opening.Height,
						SillHeight = opening.SillHeight,
						Cased = false,
						Leaf = false
					} );
				}
			}
		}

		return ghosts;
	}

	public static List<ArchRoom> Breach( ArchPlan plan, ArchBuilding building, ArchRoom wing, ArchKit kit, Vector2 start, Vector2 end, float clear )
	{
		return Breach( plan, building.Rooms, wing, kit, start, end, clear, wing.Floor );
	}

	// floor is where the mouth stands: a rising walkway breaches the far wall at the storey it climbs to.
	// mayRemoveWall is false for an effect that has to be re-derivable: taking the wall away is not
	// something a later rebuild can undo, so a mouth that must follow its owner only ever cuts a hole.
	public static List<ArchRoom> Breach( ArchPlan plan, IEnumerable<ArchRoom> rooms, ArchRoom wing, ArchKit kit, Vector2 start, Vector2 end, float clear, int floor, bool mayRemoveWall = true )
	{
		var span = end - start;
		var length = span.Length;
		var thickness = kit.WallThickness;
		var opened = new List<ArchRoom>();

		if ( length < 1f )
		{
			return opened;
		}

		var kinds = ArchKinds.Load();
		var direction = span / length;

		foreach ( var room in rooms.ToList() )
		{
			if ( ReferenceEquals( room, wing ) || room.Floor != floor )
			{
				continue;
			}

			foreach ( var wall in plan.Filed( ArchKind.Wall, room, kinds ).OfType<ArchWall>().ToList() )
			{
				if ( !Along( wall, start, direction, thickness ) )
				{
					continue;
				}

				var a = Vector2.Dot( start - wall.Start, wall.Direction );
				var b = Vector2.Dot( end - wall.Start, wall.Direction );
				var from = MathF.Max( MathF.Min( a, b ), 0f );
				var to = MathF.Min( MathF.Max( a, b ), wall.Length );

				if ( to - from < 4f )
				{
					continue;
				}

				var height = ArchWallSection.Height( wall, room, kit );

				if ( !opened.Contains( room ) )
				{
					opened.Add( room );
				}

				if ( mayRemoveWall && clear >= height - 8f )
				{
					plan.Unfile( wall, kinds );
					Stub( plan, kinds, room, wall, 0f, from );
					Stub( plan, kinds, room, wall, to, wall.Length );
					continue;
				}

				wall.Openings.Add( new ArchOpening
				{
					Id = plan.AllocateId(),
					// The mouth belongs to what breached it, so losing that closes the hole.
					OwnerId = wing.Id,
					Preset = "doorway",
					Kind = OpeningKind.Archway,
					Offset = (from + to) * 0.5f,
					Width = to - from,
					Height = clear,
					Cased = false,
					Leaf = false
				} );
			}
		}

		return opened;
	}

	static bool Along( ArchWall wall, Vector2 point, Vector2 direction, float thickness )
	{
		return wall.Length >= 1f && Aligned( direction, point, wall, thickness );
	}

	static void Stub( ArchPlan plan, ArchKinds kinds, ArchRoom room, ArchWall source, float from, float to )
	{
		if ( to - from < 4f )
		{
			return;
		}

		var stub = new ArchWall
		{
			Id = plan.AllocateId(),
			Start = source.PointAt( from ),
			End = source.PointAt( to ),
			Height = source.Height,
			Thickness = source.Thickness,
			Exterior = source.Exterior,
			Baseboard = source.Baseboard,
			Cap = source.Cap,
			Palette = source.Palette
		};

		foreach ( var opening in source.Openings.Where( opening => opening.Left >= from && opening.Right <= to ) )
		{
			stub.Openings.Add( new ArchOpening
			{
				Id = plan.AllocateId(),
				Preset = opening.Preset,
				Kind = opening.Kind,
				Offset = opening.Offset - from,
				Width = opening.Width,
				Height = opening.Height,
				SillHeight = opening.SillHeight,
				Cased = opening.Cased,
				Leaf = opening.Leaf,
				FlipHinge = opening.FlipHinge
			} );
		}

		plan.File( ArchKind.Wall, room, stub, kinds );
	}

	static bool Collinear( ArchWall wall, ArchWall other, Vector2 direction, float thickness )
	{
		return Aligned( direction, wall.Start, other, thickness * 1.5f );
	}

	// The one collinear test: parallel within a hair, and standing on the same line within the tolerance.
	public static bool Aligned( Vector2 direction, Vector2 start, ArchWall other, float tolerance )
	{
		if ( other.Length < 0.5f || MathF.Abs( Vector2.Dot( direction, other.Direction ) ) < 0.999f )
		{
			return false;
		}

		var offset = other.Start - start;

		return MathF.Abs( offset.x * -direction.y + offset.y * direction.x ) <= tolerance;
	}

	public static bool PartyWallExists( ArchBuilding building, float baseHeight, Vector2 start, Vector2 end, float thickness )
	{
		return PartyWallExists( building?.Rooms, baseHeight, start, end, thickness );
	}

	public static bool PartyWallExists( IEnumerable<ArchRoom> rooms, float baseHeight, Vector2 start, Vector2 end, float thickness )
	{
		var span = end - start;
		var length = span.Length;

		if ( length < 0.5f )
		{
			return true;
		}

		var direction = span / length;

		foreach ( var room in rooms ?? Array.Empty<ArchRoom>() )
		{
			if ( MathF.Abs( room.BaseHeight - baseHeight ) > 0.5f )
			{
				continue;
			}

			foreach ( var other in room.Walls )
			{
				if ( other.Length < 0.5f || !Aligned( direction, start, other, thickness ) )
				{
					continue;
				}

				var a = Vector2.Dot( other.Start - start, direction );
				var b = Vector2.Dot( other.End - start, direction );
				var overlap = MathF.Min( MathF.Max( a, b ), length ) - MathF.Max( MathF.Min( a, b ), 0f );

				if ( overlap > length * 0.5f )
				{
					return true;
				}
			}
		}

		return false;
	}

	public static float AngleDegrees( ArchWall wall )
	{
		var direction = wall.Direction;

		return MathF.Atan2( direction.y, direction.x ).RadianToDegree();
	}

	// A wall whose whole span is claimed by a collinear twin in the room behind it - party wall or
	// shared boundary - so only one of them builds. The lower id wins when both are exterior.
	public static bool CoveredBy( ArchBuilding building, ArchRoom room, ArchWall wall )
	{
		if ( wall.Length < 0.5f )
		{
			return false;
		}

		var direction = wall.Direction;

		foreach ( var other in building.Rooms
			.Where( candidate => candidate.Floor == room.Floor && MathF.Abs( candidate.BaseHeight - room.BaseHeight ) < 0.5f && !ReferenceEquals( candidate, room ) )
			.SelectMany( candidate => candidate.Walls ) )
		{
			if ( !Aligned( direction, wall.Start, other, 0.5f ) )
			{
				continue;
			}

			var a = Vector2.Dot( other.Start - wall.Start, direction );
			var b = Vector2.Dot( other.End - wall.Start, direction );
			var from = MathF.Min( a, b );
			var to = MathF.Max( a, b );

			if ( from > 0.5f || to < wall.Length - 0.5f )
			{
				continue;
			}

			if ( other.Exterior != wall.Exterior ? !other.Exterior : other.Id >= wall.Id )
			{
				continue;
			}

			return true;
		}

		return false;
	}
}