Editor/Services/ArchRun.cs
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 - 1);
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 };
}
public static bool IsClosed( IReadOnlyList<Vector3> points ) {
return points.Count >= 4 && (points[0] - points[^1]).Length < 0.05f;
}
// Detect closure, drop the duplicated closing point.
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;
}
}