Editor/Floor/ArchPlanks.cs

Editor-side utility that generates plank/board geometry for floors, ceilings, grilles and turned members. It computes runs across a 2D region, cuts them into boards with staggering, and emits prisms and polygons into an ArchMesh using a specified plane, brush and plank spec.

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

namespace Sunless.Architecture;

public readonly struct ArchPlankSpec
{
	public float Width { get; init; }
	public float Gap { get; init; }
	public float Thickness { get; init; }
	public float Length { get; init; }
	public float Top { get; init; }
	public float Yaw { get; init; }
	// A floor board's underside lies on the slab and is never seen; a ceiling slat's is the only face that
	// is, so which cap gets closed follows from which way the boards are read.
	public bool Soffit { get; init; }
	// The SECTION the board is swept with: four or fewer is the plain rectangle, more is the n-gon inscribed
	// in width x thickness - the same section a pillar of that many sides is turned in.
	public int Sides { get; init; }
}

// Where a plank's region coordinates land in space. A floor LIES in the plane it was drawn in; a grille STANDS
// in a wall's own frame, where the region's second axis climbs and the board's thickness runs out through the
// face - which is how a burglar bar and a floor board come out of one emitter instead of two.
public readonly struct ArchPlankPlane
{
	public Vector3 Right { get; init; }
	public Vector3 Up { get; init; }
	public Vector3 Depth { get; init; }

	public static ArchPlankPlane Flat => new()
	{
		Right = new Vector3( 1f, 0f, 0f ),
		Up = new Vector3( 0f, 1f, 0f ),
		Depth = new Vector3( 0f, 0f, 1f )
	};

	// Wall local space: x along, y across, z up.
	public static ArchPlankPlane Upright => new()
	{
		Right = new Vector3( 1f, 0f, 0f ),
		Up = new Vector3( 0f, 0f, 1f ),
		Depth = new Vector3( 0f, 1f, 0f )
	};

	public Vector3 At( Vector2 point, float depth ) => Right * point.x + Up * point.y + Depth * depth;
}

// Breaks over the same cutouts as the slab, so a stairwell reads as a hole in the boards.
public static class ArchPlanks
{
	// Two ends closer than this count as the same point.
	const float Grain = 1e-4f;

	public static void Fill(
		ArchMesh canvas,
		IReadOnlyList<IReadOnlyList<Vector2>> region,
		IReadOnlyList<ArchFloorCutout> cutouts,
		ArchPlankSpec spec,
		ArchBrush brush,
		ArchPlankPlane? standing = null )
	{
		var plane = standing ?? ArchPlankPlane.Flat;
		var loops = region?.Where( loop => loop is { Count: >= 3 } ).ToList();

		if ( loops is not { Count: > 0 } )
		{
			return;
		}

		var width = MathF.Max( 1f, spec.Width );
		var thickness = MathF.Max( 0.2f, spec.Thickness );

		var facing = Rotation.FromYaw( spec.Yaw );
		var along = new Vector2( facing.Forward.x, facing.Forward.y );
		var across = new Vector2( -along.y, along.x );

		// One lane grid and stagger phase over the whole region: per-loop origins made boards step at the join.
		Extent( loops.SelectMany( loop => loop ), along, across, out var min, out var max );

		var pitch = width + MathF.Max( 0f, spec.Gap );
		// Boards are fixed width, so only the count comes from the division.
		var lanes = ArchDivide.AtMost( max.y - min.y, pitch ).Count;
		var stagger = MathF.Max( width * 2f, spec.Length );
		var holes = cutouts ?? Array.Empty<ArchFloorCutout>();

		for ( var lane = 0; lane < lanes; lane++ )
		{
			var centre = min.y + lane * pitch + width * 0.5f;
			var offset = stagger * 0.5f * (lane % 2);

			foreach ( var run in Runs( loops, holes, along * min.x + across * centre, along * max.x + across * centre ) )
			{
				var from = MathX.Lerp( min.x, max.x, run.From );
				var to = MathX.Lerp( min.x, max.x, run.To );

				foreach ( var board in Cut( from, to, stagger, offset ) )
				{
					Lay( canvas, plane, along, across, board, centre, width, spec.Top, thickness, brush, spec.Soffit, spec.Sides );
				}
			}
		}
	}

	// Every end solves against the boundary it lands on, so boards meet exactly at a doorway.
	static List<(float From, float To)> Runs(
		IReadOnlyList<IReadOnlyList<Vector2>> region,
		IReadOnlyList<ArchFloorCutout> cutouts,
		Vector2 from,
		Vector2 to )
	{
		var cuts = new List<float> { 0f, 1f };

		foreach ( var loop in region )
		{
			cuts.AddRange( ArchFootprint.Crossings( loop, from, to ) );
		}

		foreach ( var cutout in cutouts )
		{
			cuts.AddRange( ArchFootprint.Crossings( Boundary( cutout ), from, to ) );
		}

		cuts.Sort();

		var runs = new List<(float From, float To)>();

		for ( var index = 0; index + 1 < cuts.Count; index++ )
		{
			var start = cuts[index];
			var end = cuts[index + 1];

			if ( end - start < Grain || !Covered( region, cutouts, Vector2.Lerp( from, to, (start + end) * 0.5f ) ) )
			{
				continue;
			}

			if ( runs.Count > 0 && start - runs[^1].To < Grain )
			{
				runs[^1] = (runs[^1].From, end);
				continue;
			}

			runs.Add( (start, end) );
		}

		return runs;
	}

	// One at a time: even-odd cancels the overlapping holes a flight cuts.
	static bool Covered( IReadOnlyList<IReadOnlyList<Vector2>> region, IReadOnlyList<ArchFloorCutout> cutouts, Vector2 point )
	{
		return ArchFootprint.Covered( region, cutouts, point );
	}

	static IReadOnlyList<Vector2> Boundary( ArchFloorCutout cutout )
	{
		return cutout.Outline();
	}

	static IEnumerable<(float From, float To)> Cut( float from, float to, float stagger, float offset )
	{
		var joint = MathF.Ceiling( (from - offset) / stagger ) * stagger + offset;

		while ( joint < to - stagger * 0.25f )
		{
			if ( joint - from > stagger * 0.25f )
			{
				yield return (from, joint - 0.2f);
				from = joint;
			}

			joint += stagger;
		}

		yield return (from, to);
	}

	static void Lay(
		ArchMesh canvas,
		ArchPlankPlane plane,
		Vector2 along,
		Vector2 across,
		(float From, float To) board,
		float centre,
		float width,
		float top,
		float thickness,
		ArchBrush brush,
		bool soffit,
		int sides )
	{
		if ( board.To - board.From < 1f )
		{
			return;
		}

		var half = width * 0.5f;

		if ( sides >= 5 )
		{
			Turned( canvas, plane, along, across, board, centre, half, top, thickness, sides, brush );

			return;
		}

		var lower = new List<Vector3>
		{
			Corner( plane, along, across, board.From, centre - half, top - thickness ),
			Corner( plane, along, across, board.To, centre - half, top - thickness ),
			Corner( plane, along, across, board.To, centre + half, top - thickness ),
			Corner( plane, along, across, board.From, centre + half, top - thickness )
		};

		var upper = new List<Vector3>
		{
			Corner( plane, along, across, board.From, centre - half, top ),
			Corner( plane, along, across, board.To, centre - half, top ),
			Corner( plane, along, across, board.To, centre + half, top ),
			Corner( plane, along, across, board.From, centre + half, top )
		};

		// A board read from BELOW is closed on both ends: its underside is the face you see, and its top is
		// only buried when something is actually against it - hung clear of the ceiling it was left as an open
		// edge round every slat. A floor board's underside lies ON the slab, so that one stays open.
		if ( soffit )
		{
			canvas.Prism( lower, upper, brush );

			return;
		}

		canvas.Prism( lower, upper, brush, cap: false );
		canvas.Polygon( upper, brush, Vector3.Dot( ArchMesh.Newell( upper ), plane.Depth ) < 0f );
	}

	// The section is swept along the run rather than stacked up it, so a round member is a length of dowel
	// and not a stack of rings. ArchFootprint answers the ring, exactly as it does for a round column.
	static void Turned(
		ArchMesh canvas,
		ArchPlankPlane plane,
		Vector2 along,
		Vector2 across,
		(float From, float To) board,
		float centre,
		float half,
		float top,
		float thickness,
		int sides,
		ArchBrush brush )
	{
		var section = ArchFootprint.Ellipse(
			new Vector2( centre - half, top - thickness ),
			new Vector2( centre + half, top ),
			sides );

		var start = section.Select( point => Corner( plane, along, across, board.From, point.x, point.y ) ).ToList();
		var end = section.Select( point => Corner( plane, along, across, board.To, point.x, point.y ) ).ToList();

		canvas.Prism( start, end, brush );
	}

	static Vector3 Corner( ArchPlankPlane plane, Vector2 along, Vector2 across, float length, float offset, float depth )
	{
		return plane.At( along * length + across * offset, depth );
	}

	static void Extent( IEnumerable<Vector2> region, Vector2 along, Vector2 across, out Vector2 min, out Vector2 max )
	{
		min = new Vector2( float.MaxValue, float.MaxValue );
		max = new Vector2( float.MinValue, float.MinValue );

		foreach ( var point in region )
		{
			var local = new Vector2( Vector2.Dot( point, along ), Vector2.Dot( point, across ) );

			min = new Vector2( MathF.Min( min.x, local.x ), MathF.Min( min.y, local.y ) );
			max = new Vector2( MathF.Max( max.x, local.x ), MathF.Max( max.y, local.y ) );
		}
	}
}