Editor/Services/ArchRun.cs

Editor utility for generating and walking architectural 'runs' (linear or looped paths). It defines ArchStation (location along an edge), ArchRunPath (a path with utilities like length, sampling, nearest point, convexity) and ArchRun (break detection, sampling, splitting into non-broken run segments and emitting sections).

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

namespace Sunless.Architecture;

public readonly struct ArchStation
{
	public Vector2 Point { get; init; }
	public Vector2 Along { get; init; }
	public Vector2 Outward { get; init; }
	public float Height { get; init; }
	public float Distance { get; init; }

	public Vector3 Raised => new( Point.x, Point.y, Height );

	public static ArchStation Between( Vector2 from, Vector2 to, float along, float height, float distance )
	{
		var span = to - from;
		var length = span.Length;
		var direction = length < 0.05f ? Vector2.Zero : span / length;

		return new ArchStation
		{
			Point = from + direction * along,
			Along = direction,
			Outward = ArchRegion.Outward( from, to ),
			Height = height,
			Distance = distance
		};
	}
}

// Open ends are where the run was cut - a corner must not pass one.
public sealed class ArchRunPath
{
	public List<Vector2> Points { get; init; } = new();
	public bool Closed { get; init; }
	// Runs are level; anything that rakes is a swept profile, not a run.
	public float Height { get; init; }

	public int Edges => Closed ? Points.Count : Points.Count - 1;

	public Vector2 At( int index ) => Points[(index % Points.Count + Points.Count) % Points.Count];

	public bool Continues( int corner ) => Closed || (corner > 0 && corner < Points.Count);

	public List<Vector3> Raised() => Raised( Height );

	public List<Vector3> Raised( float height ) => Points.Select( point => new Vector3( point.x, point.y, height ) ).ToList();

	public static ArchRunPath Of( IReadOnlyList<Vector2> points, float height = 0f, bool closed = false )
	{
		return new ArchRunPath { Points = points.ToList(), Closed = closed, Height = height };
	}

	public static ArchRunPath Between( Vector2 from, Vector2 to, float height = 0f )
	{
		return new ArchRunPath { Points = new List<Vector2> { from, to }, Height = height };
	}

	// A surviving run carries the closing point its cut left behind - closed means "the first point
	// came back", and a run needs three corners before that is a ring rather than a doubled line.
	public static bool IsClosed( IReadOnlyList<Vector3> points )
	{
		return points.Count >= 4 && (points[0] - points[^1]).Length < 0.05f;
	}

	// One cut of a run, normalized: closure detected, the duplicated closing point dropped. The
	// generator, the ghost and the report all read this answer, so a moulding that a cut broke in
	// half and one it left whole agree about which ends are mitres and which are cut ends.
	public static ArchRunPath Normalized( IReadOnlyList<Vector3> raised, float height )
	{
		var closed = IsClosed( raised );

		return new ArchRunPath
		{
			Points = (closed ? raised.Take( raised.Count - 1 ) : raised)
				.Select( point => new Vector2( point.x, point.y ) )
				.ToList(),
			Closed = closed,
			Height = height
		};
	}

	public static ArchRunPath Normalized( IReadOnlyList<Vector2> points, float height )
	{
		return Normalized( points.Select( point => new Vector3( point.x, point.y, height ) ).ToList(), height );
	}

	public float Length
	{
		get
		{
			var total = 0f;

			for ( var edge = 0; edge < Edges; edge++ )
			{
				total += (At( edge + 1 ) - At( edge )).Length;
			}

			return total;
		}
	}

	public bool Station( float distance, out ArchStation station )
	{
		var travelled = 0f;

		for ( var edge = 0; edge < Edges; edge++ )
		{
			var from = At( edge );
			var to = At( edge + 1 );
			var length = (to - from).Length;

			if ( length >= 0.05f && travelled + length >= distance )
			{
				station = ArchStation.Between( from, to, distance - travelled, Height, distance );

				return true;
			}

			travelled += length;
		}

		station = default;

		return false;
	}

	public bool Nearest( Vector2 point, out ArchStation station, out float gap )
	{
		station = default;
		gap = float.MaxValue;

		var travelled = 0f;

		for ( var edge = 0; edge < Edges; edge++ )
		{
			var from = At( edge );
			var to = At( edge + 1 );
			var length = (to - from).Length;

			if ( length < 0.5f )
			{
				travelled += length;
				continue;
			}

			var direction = (to - from) / length;
			var along = Math.Clamp( Vector2.Dot( point - from, direction ), 0f, length );
			var closest = from + direction * along;
			var reach = (point - closest).Length;

			if ( reach < gap )
			{
				gap = reach;
				station = ArchStation.Between( from, to, along, Height, travelled + along );
			}

			travelled += length;
		}

		return gap < float.MaxValue;
	}

	// Outer loops wind counter-clockwise; holes wind the other way and read as reflex.
	public bool Convex( int corner )
	{
		if ( !Continues( corner ) )
		{
			return false;
		}

		var into = At( corner ) - At( corner - 1 );
		var outOf = At( corner + 1 ) - At( corner );

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

// The one place that walks a run for its breaks - abutments, stairwells, doorways.
public sealed class ArchRun
{
	readonly List<Vector2> source;
	readonly bool closed;
	readonly List<Func<Vector2, Vector2, bool>> breaks = new();

	float sample;
	float height;

	ArchRun( IEnumerable<Vector2> points, bool closed )
	{
		source = points.ToList();
		this.closed = closed;
	}

	public static ArchRun Around( IReadOnlyList<Vector2> loop ) => new( loop, true );

	public static ArchRun Along( IReadOnlyList<Vector2> path ) => new( path, false );

	public ArchRun BreakWhere( Func<Vector2, Vector2, bool> blocked )
	{
		if ( blocked is not null )
		{
			breaks.Add( blocked );
		}

		return this;
	}

	public ArchRun BreakOver( IReadOnlyList<ArchFloorCutout> holes )
	{
		if ( holes is { Count: > 0 } )
		{
			breaks.Add( ( a, b ) => holes.Any( hole => hole.Contains( (a + b) * 0.5f ) ) );
		}

		return this;
	}

	// Extra points so a break can land mid-wall; corners stay exact, so mitres are untouched.
	public ArchRun Sampled( float step )
	{
		sample = step;

		return this;
	}

	public ArchRun Level( float at )
	{
		height = at;

		return this;
	}

	public void Emit( ArchMesh canvas, ArchRunSection section, ArchBrush brush )
	{
		foreach ( var path in Resolve() )
		{
			section.Emit( canvas, path, brush );
		}
	}

	public List<ArchRunPath> Resolve()
	{
		var points = sample > 0.01f ? Subdivided() : source;

		if ( points.Count < 2 || (closed && points.Count < 3) )
		{
			return new List<ArchRunPath>();
		}

		var edges = closed ? points.Count : points.Count - 1;
		var blocked = new bool[edges];
		var any = false;

		for ( var edge = 0; edge < edges; edge++ )
		{
			var a = points[edge];
			var b = points[(edge + 1) % points.Count];

			blocked[edge] = breaks.Any( test => test( a, b ) );
			any |= blocked[edge];
		}

		if ( !any )
		{
			return new List<ArchRunPath> { Path( points.ToList(), closed ) };
		}

		if ( blocked.All( entry => entry ) )
		{
			return new List<ArchRunPath>();
		}

		// Walk from the first break so a stretch spanning the seam comes out whole.
		var first = closed ? Array.IndexOf( blocked, true ) : 0;
		var paths = new List<ArchRunPath>();
		var open = new List<Vector2>();

		for ( var step = 0; step < edges; step++ )
		{
			var edge = (first + step) % edges;

			if ( blocked[edge] )
			{
				Close( paths, open );
				open = new List<Vector2>();
				continue;
			}

			if ( open.Count == 0 )
			{
				open.Add( points[edge] );
			}

			open.Add( points[(edge + 1) % points.Count] );
		}

		Close( paths, open );

		return paths;
	}

	void Close( List<ArchRunPath> paths, List<Vector2> open )
	{
		if ( open.Count >= 2 )
		{
			paths.Add( Path( open, false ) );
		}
	}

	// Sampling is only for breaks - collinear sample points must not survive into the path.
	ArchRunPath Path( List<Vector2> points, bool closed )
	{
		return new ArchRunPath { Points = Straightened( points, closed ), Closed = closed, Height = height };
	}

	static List<Vector2> Straightened( List<Vector2> points, bool closed )
	{
		if ( points.Count < 3 )
		{
			return points;
		}

		var kept = new List<Vector2>();

		for ( var index = 0; index < points.Count; index++ )
		{
			// Open ends were cut, so they stay whatever angle they sit at.
			if ( !closed && (index == 0 || index == points.Count - 1) )
			{
				kept.Add( points[index] );
				continue;
			}

			var into = (points[index] - points[(index - 1 + points.Count) % points.Count]).Normal;
			var outOf = (points[(index + 1) % points.Count] - points[index]).Normal;

			if ( MathF.Abs( into.x * outOf.y - into.y * outOf.x ) > 0.001f )
			{
				kept.Add( points[index] );
			}
		}

		return kept.Count >= (closed ? 3 : 2) ? kept : points;
	}

	List<Vector2> Subdivided()
	{
		var points = new List<Vector2>();
		var edges = closed ? source.Count : source.Count - 1;

		for ( var edge = 0; edge < edges; edge++ )
		{
			var a = source[edge];
			var b = source[(edge + 1) % source.Count];
			var length = (b - a).Length;

			points.Add( a );

			if ( length < 0.05f )
			{
				continue;
			}

			var direction = (b - a) / length;

			foreach ( var offset in ArchDivide.AtMost( length, sample ).Inner )
			{
				points.Add( a + direction * offset );
			}
		}

		if ( !closed )
		{
			points.Add( source[^1] );
		}

		return points;
	}
}