Editor/Roof/ArchRoofSkeleton.cs

Editor code that computes a hip/arch roof skeleton and produces mesh topology. It builds eaves/walls from 2D outlines, computes arrival times (L-infinity wavefront), events, wavefront bands, faces, creases and flashing lines, and returns an ArchRoofShape with faces, creases and flashing segments.

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

namespace Sunless.Architecture;

// A uniform-pitch hip roof is its footprint's straight skeleton - the L-infinity distance to the boundary,
// so a reflex corner throws a straight 45° valley. An edge dying into a taller wall is open: no face, cut off flush.
public sealed class ArchRoofShape
{
	public List<List<Vector3>> Faces { get; } = new();
	public List<(Vector3 From, Vector3 To)> Creases { get; } = new();
	public List<(Vector3 From, Vector3 To)> Flashing { get; } = new();

	public void Add( Vector3 from, Vector3 to )
	{
		if ( (to - from).Length > 1f )
		{
			Creases.Add( (from, to) );
		}
	}
}

public static class ArchRoofSkeleton
{
	const float Grain = 0.02f;

	readonly struct Eave
	{
		public Vector2 Origin { get; init; }
		public Vector2 Direction { get; init; }
		public Vector2 Normal { get; init; }
		public float Length { get; init; }
		public float Offset { get; init; }
	}

	// The nominal arrival over any point, public because the plane, the elevation and every seat query must
	// measure the deck the way the skeleton builds it.
	public static float Arrival( IReadOnlyList<IReadOnlyList<Vector2>> loops, Vector2 point )
	{
		var eaves = new List<Eave>();
		var walls = new List<Eave>();

		Collect( loops, null, eaves, walls );

		return eaves.Count == 0 ? 0f : Arrival( eaves, point );
	}

	// How deep the wavefront ever reaches - the ridge line's own arrival, so a cut asking for the top of a hip
	// gets the built peak rather than the bounding box's. An event is only a CANDIDATE, offered by any facing
	// pair however far apart, so the answer is the largest one the wave still covers.
	public static float Depth( IReadOnlyList<IReadOnlyList<Vector2>> loops )
	{
		var eaves = new List<Eave>();
		var walls = new List<Eave>();

		Collect( loops, null, eaves, walls );

		if ( eaves.Count == 0 )
		{
			return 0f;
		}

		var events = Events( eaves, walls );

		for ( var index = events.Count - 1; index > 0; index-- )
		{
			if ( Wave( loops, eaves, (events[index - 1] + events[index]) * 0.5f ).Count > 0 )
			{
				return events[index];
			}
		}

		return 0f;
	}

	public static ArchRoofShape Shape( IReadOnlyList<IReadOnlyList<Vector2>> loops, Func<Vector2, Vector2, bool> open, float baseHeight, float slope )
	{
		var shape = new ArchRoofShape();
		var eaves = new List<Eave>();
		var walls = new List<Eave>();

		Collect( loops, open, eaves, walls );

		if ( eaves.Count == 0 )
		{
			return shape;
		}

		// An abutted joint gets the same capping a ridge does - flashing up the wall.
		foreach ( var wall in walls )
		{
			var from = wall.Origin;
			var to = wall.Origin + wall.Direction * wall.Length;

			shape.Flashing.Add( (
				new Vector3( from.x, from.y, baseHeight + slope * Arrival( eaves, from ) ),
				new Vector3( to.x, to.y, baseHeight + slope * Arrival( eaves, to ) )) );
		}

		var events = Events( eaves, walls );

		// Every event comes from a facing pair, so an outline with no two anti-parallel edges - a triangle, an
		// ellipse - offers none and would silently build nothing at all.
		if ( events.Count <= 1 )
		{
			Log.Warning( $"Architecture: a hip roof over {eaves.Count} eaves found no facing pair, so no deck was built - square the outline or use another style." );

			return shape;
		}

		for ( var index = 0; index + 1 < events.Count; index++ )
		{
			var from = events[index];
			var to = events[index + 1];

			if ( to - from < Grain )
			{
				continue;
			}

			var sample = (from + to) * 0.5f;
			var wave = Wave( loops, eaves, sample );

			if ( wave.Count == 0 )
			{
				break;
			}

			foreach ( var loop in wave )
			{
				Band( shape, loop, walls, sample, from, to, baseHeight, slope );
			}
		}

		return shape;
	}

	static void Collect( IReadOnlyList<IReadOnlyList<Vector2>> loops, Func<Vector2, Vector2, bool> open, List<Eave> eaves, List<Eave> walls )
	{
		foreach ( var loop in loops )
		{
			if ( loop is not { Count: >= 3 } )
			{
				continue;
			}

			for ( var index = 0; index < loop.Count; index++ )
			{
				var origin = loop[index];
				var span = loop[(index + 1) % loop.Count] - origin;
				var length = span.Length;

				if ( length < Grain )
				{
					continue;
				}

				var direction = span / length;
				var normal = new Vector2( -direction.y, direction.x );

				var eave = new Eave
				{
					Origin = origin,
					Direction = direction,
					Normal = normal,
					Length = length,
					Offset = Vector2.Dot( normal, origin )
				};

				var target = open is not null && open( origin + direction * (length * 0.5f), -normal ) ? walls : eaves;

				target.Add( eave );
			}
		}
	}

	// Arrival = L-infinity distance; an eave behind the point belongs to another wing and is dropped.
	static float Arrival( List<Eave> eaves, Vector2 point )
	{
		var best = float.MaxValue;

		foreach ( var eave in eaves )
		{
			var perpendicular = Vector2.Dot( eave.Normal, point ) - eave.Offset;

			if ( perpendicular < -Grain )
			{
				continue;
			}

			var along = Vector2.Dot( point - eave.Origin, eave.Direction );
			var lateral = MathF.Max( 0f, MathF.Max( -along, along - eave.Length ) );

			best = MathF.Min( best, MathF.Max( perpendicular, lateral ) );
		}

		return best == float.MaxValue ? 0f : best;
	}

	// Facing eaves close at half their gap; against a wall, at the full gap - all topology events happen there.
	static List<float> Events( List<Eave> eaves, List<Eave> walls )
	{
		var times = new List<float> { 0f };

		void Consider( float closes )
		{
			if ( closes > Grain && !times.Any( time => MathF.Abs( time - closes ) < Grain ) )
			{
				times.Add( closes );
			}
		}

		for ( var first = 0; first < eaves.Count; first++ )
		{
			for ( var second = first + 1; second < eaves.Count; second++ )
			{
				if ( Vector2.Dot( eaves[first].Normal, eaves[second].Normal ) < -0.99f )
				{
					Consider( -(eaves[first].Offset + eaves[second].Offset) * 0.5f );
				}
			}

			foreach ( var wall in walls )
			{
				if ( Vector2.Dot( eaves[first].Normal, wall.Normal ) < -0.99f )
				{
					Consider( -wall.Offset - eaves[first].Offset );
				}
			}
		}

		times.Sort();

		return times;
	}

	static List<List<Vector2>> Wave( IReadOnlyList<IReadOnlyList<Vector2>> loops, List<Eave> eaves, float time )
	{
		var points = loops.SelectMany( loop => loop ).ToList();
		var xs = Axis( points.Select( point => point.x ), time );
		var ys = Axis( points.Select( point => point.y ), time );
		var columns = xs.Count - 1;
		var covered = new bool[columns * (ys.Count - 1)];

		for ( var ix = 0; ix < columns; ix++ )
		{
			for ( var iy = 0; iy + 1 < ys.Count; iy++ )
			{
				var centre = new Vector2( (xs[ix] + xs[ix + 1]) * 0.5f, (ys[iy] + ys[iy + 1]) * 0.5f );

				covered[iy * columns + ix] = Arrival( eaves, centre ) >= time + Grain
					&& ArchFootprint.Encloses( loops, centre );
			}
		}

		return ArchFootprint.Trace( xs, ys, covered );
	}

	static List<float> Axis( IEnumerable<float> coordinates, float time )
	{
		var values = new List<float>();

		foreach ( var coordinate in coordinates )
		{
			values.Add( coordinate - time );
			values.Add( coordinate );
			values.Add( coordinate + time );
		}

		return values.Select( ArchGridService.Fine ).Distinct().OrderBy( value => value ).ToList();
	}

	// Vertices ride edge bisectors so the band sweeps analytically; a collapse that drops out is a hip.
	static void Band( ArchRoofShape shape, List<Vector2> loop, List<Eave> walls, float sample, float from, float to, float baseHeight, float slope )
	{
		if ( loop.Count < 3 )
		{
			return;
		}

		var speed = new float[loop.Count];
		var velocity = new Vector2[loop.Count];

		for ( var index = 0; index < loop.Count; index++ )
		{
			speed[index] = Standing( walls, loop[index], loop[(index + 1) % loop.Count] ) ? 0f : 1f;
		}

		for ( var index = 0; index < loop.Count; index++ )
		{
			var behind = (index - 1 + loop.Count) % loop.Count;

			velocity[index] = Bisector(
				Inward( loop[index] - loop[behind] ), speed[behind],
				Inward( loop[(index + 1) % loop.Count] - loop[index] ), speed[index] );
		}

		for ( var index = 0; index < loop.Count; index++ )
		{
			if ( speed[index] <= 0f )
			{
				continue;
			}

			var next = (index + 1) % loop.Count;

			var face = new List<Vector3>
			{
				At( loop[index], velocity[index], sample, from, baseHeight, slope ),
				At( loop[next], velocity[next], sample, from, baseHeight, slope ),
				At( loop[next], velocity[next], sample, to, baseHeight, slope ),
				At( loop[index], velocity[index], sample, to, baseHeight, slope )
			};

			Emit( shape.Faces, face );
		}

		Creases( shape, loop, speed, velocity, sample, from, to, baseHeight, slope );
	}

	// Convex vertex = hip; reflex = valley (flashed flat, not capped); lines meeting at band end = ridge.
	static void Creases( ArchRoofShape shape, List<Vector2> loop, float[] speed, Vector2[] velocity, float sample, float from, float to, float baseHeight, float slope )
	{
		var reach = to - sample;

		for ( var index = 0; index < loop.Count; index++ )
		{
			var behind = (index - 1 + loop.Count) % loop.Count;

			if ( speed[behind] <= 0f || speed[index] <= 0f || !Convex( loop, index ) )
			{
				continue;
			}

			shape.Add(
				At( loop[index], velocity[index], sample, from, baseHeight, slope ),
				At( loop[index], velocity[index], sample, to, baseHeight, slope ) );
		}

		for ( var index = 0; index < loop.Count; index++ )
		{
			var next = (index + 1) % loop.Count;

			if ( speed[index] <= 0f || !Closes( loop, speed, index, reach ) )
			{
				continue;
			}

			shape.Add(
				At( loop[index], velocity[index], sample, to, baseHeight, slope ),
				At( loop[next], velocity[next], sample, to, baseHeight, slope ) );
		}
	}

	static bool Closes( List<Vector2> loop, float[] speed, int index, float reach )
	{
		var normal = Inward( loop[(index + 1) % loop.Count] - loop[index] );
		var offset = Vector2.Dot( normal, loop[index] );

		for ( var other = index + 1; other < loop.Count; other++ )
		{
			if ( speed[other] <= 0f )
			{
				continue;
			}

			var facing = Inward( loop[(other + 1) % loop.Count] - loop[other] );

			if ( Vector2.Dot( normal, facing ) > -0.99f )
			{
				continue;
			}

			if ( MathF.Abs( offset + Vector2.Dot( facing, loop[other] ) + reach * 2f ) < Grain * 4f )
			{
				return true;
			}
		}

		return false;
	}

	static bool Convex( List<Vector2> loop, int index )
	{
		var into = loop[index] - loop[(index - 1 + loop.Count) % loop.Count];
		var outOf = loop[(index + 1) % loop.Count] - loop[index];

		return into.x * outOf.y - into.y * outOf.x > 0f;
	}

	// An edge on an abutment's line IS that wall - it stands still while the loop marches past.
	static bool Standing( List<Eave> walls, Vector2 from, Vector2 to )
	{
		if ( walls.Count == 0 )
		{
			return false;
		}

		var normal = Inward( to - from );
		var offset = Vector2.Dot( normal, from );

		return walls.Any( wall => Vector2.Dot( wall.Normal, normal ) > 0.99f && MathF.Abs( wall.Offset - offset ) < Grain );
	}

	static void Emit( List<List<Vector3>> faces, List<Vector3> face )
	{
		var kept = new List<Vector3>();

		foreach ( var point in face )
		{
			if ( kept.Count > 0 && (kept[^1] - point).Length < Grain )
			{
				continue;
			}

			kept.Add( point );
		}

		if ( kept.Count > 2 && (kept[0] - kept[^1]).Length < Grain )
		{
			kept.RemoveAt( kept.Count - 1 );
		}

		if ( kept.Count >= 3 )
		{
			faces.Add( kept );
		}
	}

	static Vector3 At( Vector2 origin, Vector2 velocity, float sample, float time, float baseHeight, float slope )
	{
		var point = origin + velocity * (time - sample);

		return new Vector3( point.x, point.y, baseHeight + slope * time );
	}

	static Vector2 Inward( Vector2 span )
	{
		var direction = span.Normal;

		return new Vector2( -direction.y, direction.x );
	}

	// Keeps pace with both edges: normal . velocity = that edge's speed (1 eave, 0 wall).
	static Vector2 Bisector( Vector2 first, float firstSpeed, Vector2 second, float secondSpeed )
	{
		var determinant = first.x * second.y - first.y * second.x;

		if ( MathF.Abs( determinant ) < 0.01f )
		{
			return first * firstSpeed;
		}

		return new Vector2(
			(firstSpeed * second.y - secondSpeed * first.y) / determinant,
			(secondSpeed * first.x - firstSpeed * second.x) / determinant );
	}
}