Editor/Roof/ArchRoofSkeleton.cs
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;
}
// The wavefront is found on a CELL GRID, and a grid only tells the truth about edges that lie on it - so it
// is laid on the footprint's OWN bearing rather than the world's. Read straight, a house turned off the axes
// came back as a staircase of axis-aligned boxes down its diagonal, each one carrying its own little hip.
// Coverage is still asked in world terms, because the eaves and the outline are where they really stand.
static List<List<Vector2>> Wave( IReadOnlyList<IReadOnlyList<Vector2>> loops, List<Eave> eaves, float time ) {
ArchFootprint.Bearing( loops, out var bearing );
var pivot = ArchFootprint.Pivot( loops );
var framed = loops.Select( loop => ArchFootprint.Turned( loop, pivot, -bearing ) ).ToList();
var points = framed.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 = ArchFootprint.Turned(
new Vector2( (xs[ix] + xs[ix + 1]) * 0.5f, (ys[iy] + ys[iy + 1]) * 0.5f ), pivot, bearing );
covered[iy * columns + ix] = Arrival( eaves, centre ) >= time + Grain
&& ArchFootprint.Encloses( loops, centre );
}
}
return ArchFootprint.Trace( xs, ys, covered )
.Select( loop => ArchFootprint.Turned( loop, pivot, bearing ) )
.ToList();
}
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 );
}
values.Sort();
// Near-duplicates COLLAPSE rather than snapping onto a rung. On the world axes the two were the same
// thing, because every coordinate already stood on one; in a turned footprint's own frame nothing does,
// so snapping put the two ends of a hip's last band on neighbouring rungs and its apex a third of an
// inch out - which the engine's own weld then read as a degenerate face and threw the whole hip end away.
var kept = new List<float>();
foreach ( var value in values ) {
if ( kept.Count > 0 && value - kept[^1] <= Grain ) {
continue;
}
kept.Add( value );
}
return kept;
}
// 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 );
}
}