Editor/Roof/ArchFaceCarve.cs

Editor-side static utility for roof face carving. It subtracts carve volumes from polygonal roof faces, splitting faces by planar and height bounds, cleaning up small artifacts and inserting stranded corner vertices.

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

namespace Sunless.Architecture;

// Hip edges run at 45 degrees, which no rectilinear grid can hold - so cuts run per face.
// The rim is not emitted here: ArchRoofGen.JoinedSlab already walls every edge one face owns.
static class ArchFaceCarve
{
	const float Grain = ArchCarve.Grain;

	public static List<List<Vector3>> Surround( IReadOnlyList<List<Vector3>> faces, IReadOnlyList<ArchCarveVolume> cuts, float thickness, float contact = 0f )
	{
		if ( cuts is not { Count: > 0 } )
		{
			return faces.ToList();
		}

		var kept = new List<List<Vector3>>();

		foreach ( var face in faces )
		{
			var pieces = new List<List<Vector3>> { new( face ) };

			foreach ( var cut in cuts )
			{
				pieces = Subtract( pieces, cut, thickness, contact );
			}

			kept.AddRange( pieces );
		}

		// A cut landing on a hip line strands a corner on the face nothing cut.
		Conform( kept );

		return kept;
	}

	static List<List<Vector3>> Subtract( List<List<Vector3>> pieces, ArchCarveVolume cut, float thickness, float contact )
	{
		var footprint = ArchFootprint.Wind( cut.Footprint.ToList() );

		if ( footprint.Count < 3 )
		{
			return pieces;
		}

		var bounds = Bounds( footprint, cut, thickness, contact ).ToList();
		var kept = new List<List<Vector3>>();

		foreach ( var piece in pieces )
		{
			var inside = piece;
			var outside = new List<List<Vector3>>();

			for ( var index = 0; index < bounds.Count && inside is not null; index++ )
			{
				Split( inside, bounds[index], out inside, out var beyond );

				if ( beyond is not null )
				{
					outside.Add( beyond );
				}
			}

			if ( inside is null )
			{
				kept.Add( piece );
				continue;
			}

			kept.AddRange( outside );
		}

		return kept;
	}

	// A deck RAKES, so the band is a boundary drawn across the face and not a yes or no about the whole of
	// it: the footprint's edges say where the cut stands in plan, and its floor and ceiling say where the
	// deck has climbed out of it. Judging by the overlap's extremes took a whole hip slope out for touching
	// the eave, and left the ridge over a shaft that plainly reached it.
	static IEnumerable<Func<Vector3, float>> Bounds( IReadOnlyList<Vector2> footprint, ArchCarveVolume cut, float thickness, float contact )
	{
		for ( var index = 0; index < footprint.Count; index++ )
		{
			var from = footprint[index];
			var to = footprint[(index + 1) % footprint.Count];

			yield return point => Cross( from, to, point );
		}

		yield return point => cut.Ceiling.At( Flat( point ) ) - point.z + contact;
		yield return point => point.z + thickness - cut.Floor.At( Flat( point ) );
	}

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

	// Sutherland-Hodgman on one boundary: signed depth per corner, crossing interpolated in three.
	static void Split( List<Vector3> piece, Func<Vector3, float> depth, out List<Vector3> inside, out List<Vector3> outside )
	{
		var within = new List<Vector3>();
		var beyond = new List<Vector3>();

		for ( var index = 0; index < piece.Count; index++ )
		{
			var current = piece[index];
			var next = piece[(index + 1) % piece.Count];
			var here = depth( current );
			var there = depth( next );

			if ( here >= -Grain ) within.Add( current );
			if ( here <= Grain ) beyond.Add( current );

			if ( here > Grain && there < -Grain || here < -Grain && there > Grain )
			{
				var crossing = current + (next - current) * (here / (here - there));

				within.Add( crossing );
				beyond.Add( crossing );
			}
		}

		inside = Real( within ) ? within : null;
		outside = Real( beyond ) ? beyond : null;
	}

	// A stranded corner must be inserted - a T left in becomes a wall across the deck.
	static void Conform( List<List<Vector3>> pieces )
	{
		var corners = pieces.SelectMany( piece => piece ).ToList();

		foreach ( var piece in pieces )
		{
			for ( var index = piece.Count - 1; index >= 0; index-- )
			{
				var from = piece[index];
				var to = piece[(index + 1) % piece.Count];
				var along = corners
					.Where( corner => Between( from, to, corner ) )
					.OrderBy( corner => (corner - from).Length )
					.ToList();

				for ( var step = along.Count - 1; step > 0; step-- )
				{
					if ( (along[step] - along[step - 1]).Length < Grain )
					{
						along.RemoveAt( step );
					}
				}

				piece.InsertRange( index + 1, along );
			}
		}
	}

	static bool Between( Vector3 from, Vector3 to, Vector3 point )
	{
		var span = to - from;
		var length = span.Length;

		if ( length < Grain )
		{
			return false;
		}

		var along = Vector3.Dot( point - from, span ) / length;

		if ( along < Grain || along > length - Grain )
		{
			return false;
		}

		return (from + span * (along / length) - point).Length < Grain;
	}

	static float Cross( Vector2 a, Vector2 b, Vector3 point )
	{
		var span = b - a;
		var length = span.Length;

		if ( length < 0.0001f )
		{
			return 0f;
		}

		return (span.x * (point.y - a.y) - span.y * (point.x - a.x)) / length;
	}

	// Narrower than anything the grid can author is clip noise, not deck. Measured against the FINEST grid
	// step rather than the grain: a band boundary crossing a rake almost level with it leaves a hairline
	// ribbon the length of the roof, and a ribbon is a face whose texture axis smears down the slope.
	static bool Real( List<Vector3> loop )
	{
		if ( loop.Count < 3 )
		{
			return false;
		}

		var flat = loop.Select( point => new Vector2( point.x, point.y ) ).ToList();
		var area = MathF.Abs( ArchFootprint.SignedArea( flat ) );
		var longest = 0f;

		for ( var index = 0; index < flat.Count; index++ )
		{
			longest = MathF.Max( longest, (flat[(index + 1) % flat.Count] - flat[index]).Length );
		}

		return longest > ArchGridService.FinestSize && area / longest > ArchGridService.FinestSize;
	}
}