editor/AdvancedMappingTools/Procedural/WallNetwork.cs

Editor-side geometry solver for wall networks. Takes wall paths with thickness ranges, finds where their ends meet (corner miter/bevel/butt joins, T-stops on a face, junctions of three or more ends, and mid-wall crossings), then produces per-wall WallSection cuts, hub polygons and neighbour sets for the mesh builder.

namespace AdvancedMappingTools;

public enum WallJoinStyle
{
	Miter,
	Bevel,
	Butt,
	Overlap
}

/// <summary>
/// One stretch of a wall between two distances along its line. Without corners an end is
/// square, with corners it ends on exactly these points of its left and right face.
/// </summary>
public sealed class WallSection
{
	public float From { get; set; }
	public float To { get; set; }
	public Vector3? FromLeft { get; set; }
	public Vector3? FromRight { get; set; }
	public Vector3? ToLeft { get; set; }
	public Vector3? ToRight { get; set; }

	/// <summary>
	/// Points on the faces where another wall ends, so a welded mesh can connect to them.
	/// </summary>
	public List<Vector3> LeftStops { get; set; } = [];
	public List<Vector3> RightStops { get; set; } = [];

	/// <summary>
	/// Points between the two corners of an end, from right to left, where it stops on a curved face.
	/// </summary>
	public List<Vector3> FromEdge { get; set; } = [];
	public List<Vector3> ToEdge { get; set; } = [];
}

/// <summary>
/// One wall as the solver sees it. <see cref="Left"/> and <see cref="Right"/> are how far
/// its faces lie to the left of the line, the right one is negative.
/// </summary>
public sealed record WallInput( IReadOnlyList<Vector3> Path, bool Closed, float Left, float Right, WallJoinStyle Join, float Bottom, float Top );

/// <summary>
/// What the solver found for one wall: how it is cut and which walls it touches.
/// </summary>
public sealed class WallSolution
{
	public List<WallSection> Sections { get; } = [];
	public List<List<Vector3>> Hubs { get; } = [];
	public HashSet<int> Neighbours { get; } = [];
}

/// <summary>
/// Works out how walls meet, from nothing but their lines and thicknesses. Where ends meet at a
/// corner, both are cut on the line through the crossing points of their faces, so
/// nothing overlaps and nothing stays open, at any angle and with any two thicknesses. An end
/// that reaches the side of another wall stops on that face. Of two walls crossing each other
/// one runs through and the other is cut in two. Three walls that meet without a straight
/// pair share a junction piece. Curved walls take part with the direction of their ends.
/// The walls are never merged: this only says where each of them ends.
/// </summary>
public static class WallNetwork
{
	public const float Tolerance = 2.0f;

	const float MaxMitre = 4.0f;
	const float Straight = 0.985f;
	const float EndClearance = 1.0f;

	sealed class Line
	{
		public WallInput Input;
		public PathTrack Track;
		public Vector2[] Center;
		public float[] Along;
		public float Length;
		public readonly List<(float Along, Vector3 Left, Vector3 Right, bool Forward, List<Vector3> Edge)> Cuts = [];
		public readonly List<(float Along, bool Left, Vector3 Point)> Stops = [];
		public (Vector3 Left, Vector3 Right, List<Vector3> Edge)? Start;
		public (Vector3 Left, Vector3 Right, List<Vector3> Edge)? End;

		public Vector2[] Face( float side ) => [.. Enumerable.Range( 0, Track.Count ).Select( i => Flat( Track.Place( i, side ) ) )];

		public Vector2 Tangent( float along )
		{
			var (index, _) = Track.Locate( along );
			return Flat( Track.Frames[index + 1].Position - Track.Frames[index].Position ).Normal;
		}

		public float Height => Track.Frames[0].Position.z;
	}

	/// <summary>
	/// A wall end at a meeting point, seen from there: its direction leads away into the wall.
	/// </summary>
	sealed class Arm
	{
		public int Wall;
		public Vector2 Point;
		public Vector2 Direction;
		public float LeftWidth;
		public float RightWidth;
		public Vector2? Left;
		public Vector2? Right;
		public List<Vector3> Edge = [];
		public bool AtStart;
		public Action<Vector3, Vector3, List<Vector3>> Apply;

		public Vector2 Normal => new( -Direction.y, Direction.x );
		public Vector2 LeftPoint => Point + Normal * LeftWidth;
		public Vector2 RightPoint => Point - Normal * RightWidth;
		public float Width => MathF.Max( LeftWidth + RightWidth, 0.01f );
	}

	static Vector2 Flat( Vector3 v ) => new( v.x, v.y );

	static float Cross( Vector2 a, Vector2 b ) => a.x * b.y - a.y * b.x;

	static Vector2? Meet( Vector2 a, Vector2 da, Vector2 b, Vector2 db )
	{
		var d = Cross( da, db );

		if ( MathF.Abs( d ) < 1e-4f ) return null;

		return a + da * (Cross( b - a, db ) / d);
	}

	public static List<WallSolution> Solve( IReadOnlyList<WallInput> walls )
	{
		var lines = walls.Select( Prepare ).ToList();
		var solutions = walls.Select( _ => new WallSolution() ).ToList();
		var nodes = new List<(Vector2 Point, float Height, List<Arm> Arms)>();

		void AddArm( int wall, bool atStart )
		{
			var line = lines[wall];
			var along = atStart ? 0.0f : line.Length;
			var point = atStart ? line.Center[0] : line.Center[^1];
			var height = line.Height;
			var direction = atStart ? Flat( line.Center[1] - line.Center[0] ).Normal : Flat( line.Center[^2] - line.Center[^1] ).Normal;

			var arm = new Arm
			{
				Wall = wall,
				Point = point,
				Direction = direction,
				LeftWidth = atStart ? line.Input.Left : -line.Input.Right,
				RightWidth = atStart ? -line.Input.Right : line.Input.Left,
				AtStart = atStart,
				Apply = ( left, right, edge ) =>
				{
					if ( atStart ) line.Start = (left, right, edge);
					else line.End = (right, left, [.. edge.AsEnumerable().Reverse()]);
				}
			};

			foreach ( var node in nodes )
			{
				if ( node.Point.Distance( point ) > Tolerance ) continue;
				if ( !Overlaps( line, lines[node.Arms[0].Wall] ) ) continue;

				node.Arms.Add( arm );
				return;
			}

			nodes.Add( (point, height, [arm]) );
		}

		for ( var w = 0; w < lines.Count; w++ )
		{
			if ( lines[w].Input.Closed ) continue;

			AddArm( w, true );
			AddArm( w, false );
		}

		foreach ( var (point, _, arms) in nodes )
		{
			if ( arms.Count == 1 )
			{
				ButtOntoWall( lines, solutions, arms[0], point );
				continue;
			}

			foreach ( var arm in arms )
				foreach ( var other in arms.Where( x => x != arm ) )
					solutions[arm.Wall].Neighbours.Add( other.Wall );

			var style = arms.Select( x => lines[x.Wall].Input.Join ).Min();

			if ( arms.Count == 2 ) SolveCorner( arms[0], arms[1], style, lines, solutions );
			else SolveJunction( arms, lines, solutions );
		}

		FindCrossings( lines, solutions );

		for ( var w = 0; w < lines.Count; w++ )
			Assemble( lines[w], solutions[w] );

		return solutions;
	}

	static Line Prepare( WallInput input )
	{
		var track = PathTrack.Along( input.Path, input.Closed );
		var center = Enumerable.Range( 0, track.Count ).Select( i => Flat( track.Frames[i].Position ) ).ToArray();

		return new Line
		{
			Input = input,
			Track = track,
			Center = center,
			Along = [.. track.Frames.Select( x => x.Distance )],
			Length = track.Length
		};
	}

	static bool Overlaps( Line a, Line b ) => a.Input.Bottom < b.Input.Top - 0.01f && b.Input.Bottom < a.Input.Top - 0.01f;

	static Vector3 Lift( Vector2 p ) => new( p.x, p.y, 0.0f );

	static void Finish( Arm arm )
	{
		if ( arm.Left is Vector2 left && arm.Right is Vector2 right )
			arm.Apply( Lift( left ), Lift( right ), arm.Edge );
	}

	/// <summary>
	/// Two ends meeting. Going counter clockwise from the first wall to the second, the left
	/// face of the first looks at the right face of the second.
	/// </summary>
	static void SolveCorner( Arm a, Arm b, WallJoinStyle style, List<Line> lines, List<WallSolution> solutions )
	{
		if ( style == WallJoinStyle.Overlap ) return;

		// One straight line: the ends meet on a square cut.
		if ( a.Direction.Dot( b.Direction ) < -Straight ) return;

		var turn = Cross( a.Direction, b.Direction );

		if ( MathF.Abs( turn ) < 0.02f ) return;

		// The sector inside the turn lies left of whichever wall comes first going counter clockwise.
		if ( turn < 0.0f ) (a, b) = (b, a);

		if ( style == WallJoinStyle.Butt )
		{
			SolveButt( a, b, lines );
			return;
		}

		var inner = Meet( a.LeftPoint, a.Direction, b.RightPoint, b.Direction );
		var outer = Meet( a.RightPoint, a.Direction, b.LeftPoint, b.Direction );

		if ( inner is not Vector2 c1 || outer is not Vector2 c2 ) return;

		a.Left = c1;
		b.Right = c1;

		var limit = MaxMitre * MathF.Max( a.Width, b.Width ) * 0.5f;

		if ( style == WallJoinStyle.Miter && c2.Distance( a.Point ) <= limit )
		{
			a.Right = c2;
			b.Left = c2;
		}
		else
		{
			// A bevel: both outer faces stop at the meeting point and a piece closes the gap.
			a.Right = a.RightPoint;
			b.Left = b.LeftPoint;

			solutions[a.Wall].Hubs.Add( [Lift( c1 ), Lift( a.RightPoint ), Lift( b.LeftPoint )] );
		}

		Finish( a );
		Finish( b );
	}

	/// <summary>
	/// The older wall runs on to the outside of the corner, the newer one stops on its face.
	/// </summary>
	static void SolveButt( Arm a, Arm b, List<Line> lines )
	{
		var (through, stopping) = a.Wall < b.Wall ? (a, b) : (b, a);

		var onLeft = stopping.Direction.Dot( through.Normal ) > 0.0f;
		var nearSide = onLeft ? through.LeftPoint : through.RightPoint;
		var farPoint = through.Direction.Dot( stopping.Normal ) < 0.0f ? stopping.LeftPoint : stopping.RightPoint;

		stopping.Left = Meet( stopping.LeftPoint, stopping.Direction, nearSide, through.Direction );
		stopping.Right = Meet( stopping.RightPoint, stopping.Direction, nearSide, through.Direction );
		through.Left = Meet( through.LeftPoint, through.Direction, farPoint, stopping.Direction );
		through.Right = Meet( through.RightPoint, through.Direction, farPoint, stopping.Direction );

		Finish( through );
		Finish( stopping );

		// The end of the other wall lands on this face, so the face gets corners there.
		var line = lines[through.Wall];
		var along = through.AtStart ? 0.0f : line.Length;
		var wallLeft = onLeft == through.AtStart;

		line.Stops.Add( (along, wallLeft, Lift( stopping.Left.Value )) );
		line.Stops.Add( (along, wallLeft, Lift( stopping.Right.Value )) );
	}

	/// <summary>
	/// Three or more ends. Going around the point, neighbours meet where their faces cross. Two
	/// ends in one line have no such point on their shared side: they stop square. What is left
	/// in the middle is a piece of its own, with a corner for each end around it.
	/// </summary>
	static void SolveJunction( List<Arm> arms, List<Line> lines, List<WallSolution> solutions )
	{
		var sorted = arms.OrderBy( x => MathF.Atan2( x.Direction.y, x.Direction.x ) ).ToList();

		for ( var i = 0; i < sorted.Count; i++ )
		{
			var a = sorted[i];
			var b = sorted[(i + 1) % sorted.Count];

			if ( Cross( a.Direction, b.Direction ) <= 0.02f ) continue;

			if ( Meet( a.LeftPoint, a.Direction, b.RightPoint, b.Direction ) is Vector2 c )
			{
				a.Left = c;
				b.Right = c;
			}
		}

		foreach ( var arm in sorted )
		{
			arm.Left ??= arm.Right is Vector2 right ? right + arm.Normal * arm.Width : arm.LeftPoint;
			arm.Right ??= arm.Left.Value - arm.Normal * arm.Width;

			Finish( arm );
		}

		var corners = new List<Vector3>();

		void Corner( Vector2 point )
		{
			var lifted = Lift( point );

			if ( corners.Count == 0 || corners[^1].Distance( lifted ) > 0.01f )
				corners.Add( lifted );
		}

		foreach ( var arm in sorted )
		{
			Corner( arm.Right.Value );
			Corner( arm.Left.Value );
		}

		if ( corners.Count > 1 && corners[^1].Distance( corners[0] ) < 0.01f )
			corners.RemoveAt( corners.Count - 1 );

		if ( corners.Count >= 3 ) solutions[sorted[0].Wall].Hubs.Add( corners );
	}

	/// <summary>
	/// An end on its own may reach the side of another wall.
	/// </summary>
	static void ButtOntoWall( List<Line> lines, List<WallSolution> solutions, Arm arm, Vector2 point )
	{
		var best = -1;
		var bestAlong = 0.0f;
		var bestDistance = float.MaxValue;

		for ( var w = 0; w < lines.Count; w++ )
		{
			if ( w == arm.Wall || !Overlaps( lines[w], lines[arm.Wall] ) ) continue;

			var line = lines[w];
			var (distance, along) = Closest( line, point );
			var reach = MathF.Max( line.Input.Left, -line.Input.Right ) + Tolerance;

			if ( distance > reach || distance >= bestDistance ) continue;
			if ( !line.Input.Closed && (along < EndClearance || along > line.Length - EndClearance) ) continue;

			best = w;
			bestAlong = along;
			bestDistance = distance;
		}

		if ( best < 0 ) return;

		StopOnFace( lines[arm.Wall], lines[best], arm, bestAlong );

		solutions[arm.Wall].Neighbours.Add( best );
		solutions[best].Neighbours.Add( arm.Wall );
	}

	static void StopOnFace( Line wall, Line through, Arm arm, float along )
	{
		var tangent = through.Tangent( along );
		var left = arm.Direction.Dot( new Vector2( -tangent.y, tangent.x ) ) > 0.0f;
		var face = through.Face( left ? through.Input.Left : through.Input.Right );

		arm.Left = Hit( face, through.Input.Closed, arm.LeftPoint, arm.Direction ) ?? arm.LeftPoint;
		arm.Right = Hit( face, through.Input.Closed, arm.RightPoint, arm.Direction ) ?? arm.RightPoint;
		arm.Edge = Between( face, arm.Right.Value, arm.Left.Value );

		Finish( arm );

		through.Stops.Add( (along, left, Lift( arm.Left.Value )) );
		through.Stops.Add( (along, left, Lift( arm.Right.Value )) );
	}

	/// <summary>
	/// The corners of a chain between two points on it, in the order from the first to the second.
	/// On a curved face an end has to follow them, a straight cut would reach into the wall.
	/// </summary>
	static List<Vector3> Between( Vector2[] chain, Vector2 from, Vector2 to )
	{
		float Place( Vector2 point )
		{
			var best = (Distance: float.MaxValue, At: 0.0f);

			for ( var i = 0; i + 1 < chain.Length; i++ )
			{
				var d = chain[i + 1] - chain[i];
				var t = d.LengthSquared > 0.0f ? Math.Clamp( (point - chain[i]).Dot( d ) / d.LengthSquared, 0.0f, 1.0f ) : 0.0f;
				var distance = (chain[i] + d * t).Distance( point );

				if ( distance < best.Distance ) best = (distance, i + t);
			}

			return best.At;
		}

		var a = Place( from );
		var b = Place( to );
		var points = new List<Vector3>();

		for ( var i = (int)MathF.Ceiling( MathF.Min( a, b ) + 0.001f ); i <= (int)MathF.Floor( MathF.Max( a, b ) - 0.001f ); i++ )
			points.Add( Lift( chain[i] ) );

		if ( a > b ) points.Reverse();

		return points;
	}

	/// <summary>
	/// Where a line through a point meets a chain of segments, the crossing closest to the point.
	/// </summary>
	static Vector2? Hit( Vector2[] chain, bool closed, Vector2 point, Vector2 direction )
	{
		var best = default( Vector2? );
		var bestDistance = float.MaxValue;

		for ( var i = 0; i + 1 < chain.Length; i++ )
		{
			var a = chain[i];
			var b = chain[i + 1];
			var along = b - a;
			var d = Cross( direction, along );

			if ( MathF.Abs( d ) < 1e-5f ) continue;

			var t = Cross( a - point, along ) / d;
			var u = Cross( a - point, direction ) / d;

			// The ends of the chain reach a little further, a wall may stop right at the corner of another.
			if ( u < -0.05f || u > 1.05f ) continue;

			var distance = MathF.Abs( t );

			if ( distance >= bestDistance ) continue;

			best = point + direction * t;
			bestDistance = distance;
		}

		return best;
	}

	static (float Distance, float Along) Closest( Line line, Vector2 point )
	{
		var best = (Distance: float.MaxValue, Along: 0.0f);

		for ( var i = 0; i + 1 < line.Center.Length; i++ )
		{
			var a = line.Center[i];
			var b = line.Center[i + 1];
			var d = b - a;
			var t = d.LengthSquared > 0.0f ? Math.Clamp( (point - a).Dot( d ) / d.LengthSquared, 0.0f, 1.0f ) : 0.0f;
			var on = a + d * t;
			var distance = on.Distance( point );

			if ( distance < best.Distance )
				best = (distance, line.Along[i] + (line.Along[i + 1] - line.Along[i]) * t);
		}

		return best;
	}

	/// <summary>
	/// Two walls crossing in their middle: the thicker runs through, the other is cut around it.
	/// </summary>
	static void FindCrossings( List<Line> lines, List<WallSolution> solutions )
	{
		for ( var a = 0; a < lines.Count; a++ )
		{
			for ( var b = a + 1; b < lines.Count; b++ )
			{
				if ( !Overlaps( lines[a], lines[b] ) ) continue;

				foreach ( var (alongA, alongB, point) in Crossings( lines[a], lines[b] ) )
				{
					var aThick = lines[a].Input.Left - lines[a].Input.Right;
					var bThick = lines[b].Input.Left - lines[b].Input.Right;
					var cutB = aThick > bThick - 0.01f || lines[b].Input.Closed;

					if ( cutB && lines[b].Input.Closed ) continue;

					var (through, cut, atThrough, atCut, cutIndex) = cutB ? (lines[a], lines[b], alongA, alongB, b) : (lines[b], lines[a], alongB, alongA, a);

					CutAround( through, cut, cutIndex, atThrough, atCut, point );

					solutions[a].Neighbours.Add( b );
					solutions[b].Neighbours.Add( a );
				}
			}
		}
	}

	static IEnumerable<(float AlongA, float AlongB, Vector2 Point)> Crossings( Line a, Line b )
	{
		for ( var i = 0; i + 1 < a.Center.Length; i++ )
		{
			for ( var j = 0; j + 1 < b.Center.Length; j++ )
			{
				var p = a.Center[i];
				var r = a.Center[i + 1] - p;
				var q = b.Center[j];
				var s = b.Center[j + 1] - q;
				var d = Cross( r, s );

				if ( MathF.Abs( d ) < 1e-5f ) continue;

				var t = Cross( q - p, s ) / d;
				var u = Cross( q - p, r ) / d;

				if ( t < 0.0f || t > 1.0f || u < 0.0f || u > 1.0f ) continue;

				var alongA = a.Along[i] + (a.Along[i + 1] - a.Along[i]) * t;
				var alongB = b.Along[j] + (b.Along[j + 1] - b.Along[j]) * u;

				// Ends are handled as ends.
				var endA = !a.Input.Closed && (alongA < Tolerance + a.Input.Left || alongA > a.Length - Tolerance - a.Input.Left);
				var endB = !b.Input.Closed && (alongB < Tolerance + b.Input.Left || alongB > b.Length - Tolerance - b.Input.Left);

				if ( endA || endB ) continue;

				yield return (alongA, alongB, p + r * t);
			}
		}
	}

	static void CutAround( Line through, Line cut, int cutIndex, float atThrough, float atCut, Vector2 point )
	{
		var tangent = cut.Tangent( atCut );

		var ahead = new Arm { Wall = cutIndex, Point = point, Direction = tangent, LeftWidth = cut.Input.Left, RightWidth = -cut.Input.Right };
		var behind = new Arm { Wall = cutIndex, Point = point, Direction = -tangent, LeftWidth = -cut.Input.Right, RightWidth = cut.Input.Left };

		ahead.Apply = ( left, right, edge ) => cut.Cuts.Add( (atCut, left, right, true, edge) );
		behind.Apply = ( left, right, edge ) => cut.Cuts.Add( (atCut, right, left, false, [.. edge.AsEnumerable().Reverse()]) );

		StopOnFace( cut, through, ahead, atThrough );
		StopOnFace( cut, through, behind, atThrough );
	}

	/// <summary>
	/// How far along a wall's line a point on one of its faces lies.
	/// </summary>
	static float AlongOf( Line line, Vector3 point ) => Closest( line, Flat( point ) ).Along;

	static void Assemble( Line line, WallSolution solution )
	{
		var section = new WallSection { From = 0.0f, To = line.Length };

		if ( line.Start is (Vector3 startLeft, Vector3 startRight, var startEdge) )
		{
			section.FromLeft = startLeft;
			section.FromRight = startRight;
			section.FromEdge = startEdge;
			section.From = MathF.Max( AlongOf( line, startLeft ), AlongOf( line, startRight ) );
		}

		var ends = line.End;
		var cuts = line.Cuts.OrderBy( x => x.Along ).ThenBy( x => x.Forward ).ToList();

		for ( var i = 0; i + 1 < cuts.Count; i += 2 )
		{
			var stop = cuts[i];
			var start = cuts[i + 1];

			section.To = MathF.Min( AlongOf( line, stop.Left ), AlongOf( line, stop.Right ) );
			section.ToLeft = stop.Left;
			section.ToRight = stop.Right;
			section.ToEdge = stop.Edge;
			solution.Sections.Add( section );

			section = new WallSection
			{
				From = MathF.Max( AlongOf( line, start.Left ), AlongOf( line, start.Right ) ),
				To = line.Length,
				FromLeft = start.Left,
				FromRight = start.Right,
				FromEdge = start.Edge
			};
		}

		if ( ends is (Vector3 endLeft, Vector3 endRight, var endEdge) )
		{
			section.ToLeft = endLeft;
			section.ToRight = endRight;
			section.ToEdge = endEdge;
			section.To = MathF.Min( AlongOf( line, endLeft ), AlongOf( line, endRight ) );
		}

		solution.Sections.Add( section );

		foreach ( var (along, left, point) in line.Stops )
		{
			var owner = solution.Sections.FirstOrDefault( x => along >= x.From - 0.01f && along <= x.To + 0.01f );

			if ( owner is null ) continue;

			if ( left ) owner.LeftStops.Add( point );
			else owner.RightStops.Add( point );
		}
	}
}