Editor generator for procedural arches. ArchSettings holds the shape (round, pointed, horseshoe, ogee, custom profile, and so on), rise, ring section, keystone, piers, bases, imposts and wall options; ArchGenerator turns that into a PartPlan of separate parts (piers, ring or individual voussoirs, keystone, spandrel walls) using MeshKit sweeps along the opening curve. CurvedArchGenerator is the same arch row built along a PathCurve or spiral.
namespace AdvancedMappingTools;
/// <summary>
/// The first entries match <see cref="ArchStyle"/> number for number.
/// </summary>
public enum ArchShape
{
Flat,
[Title( "Round / Roman" )] Round,
[Title( "Pointed / Gothic" )] Pointed,
Segmental,
Tudor,
Lancet,
Horseshoe,
Elliptical,
Ogee,
[Title( "Multi Centered" )] ThreeCentered,
[Title( "Custom Profile" )] Custom,
[Title( "Full Circle" )] Circular
}
public enum ArchOuterShape
{
[Title( "Same As Opening" )] Same,
[Title( "Flat Top" )] Flat,
Round,
Pointed
}
public enum ArchRingSection
{
Rectangular,
Chamfered,
Rounded,
Stepped,
Custom
}
public enum PierShape
{
Square,
Round,
Octagonal
}
public class ArchSettings : GeneratorSettings
{
/// <summary>
/// The clear length between the outer piers, set by the tool.
/// </summary>
public float Length { get; set; } = 128.0f;
[Property, Group( "Arch" ), WideMode]
public ArchShape Shape { get; set; } = ArchShape.Round;
[Property, Group( "Arch" ), Range( 0, 4096, slider: false ), WideMode, Description( "Height of the arch over its spring line. Zero uses the natural height of the shape." )]
public float Rise { get; set; } = 0.0f;
[Property, Group( "Arch" ), Title( "Spring Height" ), Range( 0, 4096, slider: false ), WideMode, Description( "Where the arch starts above the ground. A full circle starts here at its lowest point." )]
public float SpringHeight { get; set; } = 96.0f;
[Property, Group( "Arch" ), Range( 1, 512, slider: false ), WideMode, Description( "Of the ring, measured across it." )]
public float Thickness { get; set; } = 16.0f;
[Property, Group( "Arch" ), Range( 1, 1024, slider: false ), WideMode]
public float Depth { get; set; } = 24.0f;
[Property, Group( "Arch" ), Range( 4, 64, slider: false ), WideMode]
public int Segments { get; set; } = 16;
[Property, Group( "Arch" ), Title( "Connected Arches" ), Range( 1, 64, slider: false ), WideMode, Description( "Arches in a row, sharing the piers between them." )]
public int Bays { get; set; } = 1;
[Property, Group( "Ring" ), Title( "Outer Shape" ), WideMode, Description( "The ring follows the opening, or has a shape of its own on the outside." )]
public ArchOuterShape OuterShape { get; set; } = ArchOuterShape.Same;
[Property, Group( "Ring" ), Title( "Cross Section" ), WideMode]
public ArchRingSection Section { get; set; } = ArchRingSection.Rectangular;
[Property, HideIf( nameof( Section ), ArchRingSection.Rectangular ), Group( "Ring" ), Title( "Edge Size" ), Range( 0, 128, slider: false ), WideMode, Description( "Of a chamfered, rounded or stepped cross section." )]
public float EdgeSize { get; set; } = 3.0f;
[Property, ShowIf( nameof( Section ), ArchRingSection.Rounded ), Group( "Ring" ), Title( "Profile Subdivisions" ), Range( 1, 16, slider: false ), WideMode, Description( "Steps around a rounded edge." )]
public int ProfileSubdivisions { get; set; } = 3;
[Property, Group( "Ring" ), Title( "Smooth Surface" ), WideMode, Description( "Curved faces shade smoothly. Off shows every segment." )]
public bool SmoothSurface { get; set; } = true;
[Property, Group( "Ring" ), Title( "Separate Stones" ), WideMode, Description( "Builds the ring from single stones, each one its own part." )]
public bool Voussoirs { get; set; } = false;
[Property, ShowIf( nameof( Voussoirs ), true ), Group( "Ring" ), Title( "Stone Count" ), Range( 3, 63, slider: false ), WideMode]
public int Stones { get; set; } = 13;
[Property, Group( "Keystone" ), Title( "Keystone" ), WideMode]
public bool Keystone { get; set; } = true;
[Property, ShowIf( nameof( Keystone ), true ), Group( "Keystone" ), Title( "Keystone Width" ), Range( 1, 512, slider: false ), WideMode]
public float KeystoneWidth { get; set; } = 20.0f;
[Property, ShowIf( nameof( Keystone ), true ), Group( "Keystone" ), Title( "Above The Ring" ), Range( 0, 256, slider: false ), WideMode]
public float KeystoneRise { get; set; } = 6.0f;
[Property, ShowIf( nameof( Keystone ), true ), Group( "Keystone" ), Title( "Below The Ring" ), Range( 0, 256, slider: false ), WideMode]
public float KeystoneDrop { get; set; } = 3.0f;
[Property, ShowIf( nameof( Keystone ), true ), Group( "Keystone" ), Title( "Standing Proud" ), Range( 0, 64, slider: false ), WideMode, Description( "How far it stands out from both faces of the ring." )]
public float KeystoneProud { get; set; } = 2.0f;
[Property, Group( "Supports" ), Title( "Piers" ), WideMode, Description( "A full circle stands by itself and has none." )]
public bool Piers { get; set; } = true;
[Property, Group( "Supports" ), Title( "Pier Shape" ), WideMode]
public PierShape PierShape { get; set; } = PierShape.Square;
[Property, Group( "Supports" ), Title( "Pier Width" ), Range( 1, 512, slider: false ), WideMode, Description( "Also the distance between connected arches." )]
public float PierWidth { get; set; } = 24.0f;
[Property, Group( "Supports" ), Title( "Bases" ), WideMode]
public bool Bases { get; set; } = true;
[Property, ShowIf( nameof( Bases ), true ), Group( "Supports" ), Title( "Base Height" ), Range( 1, 256, slider: false ), WideMode]
public float BaseHeight { get; set; } = 12.0f;
[Property, ShowIf( nameof( Bases ), true ), Group( "Supports" ), Title( "Base Overhang" ), Range( 0, 64, slider: false ), WideMode]
public float BaseOverhang { get; set; } = 3.0f;
[Property, Group( "Supports" ), Title( "Imposts" ), WideMode, Description( "A block on the pier where the arch starts." )]
public bool Imposts { get; set; } = true;
[Property, ShowIf( nameof( Imposts ), true ), Group( "Supports" ), Title( "Impost Height" ), Range( 1, 256, slider: false ), WideMode]
public float ImpostHeight { get; set; } = 8.0f;
[Property, ShowIf( nameof( Imposts ), true ), Group( "Supports" ), Title( "Impost Overhang" ), Range( 0, 64, slider: false ), WideMode]
public float ImpostOverhang { get; set; } = 2.0f;
[Property, Group( "Wall" ), Title( "Wall Above" ), WideMode, Description( "Fills the corners next to the arch up to a straight top." )]
public bool WallAbove { get; set; } = true;
[Property, ShowIf( nameof( WallAbove ), true ), Group( "Wall" ), Title( "Wall Top" ), Range( 8, 8192, slider: false ), WideMode, Description( "Height of the top above the ground. It stays above the arch." )]
public float WallTop { get; set; } = 192.0f;
[Property, ShowIf( nameof( WallAbove ), true ), Group( "Wall" ), Title( "Wall Depth" ), Range( 1, 1024, slider: false ), WideMode, Description( "A wall thinner than the ring lets the ring stand out." )]
public float WallDepth { get; set; } = 20.0f;
[Property, ShowIf( nameof( Shape ), ArchShape.Custom ), Group( "Custom" ), Title( "Profile Points" ), WideMode, Description( "With the Custom Profile shape: the left half as across,up pairs from 0,0 at the spring point to 1,1 at the crown, split by semicolons." )]
public string CustomProfile { get; set; } = "0,0; 0.04,0.5; 0.3,0.82; 1,1";
[Property, ShowIf( nameof( Section ), ArchRingSection.Custom ), Group( "Custom" ), Title( "Section Points" ), WideMode, Description( "With the Custom cross section: across,up pairs from 0 to 1, split by semicolons. Across runs through the depth, up from the opening outward." )]
public string CustomSection { get; set; } = "0.15,0; 0.85,0; 0.85,0.35; 1,0.35; 1,1; 0,1; 0,0.35; 0.15,0.35";
[Property, Group( "Materials" ), Title( "Ring" ), ResourceType( "vmat" ), WideMode]
public string RingMaterial { get; set; }
[Property, Group( "Materials" ), Title( "Every Second Stone" ), ResourceType( "vmat" ), WideMode, Description( "A second material for striped arches. Empty uses the ring material." )]
public string AlternateMaterial { get; set; }
[Property, ShowIf( nameof( Keystone ), true ), Group( "Materials" ), Title( "Keystone" ), ResourceType( "vmat" ), WideMode]
public string KeystoneMaterial { get; set; }
[Property, Group( "Materials" ), Title( "Piers" ), ResourceType( "vmat" ), WideMode]
public string PierMaterial { get; set; }
[Property, Group( "Materials" ), Title( "Bases And Imposts" ), ResourceType( "vmat" ), WideMode]
public string TrimMaterial { get; set; }
[Property, ShowIf( nameof( WallAbove ), true ), Group( "Materials" ), Title( "Wall" ), ResourceType( "vmat" ), WideMode]
public string WallMaterial { get; set; }
}
public sealed class CurvedArchSettings : ArchSettings
{
/// <summary>
/// The line the arches stand on, set by the tool.
/// </summary>
public PathCurve Path { get; set; } = PathCurve.FromPoints( [Vector3.Zero, new Vector3( 256.0f, 0.0f, 0.0f )] );
[Property, Group( "Path" ), WideMode]
public PathLayout Layout { get; set; } = PathLayout.Drawn;
[Property, Group( "Path" ), Title( "Spiral Turns" ), Range( 0.1f, 32, slider: false ), WideMode]
public float Turns { get; set; } = 0.5f;
[Property, Group( "Path" ), Title( "Spiral Clockwise" ), WideMode]
public bool Clockwise { get; set; } = false;
[Property, Group( "Path" ), Title( "Climb" ), Range( -4096, 4096, slider: false ), WideMode, Description( "Height added along the path, on top of the height of its points." )]
public float Climb { get; set; } = 0.0f;
[Property, Group( "Path" ), Title( "Path Detail" ), Range( 1, 256, slider: false ), WideMode, Description( "Longest straight piece along the path." )]
public float PathDetail { get; set; } = 8.0f;
}
/// <summary>
/// An arch of independent parts: piers with base and impost, the ring, its keystone or single
/// stones, and the wall in the corners next to it. Ring and keystone are one cross section
/// carried along the curve of the opening, so each has a closed surface all the way round and
/// a neighbour only ever touches it. Nothing overlaps, and any part can be replaced by hand.
/// </summary>
public class ArchGenerator : IPartGenerator
{
const string RingCategory = "ring";
const string KeystoneCategory = "keystone";
const string PierCategory = "pier";
const string TrimCategory = "trim";
const string WallCategory = "wall";
const int RoundSides = 16;
const float SmallestThickness = 0.5f;
const float WallClearance = 1.0f;
const float StoneDepthRange = 0.1f;
const float FlatArchSplay = 0.5f;
const float PointedOuterRise = 1.2f;
const float FanSpacing = 1.0f;
const float OutwardStep = 0.7f;
public virtual string Id => "arch";
public virtual string Title => "Arch";
public virtual Type SettingsType => typeof( ArchSettings );
public IReadOnlyList<string> SeedNames => [RingCategory, KeystoneCategory, PierCategory, TrimCategory, WallCategory];
/// <summary>
/// The line the arches stand on, or nothing for a straight row along X.
/// </summary>
protected virtual PathTrack Route( ArchSettings settings ) => null;
/// <summary>
/// The curve of one ring, at any distance from the opening.
/// </summary>
sealed class RingCurve( Vector2[] inner, Vector2[] outer, float thickness, float spring, bool turned = false )
{
readonly Dictionary<float, Vector2[]> _curves = [];
public int Last => inner.Length - 1;
public int Crown => inner.Length / 2;
/// <summary>
/// This curve turned over at the spring line. It runs the other way, so outside stays outside.
/// </summary>
public RingCurve TurnedOver => new( inner, outer, thickness, spring, true );
public Vector2[] At( float offset )
{
if ( _curves.TryGetValue( offset, out var known ) ) return known;
var curve = offset == 0.0f ? [.. inner]
: outer is null ? MeshKit.OffsetCurve( inner, offset )
: [.. inner.Zip( outer, ( a, b ) => a + (b - a) * (offset / thickness) )];
if ( offset > 0.0f ) Rest( curve );
if ( turned )
{
Array.Reverse( curve );
for ( var i = 0; i < curve.Length; i++ )
curve[i] = curve[i].WithY( spring * 2.0f - curve[i].y );
}
return _curves[offset] = curve;
}
/// <summary>
/// A ring stands on the spring line, even where the curve leaves it at an angle.
/// </summary>
void Rest( Vector2[] curve )
{
Clip( curve );
Array.Reverse( curve );
Clip( curve );
Array.Reverse( curve );
}
void Clip( Vector2[] curve )
{
if ( curve[0].y >= spring )
{
var step = curve[1] - curve[0];
// Level ends belong to a flat arch, which keeps its height there.
if ( step.y > 0.001f )
{
curve[0] -= step * ((curve[0].y - spring) / step.y);
curve[0] = curve[0].WithY( spring );
}
return;
}
var first = Array.FindIndex( curve, x => x.y >= spring );
if ( first <= 0 ) return;
var from = curve[first - 1];
var to = curve[first];
var point = from + (to - from) * ((spring - from.y) / (to.y - from.y));
for ( var i = 0; i < first; i++ )
curve[i] = point;
}
}
/// <summary>
/// Everything the parts of one arch share.
/// </summary>
sealed class Build( ArchSettings settings, GenContext context, PathTrack track, float thickness )
{
public ArchSettings S => settings;
public GenContext Context => context;
public float Thickness => thickness;
public float Top { get; set; }
public Vector3 Place( float x, float y, float z )
{
if ( track is null )
return new Vector3( x, y, z );
// Rings without piers and horseshoe bulges may extend past the path.
// Clamping them onto its endpoint collapses faces into vertical lines.
if ( !track.Closed && x < 0 )
return track.PlaceAt( 0, y, z ) + track.Frames[0].Forward * x;
if ( !track.Closed && x > track.Length )
return track.PlaceAt( track.Length, y, z ) + track.Frames[^1].Forward * (x - track.Length);
return track.PlaceAt( x, y, z );
}
public bool Straight => track is null;
/// <summary>
/// A box, or the piece of one that follows the path.
/// </summary>
public void Block( MeshKit kit, float x0, float x1, float y0, float y1, float z0, float z1 )
{
kit.Hex( Place( x0, y0, z0 ), Place( x1, y0, z0 ), Place( x1, y1, z0 ), Place( x0, y1, z0 ),
Place( x0, y0, z1 ), Place( x1, y0, z1 ), Place( x1, y1, z1 ), Place( x0, y1, z1 ) );
}
/// <summary>
/// Carry a section along a curve between two stations. A section is across, out from the opening.
/// </summary>
public void Carry( MeshKit kit, RingCurve curve, int from, int to, IReadOnlyList<Vector2> section )
{
var rings = section.Select( p =>
{
var line = curve.At( p.y );
return Enumerable.Range( from, to - from + 1 ).Select( i => Place( line[i].x, p.x, line[i].y ) ).ToArray();
} ).ToArray();
kit.Sweep( rings, false );
}
}
public PartPlan Generate( GeneratorSettings settings, GenContext context )
{
var s = (ArchSettings)settings;
var root = context.Group( "root", Title, "Building", RingCategory );
var track = Route( s );
var circle = s.Shape == ArchShape.Circular;
var piers = s.Piers && !circle;
var gap = MathF.Max( s.PierWidth, 1.0f );
var bays = Math.Max( s.Bays, 1 );
var origin = track is not null && piers ? gap : 0.0f;
var clear = track is null ? s.Length : track.Length - (piers ? gap * 2.0f : 0.0f);
var span = (clear - gap * (bays - 1)) / bays;
if ( span < 4.0f ) return root;
// An arch that bulges past its spring points needs room to its neighbour.
var bulge = MathF.Max( -Opening( s, s.Shape, 0.0f, span, s.SpringHeight, RiseOf( s, span ) ).Min( p => p.x ), 0.0f );
if ( bays > 1 && gap < (bulge + SmallestThickness) * 2.0f )
{
gap = (bulge + SmallestThickness) * 2.0f;
span = (clear - gap * (bays - 1)) / bays;
if ( span < 4.0f ) return root;
}
var rise = RiseOf( s, span );
var spring = circle ? s.SpringHeight + span * 0.5f : s.SpringHeight;
var thickness = RingThickness( s, span, rise, spring, circle ? float.MaxValue : bays > 1 ? gap * 0.5f : piers ? gap : float.MaxValue );
var build = new Build( s, context, track, thickness );
var section = Section( s, thickness );
var rim = section.Max( x => x.y );
var reach = rim + (s.Keystone ? s.KeystoneRise : 0.0f);
build.Top = MathF.Max( s.WallTop, Curve( s, 0.0f, span, spring, rise, thickness, circle ).At( reach ).Max( x => x.y ) + WallClearance );
if ( piers )
{
for ( var i = 0; i <= bays; i++ )
{
var right = origin + i * (span + gap);
var id = $"pier{i:00}";
Pier( root.Add( context.Group( id, $"Pier {i + 1}", "Pier", PierCategory ) ), build, id, right - gap, right, spring );
}
}
for ( var b = 0; b < bays; b++ )
{
var id = $"bay{b:00}";
var bay = root.Add( context.Group( id, $"Arch {b + 1}", "Bay", RingCategory ) );
var x0 = origin + b * (span + gap);
var x1 = x0 + span;
var curve = Curve( s, x0, x1, spring, rise, thickness, circle );
var outline = curve.At( rim );
// A bay ends on its pier or halfway between two. An arch that stands alone ends with its ring.
var left = b > 0 ? x0 - gap * 0.5f : piers || circle ? x0 - gap : outline.Min( p => p.x );
var right = b < bays - 1 ? x1 + gap * 0.5f : piers || circle ? x1 + gap : outline.Max( p => p.x );
Ring( bay, build, id, curve, section, rim, left, right, spring );
}
return root;
}
static float RiseOf( ArchSettings s, float span )
{
if ( s.Shape == ArchShape.Flat ) return 0.0f;
if ( s.Shape == ArchShape.Circular ) return span * 0.5f;
if ( s.Rise > 0.0f ) return s.Rise;
if ( s.Shape == ArchShape.Custom ) return span * 0.5f;
return MeshKit.ArchRise( (ArchStyle)(int)s.Shape, span );
}
/// <summary>
/// The thickness a ring may have: where its foot leaves the opening has to stay on its share of the pier.
/// </summary>
static float RingThickness( ArchSettings s, float span, float rise, float spring, float reach )
{
var thickness = MathF.Max( s.Thickness, SmallestThickness );
var curve = Curve( s, 0.0f, span, spring, rise, thickness, s.Shape == ArchShape.Circular );
var bulge = -curve.At( 0.0f ).Min( p => p.x );
var foot = -curve.At( thickness ).Min( p => p.x );
// A bulge stays clear of the end of the bay. A foot may stand right on it.
var room = bulge > 0.0f ? reach - WallClearance : reach;
return foot > room ? MathF.Max( thickness * (room - bulge) / (foot - bulge), SmallestThickness ) : thickness;
}
static RingCurve Curve( ArchSettings s, float x0, float x1, float spring, float rise, float thickness, bool circle )
{
// A full circle keeps its outside inside the bay, the other half of it hangs from here.
var inset = circle ? thickness : 0.0f;
var inner = Opening( s, s.Shape, x0 + inset, x1 - inset, spring, circle ? rise - inset : rise );
var segments = inner.Length - 1;
var outer = s.OuterShape switch
{
ArchOuterShape.Flat => Boxed( inner.Length, x0 - thickness, x1 + thickness, spring, spring + rise + thickness ),
ArchOuterShape.Round => Opening( s, ArchShape.Round, x0 - thickness, x1 + thickness, spring, rise + thickness, segments ),
ArchOuterShape.Pointed => Opening( s, ArchShape.Pointed, x0 - thickness, x1 + thickness, spring, rise * PointedOuterRise + thickness, segments ),
_ => s.Shape == ArchShape.Flat ? Splayed( inner, thickness, spring ) : null
};
return new RingCurve( inner, circle ? null : outer, thickness, spring );
}
/// <summary>
/// The top of a flat arch: its stones spread upward, so it wedges itself in between its piers.
/// </summary>
static Vector2[] Splayed( Vector2[] inner, float thickness, float spring )
{
var first = inner[0].x;
var last = inner[^1].x;
var spread = thickness * FlatArchSplay;
return [.. inner.Select( p => new Vector2( first - spread + (last - first + spread * 2.0f) * (p.x - first) / (last - first), spring + thickness ) )];
}
/// <summary>
/// Points along three sides of a box around an opening, as many as the opening has.
/// </summary>
static Vector2[] Boxed( int count, float x0, float x1, float spring, float top )
{
var height = top - spring;
var width = x1 - x0;
var total = height * 2.0f + width;
var last = count - 1;
var result = new Vector2[count];
for ( var i = 0; i < count; i++ )
{
var along = total * i / last;
result[i] = along < height ? new Vector2( x0, spring + along )
: along < height + width ? new Vector2( x0 + along - height, top )
: new Vector2( x1, top - (along - height - width) );
}
// The box needs its corners, whatever the spacing of the points.
var corner = Math.Clamp( (int)MathF.Round( height / total * last ), 1, last / 2 );
result[corner] = new Vector2( x0, top );
result[last - corner] = new Vector2( x1, top );
return result;
}
static Vector2[] Opening( ArchSettings s, ArchShape shape, float x0, float x1, float spring, float rise, int segments = -1 )
{
var count = segments > 0 ? segments : s.Segments;
switch ( shape )
{
case ArchShape.Flat:
{
var even = Math.Max( count, 2 ) / 2 * 2;
return [.. Enumerable.Range( 0, even + 1 ).Select( i => new Vector2( x0 + (x1 - x0) * i / even, spring ) )];
}
case ArchShape.Custom:
return MeshKit.ArchCurve( x0, x1, spring, rise, s.CustomProfile, count ) ?? MeshKit.ArchCurve( x0, x1, spring, rise, ArchStyle.Round, count );
case ArchShape.Circular:
return MeshKit.ArchCurve( x0, x1, spring, rise, ArchStyle.Round, count );
default:
return MeshKit.ArchCurve( x0, x1, spring, rise, (ArchStyle)(int)shape, count );
}
}
/// <summary>
/// The cross section of the ring as across, out from the opening, counter clockwise.
/// </summary>
static List<Vector2> Section( ArchSettings s, float thickness )
{
var h = s.Depth * 0.5f;
var t = thickness;
var e = Math.Clamp( s.EdgeSize, 0.0f, MathF.Min( h, t * 0.5f ) - 0.01f );
switch ( s.Section )
{
case ArchRingSection.Chamfered when e > 0.0f:
return [new( -h + e, 0 ), new( h - e, 0 ), new( h, e ), new( h, t - e ), new( h - e, t ), new( -h + e, t ), new( -h, t - e ), new( -h, e )];
case ArchRingSection.Rounded when e > 0.0f:
{
var steps = Math.Max( s.ProfileSubdivisions, 1 );
var result = new List<Vector2>();
void Corner( float cy, float cr, float start )
{
for ( var i = 0; i <= steps; i++ )
{
var angle = (start + 90.0f * i / steps).DegreeToRadian();
result.Add( new Vector2( cy + MathF.Cos( angle ) * e, cr + MathF.Sin( angle ) * e ) );
}
}
Corner( h - e, e, 270.0f );
Corner( h - e, t - e, 0.0f );
Corner( -h + e, t - e, 90.0f );
Corner( -h + e, e, 180.0f );
return result;
}
case ArchRingSection.Stepped when e > 0.0f:
return [new( -h + e, 0 ), new( h - e, 0 ), new( h - e, t * 0.5f ), new( h, t * 0.5f ), new( h, t ), new( -h, t ), new( -h, t * 0.5f ), new( -h + e, t * 0.5f )];
case ArchRingSection.Custom:
{
var points = MeshKit.ParsePoints( s.CustomSection );
if ( points.Count < 3 ) break;
var shape = points.Select( p => new Vector2( (p.x - 0.5f) * s.Depth, p.y * t ) ).ToList();
var area = 0.0f;
for ( var i = 0; i < shape.Count; i++ )
area += shape[i].x * shape[(i + 1) % shape.Count].y - shape[(i + 1) % shape.Count].x * shape[i].y;
if ( area < 0.0f ) shape.Reverse();
return shape;
}
}
return [new( -h, 0 ), new( h, 0 ), new( h, t ), new( -h, t )];
}
static void Pier( PartPlan pier, Build build, string id, float x0, float x1, float spring )
{
var s = build.S;
var context = build.Context;
var trim = context.Material( s.TrimMaterial );
var stone = context.Material( s.PierMaterial );
var depth = s.Depth * 0.5f;
var middle = (x0 + x1) * 0.5f;
var radius = MathF.Min( x1 - x0, s.Depth ) * 0.5f;
var bottom = s.Bases ? MathF.Min( s.BaseHeight, spring * 0.3f ) : 0.0f;
var top = spring - (s.Imposts ? MathF.Min( s.ImpostHeight, spring * 0.3f ) : 0.0f);
if ( top <= bottom ) return;
void Upright( MeshKit kit, float grow, float z0, float z1 )
{
if ( s.PierShape == PierShape.Square )
{
build.Block( kit, x0 - grow, x1 + grow, -depth - grow, depth + grow, z0, z1 );
return;
}
var center = build.Place( middle, 0.0f, 0.0f );
kit.Cylinder( center, radius + grow, radius + grow, z0, z1, s.PierShape == PierShape.Round ? RoundSides : 8 );
}
if ( s.Bases && bottom > 0.0f )
pier.Add( context.Solid( $"{id}/base", "Base", "PierBase", TrimCategory, trim, kit => Upright( kit, s.BaseOverhang, 0.0f, bottom ) ) );
pier.Add( context.Solid( $"{id}/shaft", "Shaft", "PierShaft", PierCategory, stone, kit => Upright( kit, 0.0f, bottom, top ) ) );
if ( s.Imposts && top < spring )
{
pier.Add( context.Solid( $"{id}/impost", "Impost", "Impost", TrimCategory, trim,
kit => build.Block( kit, x0 - s.ImpostOverhang, x1 + s.ImpostOverhang, -depth - s.ImpostOverhang, depth + s.ImpostOverhang, top, spring ) ) );
}
}
static void Ring( PartPlan bay, Build build, string id, RingCurve curve, List<Vector2> section, float rim, float left, float right, float spring )
{
var s = build.S;
var context = build.Context;
var last = curve.Last;
var crown = curve.Crown;
var opening = curve.At( 0.0f );
var keystone = s.Keystone && crown >= 2;
var length = 0.0f;
for ( var i = 0; i < last; i++ ) length += opening[i].Distance( opening[i + 1] );
var keyHalf = keystone ? Math.Clamp( (int)MathF.Round( s.KeystoneWidth * 0.5f / (length / last) ), 1, crown - 1 ) : 0;
var from = crown - keyHalf;
var to = crown + keyHalf;
var stones = s.Voussoirs ? Math.Clamp( (s.Stones - (keystone ? 1 : 0)) / 2, 1, from ) : 1;
var ring = context.Material( s.RingMaterial );
var alternate = string.IsNullOrEmpty( s.AlternateMaterial ) ? ring : context.Material( s.AlternateMaterial );
void Stretch( string name, string label, string kind, int a, int b, Material material, int index )
{
var partId = $"{id}/{name}";
if ( !s.Voussoirs )
{
bay.Add( context.Solid( partId, label, kind, RingCategory, material, kit =>
{
kit.SmoothingAngle = s.SmoothSurface ? MeshKit.DefaultSmoothing : 0.0f;
build.Carry( kit, curve, a, b, section );
} ) );
return;
}
var depth = 1.0f - s.Variation * StoneDepthRange * context.Stream( RingCategory, partId ).Range( 0.0f, 1.0f );
var shaped = section.Select( p => p.WithX( p.x * depth ) ).ToList();
bay.Add( context.Solid( partId, label, kind, RingCategory, index % 2 == 0 ? ring : alternate, kit =>
{
kit.SmoothingAngle = s.SmoothSurface ? MeshKit.DefaultSmoothing : 0.0f;
build.Carry( kit, curve, a, b, shaped );
} ) );
}
if ( !keystone && !s.Voussoirs )
{
Stretch( "ring", "Ring", "ArchRing", 0, last, ring, 0 );
}
else
{
for ( var i = 0; i < stones; i++ )
{
var a = from * i / stones;
var b = from * (i + 1) / stones;
Stretch( s.Voussoirs ? $"stone_left{i:00}" : "ring_left", s.Voussoirs ? $"Left Stone {i + 1}" : "Left Ring", s.Voussoirs ? "Voussoir" : "ArchRing", a, b, ring, i );
Stretch( s.Voussoirs ? $"stone_right{i:00}" : "ring_right", s.Voussoirs ? $"Right Stone {i + 1}" : "Right Ring", s.Voussoirs ? "Voussoir" : "ArchRing", last - b, last - a, ring, i );
}
}
var above = rim + (keystone ? s.KeystoneRise : 0.0f);
if ( keystone )
{
var h = s.Depth * 0.5f + s.KeystoneProud;
var low = -s.KeystoneDrop;
bay.Add( context.Solid( $"{id}/keystone", "Keystone", "Keystone", KeystoneCategory, context.Material( s.KeystoneMaterial ),
kit => build.Carry( kit, curve, from, to, [new( -h, low ), new( h, low ), new( h, above ), new( -h, above )] ) ) );
}
var circle = s.Shape == ArchShape.Circular;
if ( circle )
{
Lower( bay, build, id, curve.TurnedOver, section, ring );
}
if ( !s.WallAbove ) return;
var wall = context.Material( s.WallMaterial );
var edge = curve.At( rim );
var high = keystone ? curve.At( above ) : edge;
// What the wall has to go around, from the spring line to the crown.
List<Vector2> Rim( Vector2 foot, IEnumerable<int> along, IEnumerable<int> along2 )
{
List<Vector2> points = [.. along.Select( i => edge[i] ), .. along2.Select( i => high[i] )];
if ( points[0].y > spring ) points.Insert( 0, foot );
return points;
}
var leftRim = Rim( opening[0], Enumerable.Range( 0, from + 1 ), Enumerable.Range( from, crown - from + 1 ) );
var rightRim = Rim( opening[^1], Enumerable.Range( to, last - to + 1 ).Reverse(), Enumerable.Range( crown, to - crown + 1 ).Reverse() );
bay.Add( context.Solid( $"{id}/wall_left", "Left Wall", "Spandrel", WallCategory, wall,
kit => WallPiece( kit, build, leftRim, left, spring, build.Top, true ) ) );
bay.Add( context.Solid( $"{id}/wall_right", "Right Wall", "Spandrel", WallCategory, wall,
kit => WallPiece( kit, build, rightRim, right, spring, build.Top, true ) ) );
if ( !circle ) return;
// Below the spring line there is no keystone to go around.
List<Vector2> underLeft = [.. edge.Take( crown + 1 ).Select( p => p.WithY( spring * 2.0f - p.y ) )];
List<Vector2> underRight = [.. edge.Reverse().Take( crown + 1 ).Select( p => p.WithY( spring * 2.0f - p.y ) )];
if ( underLeft.Min( p => p.y ) <= 0.0f ) return;
bay.Add( context.Solid( $"{id}/wall_left_low", "Lower Left Wall", "Spandrel", WallCategory, wall,
kit => WallPiece( kit, build, underLeft, left, spring, 0.0f, false ) ) );
bay.Add( context.Solid( $"{id}/wall_right_low", "Lower Right Wall", "Spandrel", WallCategory, wall,
kit => WallPiece( kit, build, underRight, right, spring, 0.0f, false ) ) );
}
/// <summary>
/// The part of a full circle below the spring line.
/// </summary>
static void Lower( PartPlan bay, Build build, string id, RingCurve lower, List<Vector2> section, Material ring )
{
var s = build.S;
var context = build.Context;
var last = lower.Last;
if ( !s.Voussoirs )
{
bay.Add( context.Solid( $"{id}/ring_lower", "Lower Ring", "ArchRing", RingCategory, ring,
kit => build.Carry( kit, lower, 0, last, section ) ) );
return;
}
var alternate = string.IsNullOrEmpty( s.AlternateMaterial ) ? ring : context.Material( s.AlternateMaterial );
var stones = Math.Clamp( s.Stones - 1, 1, last );
for ( var i = 0; i < stones; i++ )
{
var a = last * i / stones;
var b = last * (i + 1) / stones;
bay.Add( context.Solid( $"{id}/stone_lower{i:00}", $"Lower Stone {i + 1}", "Voussoir", RingCategory, i % 2 == 0 ? ring : alternate,
kit => build.Carry( kit, lower, a, b, section ) ) );
}
}
/// <summary>
/// The wall between a rim and a flat line, out to the edge of the bay. The rim runs from the
/// spring line to the crown. Above means the wall stands over the rim, otherwise under it.
/// </summary>
static void WallPiece( MeshKit kit, Build build, List<Vector2> rim, float edge, float spring, float line, bool above )
{
var depth = build.S.WallDepth * 0.5f;
var crown = rim[^1].x;
// A ring runs from left to right with its outside on the left, and so does a wall around one.
var turned = edge < crown != above;
if ( build.Straight )
{
List<Vector2> closed = [new( edge, spring ), .. rim, new( crown, line ), new( edge, line )];
if ( turned ) closed.Reverse();
kit.Profile( MeshKit.Separate( closed ), -depth, depth );
return;
}
// On a path a wall follows the bends, so it is a fan of narrow pieces around the arch.
var fan = Fan( rim, edge, spring, line, above );
var near = turned ? rim.AsEnumerable().Reverse().ToArray() : [.. rim];
var far = turned ? fan.AsEnumerable().Reverse().ToArray() : [.. fan];
Vector3[] Row( Vector2[] line2, float y ) => [.. line2.Select( p => build.Place( p.x, y, p.y ) )];
kit.Fan( Row( near, depth ), Row( far, depth ), Row( near, -depth ), Row( far, -depth ) );
}
/// <summary>
/// For every point of a rim the place on the outline of the bay straight out from the middle
/// of the arch, in order and never on top of each other.
/// </summary>
static List<Vector2> Fan( List<Vector2> rim, float edge, float spring, float line, bool above )
{
var crown = rim[^1].x;
var side = MathF.Sign( edge - crown );
var way = above ? 1.0f : -1.0f;
var reach = MathF.Abs( edge - crown );
var height = MathF.Abs( line - spring );
var total = height + reach;
var spacing = MathF.Min( FanSpacing, total / (rim.Count * 2.0f) );
// Everything here is measured from the crown toward the edge and up from the spring line.
var points = rim.Select( p => new Vector2( (p.x - crown) * side, (p.y - spring) * way ) ).ToArray();
// How far along the outline, up the edge and on along the top, a line from here arrives.
float Arrival( Vector2 from, Vector2 direction )
{
var toEdge = direction.x > 0.0f ? (reach - from.x) / direction.x : float.MaxValue;
var toTop = direction.y > 0.0f ? (height - from.y) / direction.y : float.MaxValue;
var hit = from + direction * MathF.Min( toEdge, toTop );
return toEdge <= toTop ? hit.y : height + reach - hit.x;
}
var places = points.Select( p => p.x > 0.0f || p.y > 0.0f ? Arrival( Vector2.Zero, p ) : 0.0f ).ToArray();
// A step straight outward, up the side of a keystone, has to stay on the wall side of its line.
for ( var i = 0; i < points.Length - 1; i++ )
{
var step = points[i + 1] - points[i];
if ( step.Normal.Dot( points[i].Normal ) > OutwardStep )
places[i] = MathF.Min( places[i], Arrival( points[i], step.Normal ) - spacing );
}
places[0] = 0.0f;
places[^1] = total;
for ( var i = places.Length - 2; i > 0; i-- )
places[i] = MathF.Min( places[i], places[i + 1] - spacing );
for ( var i = 1; i < places.Length - 1; i++ )
places[i] = MathF.Max( places[i], places[i - 1] + spacing );
// The corner of the bay has to be a point of the outline.
var corner = Enumerable.Range( 1, places.Length - 2 ).OrderBy( i => MathF.Abs( places[i] - height ) ).First();
places[corner] = height;
return [.. places.Select( x => x <= height
? new Vector2( edge, spring + way * x )
: new Vector2( edge - side * (x - height), line ) )];
}
}
/// <summary>
/// The same arches standing along a drawn path or a spiral, bent with it.
/// </summary>
public sealed class CurvedArchGenerator : ArchGenerator
{
public override string Id => "curvedarch";
public override string Title => "Curved Arch";
public override Type SettingsType => typeof( CurvedArchSettings );
protected override PathTrack Route( ArchSettings settings )
{
var s = (CurvedArchSettings)settings;
if ( s.Path is null || s.Path.SegmentCount == 0 ) return null;
var reach = s.Depth * 0.5f + s.KeystoneProud + MathF.Max( s.BaseOverhang, s.ImpostOverhang );
return s.Path.Track( s.PathDetail, FrameMode.Upright, 4.0f, s.Climb ).Plumbed().Reach( reach, reach );
}
}