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.
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 ) );
}
}
}