editor/AdvancedMappingTools/Core/MeshKit.cs
namespace AdvancedMappingTools;
public enum ArchStyle
{
Flat,
[Title( "Round / Roman" )] Round,
[Title( "Pointed / Gothic" )] Pointed,
Segmental,
Tudor,
Lancet,
Horseshoe,
Elliptical,
Ogee,
[Title( "Three Centered" )] ThreeCentered
}
/// <summary>
/// Collects faces into a PolygonMesh. Faces are given in local space through <see cref="Frame"/>,
/// wound counter clockwise when seen from outside. Vertices are shared inside one part.
/// In a frame +X runs along the shape, +Y to its left and +Z up.
/// The mesh is made in <see cref="Finish"/>: two parts that meet on exactly the same face become
/// one body there, so a shape put together from pieces has no walls left inside it.
/// </summary>
public sealed class MeshKit
{
const float WeldPrecision = 100.0f;
const float WarpLimit = 0.02f;
sealed record PendingFace( int[] Ring, Vector3[] Points, Material Material, int Part, int Assembly );
readonly List<PendingFace> _faces = [];
readonly List<(PolygonMesh Mesh, Transform Frame)> _imports = [];
/// <summary>Include a finished mesh without re-welding its cut rims or independent solids.</summary>
public void ImportMesh( PolygonMesh mesh, Transform frame )
{
if ( mesh is not null )
_imports.Add( (mesh, frame) );
}
readonly Dictionary<(int, int, int), int> _ids = [];
int _part;
int _assembly;
int _assemblies;
int _assemblyDepth;
bool _finished;
public PolygonMesh Mesh { get; } = new();
public Material Material { get; set; }
public Transform Frame { get; set; } = new( Vector3.Zero, Rotation.Identity );
public int FaceCount { get; private set; }
/// <summary>
/// Faces the engine refused. Known after <see cref="Finish"/>.
/// </summary>
public int FailedFaces { get; private set; }
/// <summary>
/// Faces that could not be connected to their neighbours, where a shape crosses itself.
/// Known after <see cref="Finish"/>.
/// </summary>
public int DetachedFaces { get; private set; }
public const float DefaultSmoothing = 40.0f;
const float DentLimit = 0.03f;
const float PlaneDirections = 500.0f;
const float PlaneThickness = 0.04f;
const float FlushLift = 0.06f;
const float LowArchCenter = 0.25f;
const float SmallestCap = 0.001f;
/// <summary>
/// Faces meeting at less than this many degrees shade as one surface.
/// </summary>
public float SmoothingAngle { get; set; } = DefaultSmoothing;
/// <summary>
/// Moves parts laid flush on each other apart by a hair, so their faces do not flicker.
/// </summary>
public bool LiftFlushFaces { get; set; } = true;
/// <summary>
/// Start a new shape that shares no vertices with what was added before.
/// </summary>
public void BeginPart() => _part++;
/// <summary>
/// Parts added from here to <see cref="EndAssembly"/> are fitted to each other on purpose, like the
/// bars, beads and glass of a window. They touch along edges without lying on each other, so
/// none of them is lifted off another. Against everything else they behave as before.
/// </summary>
public void BeginAssembly()
{
// An assembly inside another one, such as a window in a dormer, belongs to the outer one.
if ( _assemblyDepth++ == 0 )
_assembly = ++_assemblies;
}
public void EndAssembly()
{
if ( _assemblyDepth > 0 && --_assemblyDepth == 0 )
_assembly = 0;
}
/// <summary>
/// The finished mesh, or nothing if no face was added.
/// </summary>
public PolygonMesh Finish()
{
if ( FaceCount == 0 && _imports.Count == 0 )
return null;
if ( !_finished )
{
_finished = true;
if ( FaceCount > 0 )
AddFaces();
foreach ( var(mesh, frame)in _imports )
Mesh.MergeMesh( mesh, frame, out _, out _, out _ );
}
Mesh.SetSmoothingAngle( SmoothingAngle );
Mesh.TextureAlignToGrid( Transform.Zero );
return Mesh;
}
public void Face( params Vector3[] points )
{
var ring = new List<int>( points.Length );
var world = new List<Vector3>( points.Length );
foreach ( var point in points )
{
var position = Frame.PointToWorld( point );
var key = ((int)MathF.Round( position.x * WeldPrecision ),
(int)MathF.Round( position.y * WeldPrecision ),
(int)MathF.Round( position.z * WeldPrecision ));
if ( !_ids.TryGetValue( key, out var id ) )
_ids[key] = id = _ids.Count;
if ( ring.Count > 0 && ring[^1] == id ) continue;
ring.Add( id );
world.Add( position );
}
if ( ring.Count > 1 && ring[^1] == ring[0] )
{
ring.RemoveAt( ring.Count - 1 );
world.RemoveAt( world.Count - 1 );
}
if ( ring.Count < 3 ) return;
_faces.Add( new PendingFace( [.. ring], [.. world], Material, _part, _assembly ) );
FaceCount++;
}
/// <summary>
/// True when two rings with the same corners run the same way round.
/// </summary>
static bool SameDirection( int[] a, int[] b )
{
var start = Array.IndexOf( b, a[0] );
return b[(start + 1) % b.Length] == a[1];
}
void AddFaces()
{
var parts = new Dictionary<int, int>();
int Find( int x )
{
if ( !parts.TryGetValue( x, out var parent ) ) return parts[x] = x;
while ( parent != x )
{
x = parent;
parent = parts[x];
}
return x;
}
var dropped = new bool[_faces.Count];
foreach ( var group in Enumerable.Range( 0, _faces.Count ).GroupBy( i => string.Join( ' ', _faces[i].Ring.OrderBy( x => x ) ) ).Where( x => x.Count() > 1 ) )
{
var kept = new List<int>();
foreach ( var index in group )
{
var face = _faces[index];
// Inside one part such faces mean a shape folded back on itself, which is not for this to hide.
var same = kept.FindIndex( k => _faces[k].Part != face.Part && SameDirection( _faces[k].Ring, face.Ring ) );
var opposite = kept.FindIndex( k => _faces[k].Part != face.Part && !SameDirection( _faces[k].Ring, face.Ring ) );
if ( same >= 0 )
{
dropped[index] = true;
}
else if ( opposite >= 0 )
{
// Back to back: the wall between two parts. Both faces go and the parts become one body.
dropped[index] = true;
dropped[kept[opposite]] = true;
parts[Find( face.Part )] = Find( _faces[kept[opposite]].Part );
kept.RemoveAt( opposite );
}
else
{
kept.Add( index );
}
}
}
var lifted = LiftFlushFaces ? FlushFaces( dropped, Find ) : [];
var vertices = new Dictionary<(int, int), HalfEdgeMesh.VertexHandle>();
for ( var i = 0; i < _faces.Count; i++ )
{
if ( dropped[i] ) continue;
var pending = _faces[i];
var part = Find( pending.Part );
var handles = new HalfEdgeMesh.VertexHandle[pending.Ring.Length];
for ( var v = 0; v < handles.Length; v++ )
{
var corner = (part, pending.Ring[v]);
if ( !vertices.TryGetValue( corner, out var handle ) )
vertices[corner] = handle = Mesh.AddVertex( pending.Points[v] + lifted.GetValueOrDefault( corner ) );
handles[v] = handle;
}
var face = Mesh.AddFace( handles );
// A shape that crosses itself reuses an edge the mesh already has twice.
// Such a face stands on its own vertices instead of leaving a hole, and says so.
if ( !face.IsValid )
{
face = Mesh.AddFace( Mesh.AddVertices( pending.Points ) );
DetachedFaces++;
}
if ( !face.IsValid )
{
FailedFaces++;
continue;
}
Mesh.SetFaceMaterial( face, pending.Material );
}
}
/// <summary>
/// Trim laid flush on a wall shows a face in the same place as the wall, and two faces in one
/// place flicker. Whatever was added later moves out of that plane by a hair, so it stands
/// in front. Too little to see or to matter, and the last thing that decides the picture.
/// </summary>
Dictionary<(int Part, int Point), Vector3> FlushFaces( bool[] dropped, Func<int, int> find )
{
var planes = new Dictionary<(int, int, int, int), List<int>>();
var directions = new Vector3[_faces.Count];
for ( var i = 0; i < _faces.Count; i++ )
{
if ( dropped[i] ) continue;
var points = _faces[i].Points;
var sum = Vector3.Zero;
for ( var k = 1; k + 1 < points.Length; k++ )
sum += Vector3.Cross( points[k] - points[0], points[k + 1] - points[0] );
if ( sum.Length < 1e-4f ) continue;
var normal = sum.Normal;
var key = ((int)MathF.Round( normal.x * PlaneDirections ), (int)MathF.Round( normal.y * PlaneDirections ), (int)MathF.Round( normal.z * PlaneDirections ), (int)MathF.Round( normal.Dot( points[0] ) / PlaneThickness ));
directions[i] = normal;
if ( !planes.TryGetValue( key, out var list ) ) planes[key] = list = [];
list.Add( i );
}
var result = new Dictionary<(int Part, int Point), Vector3>();
foreach ( var group in planes.Values.Where( x => x.Count > 1 ) )
{
if ( group.Select( i => find( _faces[i].Part ) ).Distinct().Count() < 2 ) continue;
// Layers on layers: each face stands one step in front of the highest one it covers.
var layers = new Dictionary<int, int>();
foreach ( var i in group.OrderBy( x => find( _faces[x].Part ) ) )
{
var part = find( _faces[i].Part );
var rank = 0;
foreach ( var (other, layer) in layers )
{
if ( find( _faces[other].Part ) != part && layer >= rank && !Fitted( _faces[i], _faces[other] ) && Overlap( _faces[i].Points, _faces[other].Points, directions[i] ) )
rank = layer + 1;
}
layers[i] = rank;
if ( rank == 0 ) continue;
foreach ( var point in _faces[i].Ring )
{
var corner = (part, point);
var lift = directions[i] * (FlushLift * rank);
if ( result.GetValueOrDefault( corner ).Dot( directions[i] ) < lift.Dot( directions[i] ) )
result[corner] = result.GetValueOrDefault( corner ) + lift;
}
}
}
return result;
}
static bool Fitted( PendingFace a, PendingFace b ) => a.Assembly != 0 && a.Assembly == b.Assembly;
/// <summary>
/// True when two flat faces in one plane cover some of the same area.
/// </summary>
static bool Overlap( Vector3[] a, Vector3[] b, Vector3 normal )
{
var u = Vector3.Cross( normal, MathF.Abs( normal.x ) < 0.7f ? Vector3.Forward : Vector3.Left ).Normal;
var v = Vector3.Cross( normal, u ).Normal;
Vector2[] Flat( Vector3[] points ) => [.. points.Select( p => new Vector2( p.Dot( u ), p.Dot( v ) ) )];
var first = Flat( a );
var second = Flat( b );
static bool Inside( Vector2[] ring, Vector2 point )
{
var inside = false;
for ( var i = 0; i < ring.Length; i++ )
{
var p = ring[i];
var q = ring[(i + 1) % ring.Length];
if ( p.y > point.y != q.y > point.y && point.x < p.x + (point.y - p.y) / (q.y - p.y) * (q.x - p.x) )
inside = !inside;
}
return inside;
}
static bool Cross( Vector2 p, Vector2 q, Vector2 r, Vector2 s )
{
var d1 = (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
var d2 = (q.x - p.x) * (s.y - p.y) - (q.y - p.y) * (s.x - p.x);
var d3 = (s.x - r.x) * (p.y - r.y) - (s.y - r.y) * (p.x - r.x);
var d4 = (s.x - r.x) * (q.y - r.y) - (s.y - r.y) * (q.x - r.x);
return d1 * d2 < -1e-3f && d3 * d4 < -1e-3f;
}
// The middle of an edge or a face stands in for faces that only share their corners.
static Vector2 Middle( Vector2[] ring ) => ring.Aggregate( Vector2.Zero, ( sum, x ) => sum + x ) / ring.Length;
if ( Inside( first, Middle( second ) ) || Inside( second, Middle( first ) ) ) return true;
for ( var i = 0; i < first.Length; i++ )
{
if ( Inside( second, (first[i] + first[(i + 1) % first.Length]) * 0.5f ) ) return true;
for ( var k = 0; k < second.Length; k++ )
{
if ( Cross( first[i], first[(i + 1) % first.Length], second[k], second[(k + 1) % second.Length] ) ) return true;
}
}
return false;
}
public void FacingFront( float x0, float x1, float y, float z0, float z1 ) =>
Face( new( x0, y, z0 ), new( x1, y, z0 ), new( x1, y, z1 ), new( x0, y, z1 ) );
public void FacingBack( float x0, float x1, float y, float z0, float z1 ) =>
Face( new( x1, y, z0 ), new( x0, y, z0 ), new( x0, y, z1 ), new( x1, y, z1 ) );
public void FacingUp( float x0, float x1, float y0, float y1, float z ) =>
Face( new( x0, y0, z ), new( x1, y0, z ), new( x1, y1, z ), new( x0, y1, z ) );
public void FacingDown( float x0, float x1, float y0, float y1, float z ) =>
Face( new( x0, y0, z ), new( x0, y1, z ), new( x1, y1, z ), new( x1, y0, z ) );
public void FacingForward( float x, float y0, float y1, float z0, float z1 ) =>
Face( new( x, y0, z0 ), new( x, y1, z0 ), new( x, y1, z1 ), new( x, y0, z1 ) );
public void FacingRearward( float x, float y0, float y1, float z0, float z1 ) =>
Face( new( x, y1, z0 ), new( x, y0, z0 ), new( x, y0, z1 ), new( x, y1, z1 ) );
/// <summary>
/// A closed box as a part of its own.
/// </summary>
public void Box( Vector3 mins, Vector3 maxs )
{
if ( maxs.x - mins.x <= 0.0f || maxs.y - mins.y <= 0.0f || maxs.z - mins.z <= 0.0f ) return;
BeginPart();
FacingFront( mins.x, maxs.x, mins.y, mins.z, maxs.z );
FacingBack( mins.x, maxs.x, maxs.y, mins.z, maxs.z );
FacingUp( mins.x, maxs.x, mins.y, maxs.y, maxs.z );
FacingDown( mins.x, maxs.x, mins.y, maxs.y, mins.z );
FacingForward( maxs.x, mins.y, maxs.y, mins.z, maxs.z );
FacingRearward( mins.x, mins.y, maxs.y, mins.z, maxs.z );
}
/// <summary>
/// A closed body between a bottom and a top ring of four corners, counter clockwise from above.
/// </summary>
public void Hex( Vector3 a, Vector3 b, Vector3 c, Vector3 d, Vector3 e, Vector3 f, Vector3 g, Vector3 h )
{
BeginPart();
Face( d, c, b, a );
Face( e, f, g, h );
Face( a, b, f, e );
Face( b, c, g, f );
Face( c, d, h, g );
Face( d, a, e, h );
}
/// <summary>
/// A square bar between two points, kept upright around its length.
/// </summary>
public void Beam( Vector3 from, Vector3 to, float width, float height )
{
var delta = to - from;
if ( delta.Length < 0.01f ) return;
var up = MathF.Abs( delta.Normal.z ) > 0.99f ? Vector3.Forward : Vector3.Up;
var frame = Frame;
Frame = frame.ToWorld( new Transform( from, Rotation.LookAt( delta.Normal, up ) ) );
Box( new Vector3( 0.0f, -width * 0.5f, -height * 0.5f ), new Vector3( delta.Length, width * 0.5f, height * 0.5f ) );
Frame = frame;
}
/// <summary>
/// A shape drawn in the XZ plane, counter clockwise seen from -Y, extruded along Y.
/// </summary>
public void Profile( IReadOnlyList<Vector2> shape, float y0, float y1 )
{
BeginPart();
Face( [.. shape.Select( p => new Vector3( p.x, y0, p.y ) )] );
Face( [.. shape.Reverse().Select( p => new Vector3( p.x, y1, p.y ) )] );
for ( var i = 0; i < shape.Count; i++ )
{
var a = shape[i];
var b = shape[(i + 1) % shape.Count];
Face( new( a.x, y0, a.y ), new( a.x, y1, a.y ), new( b.x, y1, b.y ), new( b.x, y0, b.y ) );
}
}
/// <summary>
/// A cylinder, or a cone when the radii differ. The part ends in a point at radius zero.
/// </summary>
public void Cylinder( Vector3 center, float radius0, float radius1, float z0, float z1, int sides )
{
var low = Circle( center, radius0, z0, sides );
var high = Circle( center, radius1, z1, sides );
BeginPart();
Face( [.. low.Reverse()] );
Face( high );
for ( var i = 0; i < sides; i++ )
{
var j = (i + 1) % sides;
Face( low[i], low[j], high[j], high[i] );
}
}
/// <summary>
/// A hollow tube, closed at top and bottom.
/// </summary>
public void Ring( Vector3 center, float outer, float inner, float z0, float z1, int sides )
{
var outerLow = Circle( center, outer, z0, sides );
var outerHigh = Circle( center, outer, z1, sides );
var innerLow = Circle( center, inner, z0, sides );
var innerHigh = Circle( center, inner, z1, sides );
BeginPart();
for ( var i = 0; i < sides; i++ )
{
var j = (i + 1) % sides;
Face( outerLow[i], outerLow[j], outerHigh[j], outerHigh[i] );
Face( innerLow[j], innerLow[i], innerHigh[i], innerHigh[j] );
Face( innerHigh[i], outerHigh[i], outerHigh[j], innerHigh[j] );
Face( innerLow[j], outerLow[j], outerLow[i], innerLow[i] );
}
}
/// <summary>
/// A turned shape from a list of (radius, height) going upward. A radius of zero closes an end.
/// </summary>
public void Lathe( Vector3 center, IReadOnlyList<Vector2> profile, int sides )
{
BeginPart();
for ( var r = 0; r < profile.Count - 1; r++ )
{
var low = Circle( center, profile[r].x, profile[r].y, sides );
var high = Circle( center, profile[r + 1].x, profile[r + 1].y, sides );
for ( var i = 0; i < sides; i++ )
{
var j = (i + 1) % sides;
Face( low[i], low[j], high[j], high[i] );
}
}
}
public static Vector3[] Circle( Vector3 center, float radius, float z, int sides )
{
var points = new Vector3[sides];
for ( var i = 0; i < sides; i++ )
{
var angle = MathF.PI * 2.0f * i / sides;
points[i] = center + new Vector3( MathF.Cos( angle ) * radius, MathF.Sin( angle ) * radius, z );
}
return points;
}
/// <summary>
/// Skin rings that each follow the same path into a bar with this cross section.
/// One ring per profile point, counter clockwise in the path's left/up plane.
/// With <paramref name="materials"/> every side of the profile gets its own material.
/// </summary>
public void Sweep( IReadOnlyList<Vector3[]> rings, bool closed, bool closedProfile = true, IReadOnlyList<Material> materials = null )
{
var count = rings[0].Length;
var steps = closed ? count : count - 1;
var sides = closedProfile ? rings.Count : rings.Count - 1;
BeginPart();
for ( var r = 0; r < sides; r++ )
{
var p = rings[r];
var q = rings[(r + 1) % rings.Count];
if ( materials is not null ) Material = materials[r];
for ( var i = 0; i < steps; i++ )
{
var j = (i + 1) % count;
SweepQuad( p[i], q[i], q[j], p[j] );
}
}
if ( closed || !closedProfile ) return;
// A band that runs out to nothing ends in a line: its sides meet there, a cap would have no area.
if ( HasArea( rings.Select( x => x[0] ) ) ) Face( [.. rings.Reverse().Select( x => x[0] )] );
if ( HasArea( rings.Select( x => x[^1] ) ) ) Face( [.. rings.Select( x => x[^1] )] );
}
static bool HasArea( IEnumerable<Vector3> ring )
{
var points = ring.ToArray();
var normal = Vector3.Zero;
for ( var i = 0; i < points.Length; i++ )
normal += Vector3.Cross( points[i], points[(i + 1) % points.Length] );
return normal.Length > SmallestCap;
}
/// <summary>
/// A side of a shell, which can be any shape. A corner that points inward or lies on a straight
/// line has to end the diagonal, any other one folds the quad over itself or lays a
/// triangle flat on that line.
/// </summary>
void ShellQuad( Vector3 a, Vector3 b, Vector3 c, Vector3 d )
{
var facing = Vector3.Cross( c - a, d - b );
if ( Dented( a, b, c, facing ) || Dented( c, d, a, facing ) )
{
Face( a, b, d );
Face( b, c, d );
}
else if ( Dented( d, a, b, facing ) || Dented( b, c, d, facing ) )
{
Face( a, b, c );
Face( a, c, d );
}
else
{
SweepQuad( a, b, c, d );
}
}
static bool Dented( Vector3 from, Vector3 point, Vector3 to, Vector3 facing )
{
var first = point - from;
var second = to - point;
if ( first.Length < 0.001f || second.Length < 0.001f ) return false;
var turn = Vector3.Cross( first.Normal, second.Normal ).Dot( facing.Normal );
// A sharp tip runs back on itself as well, but it stays a corner.
return first.Dot( second ) > 0.0f ? turn < DentLimit : turn < -DentLimit;
}
/// <summary>
/// One side of a sweep. On a climbing turn these quads are warped and a warped face folds
/// along a diagonal. Top and bottom of a thin body have to fold along the same line,
/// or they pass through each other, so such a quad is cut here and not left to chance.
/// </summary>
void SweepQuad( Vector3 a, Vector3 b, Vector3 c, Vector3 d )
{
var normal = Vector3.Cross( b - a, d - a );
if ( normal.Length < 1e-5f || MathF.Abs( (c - a).Dot( normal.Normal ) ) < WarpLimit )
{
Face( a, b, c, d );
return;
}
if ( (b - a).Dot( Vector3.Cross( Vector3.Up, d - a ) ) > 0.0f )
{
Face( a, b, c );
Face( a, c, d );
}
else
{
Face( a, b, d );
Face( b, c, d );
}
}
/// <summary>
/// A closed body between two grids of points with the same size, the upper one looking up.
/// Going along the first index and then the second one has to turn counter clockwise seen
/// from above. Corners of a grid may lie on top of each other, a side then ends in a point.
/// </summary>
public void Shell( Vector3[,] upper, Vector3[,] lower )
{
var columns = upper.GetLength( 0 ) - 1;
var rows = upper.GetLength( 1 ) - 1;
BeginPart();
for ( var i = 0; i < columns; i++ )
{
for ( var j = 0; j < rows; j++ )
{
ShellQuad( upper[i, j], upper[i + 1, j], upper[i + 1, j + 1], upper[i, j + 1] );
ShellQuad( lower[i, j + 1], lower[i + 1, j + 1], lower[i + 1, j], lower[i, j] );
}
ShellQuad( lower[i, 0], lower[i + 1, 0], upper[i + 1, 0], upper[i, 0] );
ShellQuad( lower[i + 1, rows], lower[i, rows], upper[i, rows], upper[i + 1, rows] );
}
for ( var j = 0; j < rows; j++ )
{
ShellQuad( lower[0, j + 1], lower[0, j], upper[0, j], upper[0, j + 1] );
ShellQuad( lower[columns, j], lower[columns, j + 1], upper[columns, j + 1], upper[columns, j] );
}
}
/// <summary>
/// A closed bar between two lines of points, with its inner and outer surface as wide as
/// <paramref name="depth"/>. The stretches between the lines can be any shape.
/// </summary>
public void Fan( IReadOnlyList<Vector3> near, IReadOnlyList<Vector3> far, IReadOnlyList<Vector3> nearBack, IReadOnlyList<Vector3> farBack )
{
var upper = new Vector3[near.Count, 2];
var lower = new Vector3[near.Count, 2];
for ( var i = 0; i < near.Count; i++ )
{
upper[i, 0] = farBack[i];
upper[i, 1] = far[i];
lower[i, 0] = nearBack[i];
lower[i, 1] = near[i];
}
Shell( upper, lower );
}
/// <summary>
/// How high an arch of this style stands over an opening of this width.
/// </summary>
public static float ArchRise( ArchStyle style, float width ) => style switch
{
ArchStyle.Round => width * 0.5f,
ArchStyle.Pointed => width * 0.866f,
ArchStyle.Segmental => width * 0.2f,
ArchStyle.Tudor => width * 0.35f,
ArchStyle.Lancet => width * 1.25f,
ArchStyle.Horseshoe => width * 0.785f,
ArchStyle.Elliptical => width * 0.3f,
ArchStyle.Ogee => width * 0.8f,
ArchStyle.ThreeCentered => width * 0.33f,
_ => 0.0f
};
/// <summary>
/// A prism from a closed outline, whichever way round it runs. Outline heights are ignored.
/// </summary>
public void Prism( IReadOnlyList<Vector3> outline, float z0, float z1 )
{
var area = RingArea( outline );
if ( MathF.Abs( area ) < 0.001f ) return;
var ring = area > 0.0f ? outline : outline.Reverse().ToArray();
BeginPart();
Face( [.. ring.Reverse().Select( p => p.WithZ( z0 ) )] );
Face( [.. ring.Select( p => p.WithZ( z1 ) )] );
for ( var i = 0; i < ring.Count; i++ )
{
var a = ring[i];
var b = ring[(i + 1) % ring.Count];
Face( a.WithZ( z0 ), b.WithZ( z0 ), b.WithZ( z1 ), a.WithZ( z1 ) );
}
}
/// <summary>
/// A slice of a ring or disc between two angles in degrees. An inner radius of zero makes a wedge.
/// </summary>
public void Arc( Vector3 center, float inner, float outer, float from, float to, float z0, float z1, int segments ) =>
Prism( ArcOutline( center, inner, outer, from, to, segments ), z0, z1 );
/// <summary>
/// The outline of a slice of a ring or disc, in the order of its outer edge and back along the inner one.
/// </summary>
public static List<Vector3> ArcOutline( Vector3 center, float inner, float outer, float from, float to, int segments )
{
Vector3 At( float degrees, float radius )
{
var angle = degrees.DegreeToRadian();
return center + new Vector3( MathF.Cos( angle ) * radius, MathF.Sin( angle ) * radius, 0.0f );
}
var ring = new List<Vector3>();
for ( var i = 0; i <= segments; i++ )
ring.Add( At( from + (to - from) * i / segments, outer ) );
if ( inner <= 0.0f )
{
ring.Add( center );
}
else
{
for ( var i = segments; i >= 0; i-- )
ring.Add( At( from + (to - from) * i / segments, inner ) );
}
return ring;
}
/// <summary>
/// A curved band between two curves with the same number of points, as one closed body.
/// The curves run from left to right, shaped like the <see cref="ArchCurve"/> of an arch.
/// </summary>
public void Band( IReadOnlyList<Vector2> inner, IReadOnlyList<Vector2> outer, float y0, float y1 ) =>
Profile( Separate( [.. inner, .. outer.Reverse()] ), y0, y1 );
/// <summary>
/// An outline without the points that lie on top of their neighbour.
/// </summary>
public static List<Vector2> Separate( IEnumerable<Vector2> outline )
{
var result = new List<Vector2>();
foreach ( var point in outline )
{
if ( result.Count == 0 || (result[^1] - point).Length > 0.001f ) result.Add( point );
}
while ( result.Count > 1 && (result[^1] - result[0]).Length <= 0.001f )
result.RemoveAt( result.Count - 1 );
return result;
}
/// <summary>
/// One closed body from neighbouring footprints of different heights, like solid stairs
/// seen from above. Footprints that meet must share their corners, heights are absolute.
/// Only the parts of a side that can be seen get a face, nothing is left inside.
/// </summary>
public void SteppedSolid( IReadOnlyList<(IReadOnlyList<Vector3> Outline, float Top)> steps, float bottom, Material top, Material side )
{
static (int, int) Key( Vector3 p ) => ((int)MathF.Round( p.x * WeldPrecision ), (int)MathF.Round( p.y * WeldPrecision ));
var rings = steps.Select( x => (IReadOnlyList<Vector3>)(RingArea( x.Outline ) > 0.0f ? x.Outline : x.Outline.Reverse().ToArray()) ).ToArray();
var edges = new Dictionary<((int, int), (int, int)), int>();
var stops = new Dictionary<(int, int), SortedSet<float>>();
for ( var s = 0; s < rings.Length; s++ )
{
var ring = rings[s];
for ( var i = 0; i < ring.Count; i++ )
{
var key = Key( ring[i] );
edges[(key, Key( ring[(i + 1) % ring.Count] ))] = s;
if ( !stops.TryGetValue( key, out var heights ) )
stops[key] = heights = [];
heights.Add( steps[s].Top );
}
}
BeginPart();
for ( var s = 0; s < rings.Length; s++ )
{
var ring = rings[s];
var height = steps[s].Top;
Material = side;
Face( [.. ring.Reverse().Select( p => p.WithZ( bottom ) )] );
Material = top;
Face( [.. ring.Select( p => p.WithZ( height ) )] );
Material = side;
for ( var i = 0; i < ring.Count; i++ )
{
var p = ring[i];
var q = ring[(i + 1) % ring.Count];
var low = edges.TryGetValue( (Key( q ), Key( p )), out var other ) ? steps[other].Top : bottom;
if ( low >= height ) continue;
var loop = new List<Vector3> { p.WithZ( low ), q.WithZ( low ) };
loop.AddRange( stops[Key( q )].Where( z => z > low && z < height ).Select( q.WithZ ) );
loop.Add( q.WithZ( height ) );
loop.Add( p.WithZ( height ) );
loop.AddRange( stops[Key( p )].Where( z => z > low && z < height ).Reverse().Select( p.WithZ ) );
Face( [.. loop] );
}
}
}
static float RingArea( IReadOnlyList<Vector3> ring )
{
var area = 0.0f;
for ( var i = 0; i < ring.Count; i++ )
{
var a = ring[i];
var b = ring[(i + 1) % ring.Count];
area += a.x * b.y - b.x * a.y;
}
return area;
}
/// <summary>
/// The curve of an arch from its left to its right spring point, as (x, z). Every curve is
/// symmetric and has an even number of pieces, so its middle point is the crown.
/// A flat style gives just the two spring points.
/// </summary>
public static Vector2[] ArchCurve( float x0, float x1, float spring, float rise, ArchStyle style, int segments )
{
if ( style == ArchStyle.Flat || rise <= 0.0f )
return [new( x0, spring ), new( x1, spring )];
segments = Math.Clamp( segments, 2, 64 ) / 2 * 2;
var half = (x1 - x0) * 0.5f;
var middle = (x0 + x1) * 0.5f;
var points = new Vector2[segments + 1];
var crown = segments / 2;
for ( var i = 0; i <= crown; i++ )
{
var side = ArchSide( style, half, rise, i / (float)crown );
points[i] = new Vector2( middle + side.x, spring + side.y );
points[segments - i] = new Vector2( middle - side.x, spring + side.y );
}
points[0] = new Vector2( x0, spring );
points[segments] = new Vector2( x1, spring );
return points;
}
/// <summary>
/// An arch through points of its left half, written as across,up pairs split by semicolons.
/// Both run from 0 to 1, between the spring point and the crown. The line is smoothed,
/// and the result is null when the text holds no usable points.
/// </summary>
public static Vector2[] ArchCurve( float x0, float x1, float spring, float rise, string profile, int segments )
{
var written = ParsePoints( profile );
if ( written.Count == 0 ) return null;
if ( written[0] != Vector2.Zero ) written.Insert( 0, Vector2.Zero );
if ( written[^1] != Vector2.One ) written.Add( Vector2.One );
segments = Math.Clamp( segments, 2, 64 ) / 2 * 2;
var half = (x1 - x0) * 0.5f;
var path = PathCurve.FromPoints( written.Select( p => new Vector3( p.x * half, 0.0f, p.y * rise ) ), written.Count > 2 ? PathInterpolation.CatmullRom : PathInterpolation.Linear );
var line = new List<Vector2>();
for ( var s = 0; s < path.SegmentCount; s++ )
{
for ( var i = 0; i < 24; i++ )
{
var point = path.Evaluate( s, i / 24.0f );
line.Add( new Vector2( point.x, point.z ) );
}
}
line.Add( new Vector2( half, rise ) );
var lengths = new float[line.Count];
for ( var i = 1; i < line.Count; i++ )
lengths[i] = lengths[i - 1] + (line[i] - line[i - 1]).Length;
var crown = segments / 2;
var points = new Vector2[segments + 1];
var cursor = 1;
for ( var i = 0; i <= crown; i++ )
{
var wanted = lengths[^1] * i / crown;
while ( cursor < line.Count - 1 && lengths[cursor] < wanted ) cursor++;
var span = lengths[cursor] - lengths[cursor - 1];
var along = span > 0.0f ? (wanted - lengths[cursor - 1]) / span : 0.0f;
var side = line[cursor - 1] + (line[cursor] - line[cursor - 1]) * Math.Clamp( along, 0.0f, 1.0f );
points[i] = new Vector2( x0 + side.x, spring + side.y );
points[segments - i] = new Vector2( x1 - side.x, spring + side.y );
}
points[0] = new Vector2( x0, spring );
points[segments] = new Vector2( x1, spring );
return points;
}
/// <summary>
/// Points written as x,y pairs split by semicolons. What cannot be read is left out.
/// </summary>
public static List<Vector2> ParsePoints( string text )
{
var result = new List<Vector2>();
foreach ( var pair in (text ?? "").Split( ';', StringSplitOptions.RemoveEmptyEntries ) )
{
var values = pair.Split( ',' );
if ( values.Length != 2 ) continue;
if ( float.TryParse( values[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var x )
&& float.TryParse( values[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var y ) )
{
result.Add( new Vector2( x, y ) );
}
}
return result;
}
/// <summary>
/// The left half of an arch around its middle, from the spring point at t = 0 to the crown
/// at t = 1. All of these are built from true arcs, so a thickness can follow them.
/// </summary>
static Vector2 ArchSide( ArchStyle style, float half, float rise, float t )
{
switch ( style )
{
case ArchStyle.Segmental:
{
// A slice of a large circle through both spring points and the crown.
var radius = (half * half + rise * rise) / (2.0f * rise);
var angle = MathF.Atan2( half, radius - rise ) * (1.0f - t);
return new Vector2( -MathF.Sin( angle ) * radius, rise - radius + MathF.Cos( angle ) * radius );
}
case ArchStyle.Pointed:
case ArchStyle.Lancet:
{
// Two circles meeting in the tip. Each centre lies on the spring line, further
// out for a sharper arch: on the opposite spring point for an equilateral one.
// A low arch needs its centres below that line, or the circles meet in a dip.
var center = MathF.Max( (rise * rise - half * half) / (2.0f * half), half * LowArchCenter );
var drop = MathF.Max( (half * half + 2.0f * half * center - rise * rise) / (2.0f * rise), 0.0f );
var radius = MathF.Sqrt( (half + center) * (half + center) + drop * drop );
var start = MathF.Atan2( drop, -(half + center) );
var angle = start + (MathF.Atan2( rise + drop, -center ) - start) * t;
return new Vector2( center + MathF.Cos( angle ) * radius, MathF.Sin( angle ) * radius - drop );
}
case ArchStyle.Tudor:
return TudorSide( half, rise, t );
case ArchStyle.ThreeCentered:
return ThreeCenteredSide( half, rise, t );
case ArchStyle.Ogee:
{
// One curve out of the spring point, turning over into a tip that points up.
var u = 1.0f - t;
return new Vector2( -half * (u * u * u + 3.0f * u * u * t + 3.0f * u * t * t * 0.33f), rise * (3.0f * u * u * t * 0.62f + 3.0f * u * t * t * 0.42f + t * t * t) );
}
case ArchStyle.Horseshoe:
{
// A circle that carries on below its centre, so the opening closes in at the bottom.
var below = 25.0f.DegreeToRadian();
var radius = half / MathF.Cos( below );
var center = half * MathF.Tan( below );
var angle = MathF.PI + below - (MathF.PI * 0.5f + below) * t;
return new Vector2( MathF.Cos( angle ) * radius, (center + MathF.Sin( angle ) * radius) * rise / (center + radius) );
}
default:
{
var angle = MathF.PI * (1.0f - t * 0.5f);
return new Vector2( MathF.Cos( angle ) * half, MathF.Sin( angle ) * rise );
}
}
}
/// <summary>
/// Four centres: a small arc out of the spring point, then a large flat one up to a blunt tip.
/// </summary>
static Vector2 TudorSide( float half, float rise, float t )
{
var small = half * 0.45f;
var center = new Vector2( -half + small, 0.0f );
var tip = new Vector2( 0.0f, rise );
var offset = tip - center;
// The small arc turns until the large one can reach the tip from there.
for ( var degrees = 70.0f; degrees >= 15.0f; degrees -= 5.0f )
{
var turn = degrees.DegreeToRadian();
var outward = new Vector2( -MathF.Cos( turn ), MathF.Sin( turn ) );
var room = small - offset.Dot( outward );
if ( room < half * 0.02f ) continue;
var extra = (offset.LengthSquared - small * small) / (2.0f * room);
var large = small + extra;
var far = center - outward * extra;
var start = MathF.Atan2( outward.y, outward.x );
var end = MathF.Atan2( tip.y - far.y, tip.x - far.x );
if ( t < 0.4f )
{
var angle = MathF.PI + (start - MathF.PI) * (t / 0.4f);
return center + new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * small;
}
var along = start + (end - start) * ((t - 0.4f) / 0.6f);
return far + new Vector2( MathF.Cos( along ), MathF.Sin( along ) ) * large;
}
return ArchSide( ArchStyle.Pointed, half, rise, t );
}
/// <summary>
/// Three centres: tight arcs at the spring points and one wide arc over the top, a flat
/// arch that still starts upright. Only an arch lower than half its span can be made so.
/// </summary>
static Vector2 ThreeCenteredSide( float half, float rise, float t )
{
if ( rise >= half * 0.98f ) return ArchSide( ArchStyle.Elliptical, half, rise, t );
var small = rise * 0.7f;
var drop = ((half - small) * (half - small) - (rise - small) * (rise - small)) / (2.0f * (rise - small));
var large = rise + drop;
var center = new Vector2( -half + small, 0.0f );
var turn = MathF.Atan2( drop, -(half - small) );
if ( t < 0.4f )
{
var angle = MathF.PI + (turn - MathF.PI) * (t / 0.4f);
return center + new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * small;
}
var along = turn + (MathF.PI * 0.5f - turn) * ((t - 0.4f) / 0.6f);
return new Vector2( MathF.Cos( along ) * large, MathF.Sin( along ) * large - drop );
}
/// <summary>
/// A curve moved outward along its own normals, the same distance everywhere. A negative
/// distance moves it inward. A tip stays a tip, its point moves further to keep the distance.
/// A curve runs from left to right, which puts outside on its left.
/// </summary>
public static Vector2[] OffsetCurve( IReadOnlyList<Vector2> curve, float distance )
{
var result = new Vector2[curve.Count];
for ( var i = 0; i < curve.Count; i++ )
{
var before = i > 0 ? (curve[i] - curve[i - 1]).Normal : (curve[1] - curve[0]).Normal;
var after = i < curve.Count - 1 ? (curve[i + 1] - curve[i]).Normal : before;
var normalBefore = new Vector2( -before.y, before.x );
var normalAfter = new Vector2( -after.y, after.x );
var mitre = (normalBefore + normalAfter).Normal;
result[i] = curve[i] + mitre * (distance / MathF.Max( mitre.Dot( normalAfter ), 0.25f ));
}
ClearCrossings( result );
return result;
}
/// <summary>
/// A curve moved inward runs into itself at a tip and on a tight turn. Everything between two
/// stretches that cross is moved to the crossing, so the curve keeps its number of points.
/// </summary>
static void ClearCrossings( Vector2[] curve )
{
for ( var i = 0; i + 1 < curve.Length; i++ )
{
for ( var j = curve.Length - 2; j > i + 1; j-- )
{
var d0 = curve[i + 1] - curve[i];
var d1 = curve[j + 1] - curve[j];
var cross = d0.x * d1.y - d0.y * d1.x;
if ( MathF.Abs( cross ) < 1e-6f ) continue;
var offset = curve[j] - curve[i];
var u = (offset.x * d1.y - offset.y * d1.x) / cross;
var v = (offset.x * d0.y - offset.y * d0.x) / cross;
if ( u < 0.0f || u > 1.0f || v < 0.0f || v > 1.0f ) continue;
var point = curve[i] + d0 * u;
for ( var k = i + 1; k <= j; k++ )
curve[k] = point;
i = j;
break;
}
}
}
/// <summary>
/// Frame boxes around a rectangular opening, on the inside of its edges.
/// </summary>
public void OpeningFrame( float x0, float x1, float sill, float head, float width, float depth, bool withSill )
{
if ( width <= 0.0f ) return;
var low = withSill ? MathF.Min( sill + width, head - width ) : sill;
foreach ( var (from, to) in new[] { (x0, x0 + width), (x1 - width, x1) } )
{
Box( new Vector3( from, -depth, sill ), new Vector3( to, depth, low ) );
Box( new Vector3( from, -depth, low ), new Vector3( to, depth, head - width ) );
Box( new Vector3( from, -depth, head - width ), new Vector3( to, depth, head ) );
}
Box( new Vector3( x0 + width, -depth, head - width ), new Vector3( x1 - width, depth, head ) );
if ( withSill )
Box( new Vector3( x0 + width, -depth, sill ), new Vector3( x1 - width, depth, low ) );
}
/// <summary>
/// Frame around an arched opening: two jambs and one curved band.
/// </summary>
public void ArchFrame( float x0, float x1, float sill, float spring, float rise, ArchStyle style, int segments, float width, float depth, bool withSill )
{
if ( width <= 0.0f ) return;
var low = withSill ? MathF.Min( sill + width, spring ) : sill;
foreach ( var (from, to) in new[] { (x0, x0 + width), (x1 - width, x1) } )
{
Box( new Vector3( from, -depth, sill ), new Vector3( to, depth, low ) );
Box( new Vector3( from, -depth, low ), new Vector3( to, depth, spring ) );
}
if ( withSill )
Box( new Vector3( x0 + width, -depth, sill ), new Vector3( x1 - width, depth, low ) );
var outer = ArchCurve( x0, x1, spring, rise, style, segments );
var inner = OffsetCurve( outer, -width );
// The band stands on the two jambs.
outer[0] = new Vector2( x0, spring );
outer[^1] = new Vector2( x1, spring );
inner[0] = new Vector2( x0 + width, spring );
inner[^1] = new Vector2( x1 - width, spring );
Band( inner, outer, -depth, depth );
}
/// <summary>
/// A straight wall with arched openings. Everything above the spring line is one outline
/// running along the arches, so a curve may reach back over itself like a horseshoe does.
/// Below it stand the piers. Each piece shares its corners with the next one.
/// </summary>
public void WallWithArches( float length, float thickness, float height,
IReadOnlyList<(float Start, float End)> openings, float sill, float spring, float rise, ArchStyle style, int segments )
{
var y0 = -thickness * 0.5f;
var y1 = thickness * 0.5f;
var rows = new List<float> { 0.0f };
if ( sill > 0.0f ) rows.Add( sill );
rows.Add( spring );
void Pier( float x0, float x1 )
{
if ( x1 - x0 <= 0.0f ) return;
for ( var r = 0; r < rows.Count - 1; r++ )
{
FacingFront( x0, x1, y0, rows[r], rows[r + 1] );
FacingBack( x0, x1, y1, rows[r], rows[r + 1] );
}
FacingDown( x0, x1, y0, y1, 0.0f );
}
BeginPart();
var outline = new List<Vector2> { new( 0.0f, spring ) };
var cursor = 0.0f;
foreach ( var (start, end) in openings )
{
Pier( cursor, start );
FacingForward( start, y0, y1, sill, spring );
FacingRearward( end, y0, y1, sill, spring );
if ( sill > 0.0f )
{
FacingFront( start, end, y0, 0.0f, sill );
FacingBack( start, end, y1, 0.0f, sill );
FacingUp( start, end, y0, y1, sill );
FacingDown( start, end, y0, y1, 0.0f );
}
foreach ( var point in ArchCurve( start, end, spring, rise, style, segments ) )
{
if ( outline[^1] != point ) outline.Add( point );
}
cursor = end;
}
Pier( cursor, length );
if ( outline[^1].x < length ) outline.Add( new Vector2( length, spring ) );
outline.Add( new Vector2( length, height ) );
outline.Add( new Vector2( 0.0f, height ) );
Face( [.. outline.Select( p => new Vector3( p.x, y0, p.y ) )] );
Face( [.. outline.AsEnumerable().Reverse().Select( p => new Vector3( p.x, y1, p.y ) )] );
for ( var i = 0; i < outline.Count; i++ )
{
var a = outline[i];
var b = outline[(i + 1) % outline.Count];
// The stretches along the spring line stand on the piers.
if ( a.y == spring && b.y == spring ) continue;
Face( new( a.x, y0, a.y ), new( a.x, y1, a.y ), new( b.x, y1, b.y ), new( b.x, y0, b.y ) );
}
for ( var r = 0; r < rows.Count - 1; r++ )
{
FacingRearward( 0.0f, y0, y1, rows[r], rows[r + 1] );
FacingForward( length, y0, y1, rows[r], rows[r + 1] );
}
}
/// <summary>
/// A straight wall from x = 0 to <paramref name="length"/> with rectangular openings that
/// all run from <paramref name="sill"/> to <paramref name="head"/>. Laid out on a grid so
/// every face shares its corners with its neighbours.
/// </summary>
public void WallWithOpenings( float length, float thickness, float height,
IReadOnlyList<(float Start, float End)> openings, float sill, float head )
{
var y0 = -thickness * 0.5f;
var y1 = thickness * 0.5f;
var columns = new List<float> { 0.0f };
foreach ( var opening in openings )
{
columns.Add( opening.Start );
columns.Add( opening.End );
}
columns.Add( length );
var rows = new List<float> { 0.0f };
if ( sill > 0.0f ) rows.Add( sill );
rows.Add( head );
if ( head < height ) rows.Add( height );
var openRow = rows.IndexOf( head ) - 1;
bool IsOpening( int column ) => column % 2 == 1;
BeginPart();
for ( var c = 0; c < columns.Count - 1; c++ )
{
var x0 = columns[c];
var x1 = columns[c + 1];
for ( var r = 0; r < rows.Count - 1; r++ )
{
if ( IsOpening( c ) && r == openRow ) continue;
FacingFront( x0, x1, y0, rows[r], rows[r + 1] );
FacingBack( x0, x1, y1, rows[r], rows[r + 1] );
}
if ( !IsOpening( c ) || head < height )
FacingUp( x0, x1, y0, y1, height );
if ( !IsOpening( c ) || sill > 0.0f )
FacingDown( x0, x1, y0, y1, 0.0f );
if ( !IsOpening( c ) ) continue;
if ( sill > 0.0f ) FacingUp( x0, x1, y0, y1, sill );
if ( head < height ) FacingDown( x0, x1, y0, y1, head );
FacingForward( x0, y0, y1, sill, head );
FacingRearward( x1, y0, y1, sill, head );
}
for ( var r = 0; r < rows.Count - 1; r++ )
{
FacingRearward( 0.0f, y0, y1, rows[r], rows[r + 1] );
FacingForward( length, y0, y1, rows[r], rows[r + 1] );
}
}
}