Editor/Output/ArchMesh.Primitives.cs
using System;
using System.Collections.Generic;
using System.Linq;
using HalfEdgeMesh;
using Sandbox;
namespace Sunless.Architecture;
public sealed partial class ArchMesh {
// Idempotent — contact pass and scene finish both call this
public PolygonMesh Finish() {
if ( finished ) {
return mesh;
}
Tidy();
mesh.SetSmoothingAngle( 0f );
finished = true;
return mesh;
}
void Tidy() {
for ( var sweep = 0; sweep < Sweeps && Dissolved() > 0; sweep++ ) {
}
// Skip faces with manually authored UVs
mesh.ComputeFaceTextureCoordinatesFromParameters( mesh.FaceHandles.Where( face => !authored.Contains( face ) ) );
}
const int Sweeps = 24;
static readonly List<HalfEdgeHandle> seams = new();
int Dissolved() {
var taken = 0;
seams.Clear();
seams.AddRange( mesh.HalfEdgeHandles );
foreach ( var edge in seams ) {
var opposite = edge.OppositeEdge;
if ( !edge.IsValid || !opposite.IsValid || opposite.Index < edge.Index ) {
continue;
}
var left = edge.Face;
var right = opposite.Face;
if ( !left.IsValid || !right.IsValid || !OneFace( left, right ) || !Squares( edge ) ) {
continue;
}
mesh.DissolveEdges( new[] { edge }, true, PolygonMesh.DissolveRemoveVertexCondition.Colinear );
taken++;
}
return taken;
}
// Only dissolve into proper quads — merged result must have ≤4 corners and be planar
bool Squares( HalfEdgeHandle edge ) {
if ( !mesh.GetVerticesConnectedToFace( edge.Face, out var left )
|| !mesh.GetVerticesConnectedToFace( edge.OppositeEdge.Face, out var right ) ) {
return false;
}
var ring = Merged( left, right, edge.Vertex, edge.OppositeEdge.Vertex )
?? Merged( left, right, edge.OppositeEdge.Vertex, edge.Vertex );
if ( ring is null ) {
return false;
}
var kept = Kept( ring, edge );
return kept.Count <= 4 && Flat( kept );
}
// Seam ends that become collinear after merge are not corners of the result
List<VertexHandle> Kept( List<VertexHandle> ring, HalfEdgeHandle edge ) {
var ends = new[] { edge.Vertex, edge.OppositeEdge.Vertex };
return ring.Where( vertex => !ends.Contains( vertex ) || Holds( ring, vertex ) ).ToList();
}
bool Holds( List<VertexHandle> ring, VertexHandle vertex ) {
if ( mesh.GetFacesConnectedToVertex( vertex, out var faces ) && faces.Count > 2 ) {
return true;
}
var index = ring.IndexOf( vertex );
var behind = mesh.GetVertexPosition( ring[(index - 1 + ring.Count) % ring.Count] );
var here = mesh.GetVertexPosition( vertex );
var ahead = mesh.GetVertexPosition( ring[(index + 1) % ring.Count] );
return (here - behind).Normal.Dot( (ahead - here).Normal ) < Straight;
}
// Tighter than the engine's own collinear threshold — dissolve always agrees
const float Straight = 0.9999f;
bool Flat( List<VertexHandle> ring ) {
var corners = ring.Select( mesh.GetVertexPosition ).ToList();
var normal = Newell( corners );
return corners.All( corner => MathF.Abs( Vector3.Dot( normal, corner - corners[0] ) ) < Coplanar );
}
const float Coplanar = 0.05f;
static List<VertexHandle> Merged( VertexHandle[] left, VertexHandle[] right, VertexHandle from, VertexHandle to ) {
var near = Opened( left, from, to );
var far = Opened( right, to, from );
if ( near is null || far is null ) {
return null;
}
near.AddRange( far.Skip( 1 ).Take( far.Count - 2 ) );
return near;
}
static List<VertexHandle> Opened( VertexHandle[] loop, VertexHandle from, VertexHandle to ) {
for ( var index = 0; index < loop.Length; index++ ) {
if ( !loop[index].Equals( from ) || !loop[(index + 1) % loop.Length].Equals( to ) ) {
continue;
}
var opened = new List<VertexHandle>( loop.Length );
for ( var step = 0; step < loop.Length; step++ ) {
opened.Add( loop[(index + 1 + step) % loop.Length] );
}
return opened;
}
return null;
}
bool OneFace( FaceHandle left, FaceHandle right ) {
mesh.ComputeFaceNormal( left, out var here );
mesh.ComputeFaceNormal( right, out var there );
if ( here.Normal.Dot( there.Normal ) < 0.9995f ) {
return false;
}
if ( mesh.GetFaceMaterial( left ) != mesh.GetFaceMaterial( right ) ) {
return false;
}
mesh.GetFaceTextureParameters( left, out var alongLeft, out var acrossLeft, out var scaleLeft );
mesh.GetFaceTextureParameters( right, out var alongRight, out var acrossRight, out var scaleRight );
return Same( alongLeft, alongRight ) && Same( acrossLeft, acrossRight )
&& MathF.Abs( scaleLeft.x - scaleRight.x ) < 0.0001f && MathF.Abs( scaleLeft.y - scaleRight.y ) < 0.0001f;
}
static bool Same( Vector4 left, Vector4 right ) {
return MathF.Abs( left.x - right.x ) < 0.0001f && MathF.Abs( left.y - right.y ) < 0.0001f
&& MathF.Abs( left.z - right.z ) < 0.0001f && MathF.Abs( left.w - right.w ) < 0.0001f;
}
public void Quad( Vector3 a, Vector3 b, Vector3 c, Vector3 d, ArchBrush brush ) {
Face( Vertices( a, b, c, d ), brush, default );
}
public void Ribbon( Vector3 a, Vector3 b, Vector3 c, Vector3 d, Vector3 tangent, ArchBrush brush, ArchWeave? weave = null ) {
Face( Vertices( a, b, c, d ), brush, tangent, weave );
}
public void Polygon( IReadOnlyList<Vector3> points, ArchBrush brush, bool flip = false, Vector3 tangent = default, Vector3? anchor = null ) {
if ( points.Count < 3 ) {
return;
}
var positions = new Vector3[points.Count];
for ( var index = 0; index < points.Count; index++ ) {
positions[index] = flip ? points[points.Count - 1 - index] : points[index];
}
Face( Vertices( positions ), brush, tangent, anchor: anchor );
}
public void Wedge( Vector3 a, Vector3 b, Vector3 c, Vector3 tangent, ArchBrush brush, ArchWeave weave ) {
Face( Vertices( a, b, c ), brush, tangent, weave );
}
public void Box( Vector3 mins, Vector3 maxs, ArchBrush brush, BoxFaces faces = BoxFaces.All, Vector3 tangent = default ) {
var lo = Vector3.Min( mins, maxs );
var hi = Vector3.Max( mins, maxs );
var size = hi - lo;
if ( size.x <= 0.01f || size.y <= 0.01f || size.z <= 0.01f ) {
return;
}
var v = Vertices(
new Vector3( lo.x, lo.y, lo.z ),
new Vector3( hi.x, lo.y, lo.z ),
new Vector3( hi.x, hi.y, lo.z ),
new Vector3( lo.x, hi.y, lo.z ),
new Vector3( lo.x, lo.y, hi.z ),
new Vector3( hi.x, lo.y, hi.z ),
new Vector3( hi.x, hi.y, hi.z ),
new Vector3( lo.x, hi.y, hi.z ) );
// Solid covers the full box regardless of which faces are drawn
using var solid = Solid( ArchSolid.Box( lo, hi ) );
if ( faces.HasFlag( BoxFaces.Top ) ) Face( new[] { v[4], v[5], v[6], v[7] }, brush, tangent );
if ( faces.HasFlag( BoxFaces.Bottom ) ) Face( new[] { v[0], v[3], v[2], v[1] }, brush, tangent );
if ( faces.HasFlag( BoxFaces.Back ) ) Face( new[] { v[3], v[7], v[6], v[2] }, brush, tangent );
if ( faces.HasFlag( BoxFaces.Front ) ) Face( new[] { v[0], v[1], v[5], v[4] }, brush, tangent );
if ( faces.HasFlag( BoxFaces.Right ) ) Face( new[] { v[1], v[2], v[6], v[5] }, brush, tangent );
if ( faces.HasFlag( BoxFaces.Left ) ) Face( new[] { v[0], v[4], v[7], v[3] }, brush, tangent );
}
public void Prism( IReadOnlyList<Vector3> bottom, IReadOnlyList<Vector3> top, ArchBrush brush, bool cap = true, IReadOnlyList<bool> walls = null, Vector3 tangent = default, ArchBrush? lid = null, Vector3? anchor = null ) {
if ( bottom.Count != top.Count || bottom.Count < 3 ) {
return;
}
if ( Vector3.Dot( Newell( bottom ), Centre( top ) - Centre( bottom ) ) < 0f ) {
bottom = Flipped( bottom );
top = Flipped( top );
walls = Rewound( walls );
}
// Only convex sections get a physics hull — concave would fill the notch
using var solid = Convex( bottom ) && Convex( top ) ? Solid( ArchSolid.Hull( bottom.Concat( top ).ToList() ) ) : null;
for ( var index = 0; index < bottom.Count; index++ ) {
if ( walls is not null && index < walls.Count && !walls[index] ) {
continue;
}
var next = (index + 1) % bottom.Count;
Face( Vertices( bottom[index], bottom[next], top[next], top[index] ), brush, tangent, anchor: anchor );
}
if ( !cap ) {
return;
}
Polygon( bottom, brush, true, tangent, anchor );
// Lid maps level (world grid), sides follow the tangent
Polygon( top, lid ?? brush, false, lid is null ? tangent : default, lid is null ? anchor : null );
}
static bool Convex( IReadOnlyList<Vector3> loop ) {
if ( loop.Count < 3 ) {
return false;
}
if ( loop.Count == 3 ) {
return true;
}
var reference = Vector3.Zero;
for ( var index = 0; index < loop.Count; index++ ) {
var from = loop[(index + 1) % loop.Count] - loop[index];
var to = loop[(index + 2) % loop.Count] - loop[(index + 1) % loop.Count];
var turn = Vector3.Cross( from, to );
if ( turn.Length < 0.01f ) {
continue;
}
if ( reference.IsNearZeroLength ) {
reference = turn.Normal;
continue;
}
if ( Vector3.Dot( turn.Normal, reference ) < 0.99f ) {
return false;
}
}
return !reference.IsNearZeroLength;
}
static List<bool> Rewound( IReadOnlyList<bool> walls ) {
if ( walls is null ) {
return null;
}
var rewound = new List<bool>( walls.Count );
for ( var index = 0; index < walls.Count; index++ ) {
rewound.Add( walls[((walls.Count - 2 - index) % walls.Count + walls.Count) % walls.Count] );
}
return rewound;
}
public void Beam( Vector2 from, Vector2 to, float near, float far, float bottom, float top, ArchBrush brush, float foldFrom = 0f, float foldTo = 0f ) {
var span = to - from;
if ( span.Length < 0.05f || far - near < 0.05f || top - bottom < 0.05f ) {
return;
}
var along = span.Normal;
var outward = ArchRegion.Outward( from, to );
Vector3 Corner( Vector2 end, float offset, float fold ) {
var point = end + outward * offset + along * (fold * offset);
return new Vector3( point.x, point.y, bottom );
}
var lower = new List<Vector3>
{
Corner( from, near, foldFrom ),
Corner( to, near, -foldTo ),
Corner( to, far, -foldTo ),
Corner( from, far, foldFrom )
};
var upper = new List<Vector3>();
foreach ( var point in lower ) {
upper.Add( point.WithZ( top ) );
}
Prism( lower, upper, brush, true, new[]
{
true,
MathF.Abs( foldTo ) < 0.001f,
true,
MathF.Abs( foldFrom ) < 0.001f
} );
}
public void Rake( Vector2 from, Vector2 to, float near, float far, float bottomFrom, float bottomTo, float topFrom, float topTo, ArchBrush brush ) {
if ( (to - from).Length < 0.05f || far - near < 0.05f ) {
return;
}
if ( topFrom - bottomFrom < 0.05f && topTo - bottomTo < 0.05f ) {
return;
}
var outward = ArchRegion.Outward( from, to );
var inner = outward * near;
var outer = outward * far;
var lower = new List<Vector3>
{
new( from.x + inner.x, from.y + inner.y, bottomFrom ),
new( to.x + inner.x, to.y + inner.y, bottomTo ),
new( to.x + outer.x, to.y + outer.y, bottomTo ),
new( from.x + outer.x, from.y + outer.y, bottomFrom )
};
var upper = new List<Vector3>
{
new( from.x + inner.x, from.y + inner.y, topFrom ),
new( to.x + inner.x, to.y + inner.y, topTo ),
new( to.x + outer.x, to.y + outer.y, topTo ),
new( from.x + outer.x, from.y + outer.y, topFrom )
};
Prism( lower, upper, brush );
}
public void Strut( Vector3 from, Vector3 to, float half, ArchBrush brush ) {
var axis = to - from;
if ( axis.Length < 0.05f || half < 0.01f ) {
return;
}
var along = axis.Normal;
var reference = MathF.Abs( along.z ) > 0.95f ? Vector3.Forward : Vector3.Up;
var across = Vector3.Cross( along, reference ).Normal * half;
var up = Vector3.Cross( across.Normal, along ).Normal * half;
var section = new[] { -across - up, across - up, across + up, -across + up };
var near = new List<Vector3>();
var far = new List<Vector3>();
foreach ( var corner in section ) {
near.Add( from + corner );
far.Add( to + corner );
}
Prism( near, far, brush );
}
// Rings offset half a step so even-sided shapes land flats on axes
public void Revolve( Vector3 basePoint, IReadOnlyList<Vector2> silhouette, int sides, float radiusScale, float heightScale, ArchBrush brush, bool caps = true ) {
if ( silhouette is null || silhouette.Count < 2 ) {
return;
}
var count = Math.Max( 3, sides );
var rings = new List<Vector3[]>();
foreach ( var point in silhouette ) {
var ring = new Vector3[count];
for ( var index = 0; index < count; index++ ) {
var angle = (index + 0.5f) / count * MathF.Tau;
var radius = point.x * radiusScale;
ring[index] = basePoint + new Vector3( MathF.Cos( angle ) * radius, MathF.Sin( angle ) * radius, point.y * heightScale );
}
rings.Add( ring );
}
for ( var level = 0; level < rings.Count - 1; level++ ) {
var lower = rings[level];
var upper = rings[level + 1];
for ( var index = 0; index < count; index++ ) {
var next = (index + 1) % count;
Face( Vertices( lower[index], lower[next], upper[next], upper[index] ), brush, default );
}
}
if ( !caps ) {
return;
}
Polygon( rings[0], brush, true );
Polygon( rings[^1], brush );
}
public void Pyramid( Vector3 mins, Vector3 maxs, float apex, ArchBrush brush ) {
var lo = Vector3.Min( mins, maxs );
var hi = Vector3.Max( mins, maxs );
if ( hi.x - lo.x <= 0.01f || hi.y - lo.y <= 0.01f || apex <= 0.01f ) {
return;
}
var top = hi.z + apex;
var centre = (lo + hi) * 0.5f;
var peak = new Vector3( centre.x, centre.y, top );
var corners = new[]
{
new Vector3( lo.x, lo.y, hi.z ),
new Vector3( hi.x, lo.y, hi.z ),
new Vector3( hi.x, hi.y, hi.z ),
new Vector3( lo.x, hi.y, hi.z )
};
for ( var index = 0; index < corners.Length; index++ ) {
var next = (index + 1) % corners.Length;
Face( Vertices( corners[index], corners[next], peak ), brush, default );
}
}
public void Extrude( IReadOnlyList<Vector3> path, ArchProfile profile, float scale, Rotation orientation, ArchBrush brush, bool loop = false ) {
if ( path.Count < 2 || !profile.IsUsable ) {
return;
}
var section = profile.Outward;
var rings = Rings( path, section, scale, orientation, loop );
var segments = loop ? rings.Count : rings.Count - 1;
for ( var index = 0; index < segments; index++ ) {
var near = rings[index];
var far = rings[(index + 1) % rings.Count];
var span = profile.Closed ? near.Length : near.Length - 1;
var tangent = (path[(index + 1) % path.Count] - path[index]).Normal;
for ( var edge = 0; edge < span; edge++ ) {
var next = (edge + 1) % near.Length;
Face( Vertices( near[edge], far[edge], far[next], near[next] ), brush, profile.MapAlongPath ? tangent : default );
}
}
if ( !profile.Capped || !profile.Closed || loop ) {
return;
}
Polygon( rings[0], brush );
Polygon( rings[^1], brush, true );
}
}