Editor/Output/ArchMesh.Primitives.cs

Editor-side mesh builder for architectural geometry. It provides methods to emit quads, ribbons, polygons, prisms, boxes, beams, revolved shapes, pyramids and extrusions into a HalfEdge polygon mesh, and a Tidy pass that dissolves coplanar seams and recomputes texture coordinates.

File AccessNative Interop
using System;
using System.Collections.Generic;
using System.Linq;
using HalfEdgeMesh;
using Sandbox;

namespace Sunless.Architecture;

public sealed partial class ArchMesh
{
	// Idempotent: the contact pass finishes a canvas to read its faces and the scene finishes it again to hand it
	// over. Safe only because that pass REMOVES faces and never merges any, so it leaves no seam for a second sweep.
	public PolygonMesh Finish()
	{
		if ( finished )
		{
			return mesh;
		}

		Tidy();

		mesh.SetSmoothingAngle( 0f );
		finished = true;

		return mesh;
	}

	// The two stages every boolean pipeline ends with, and the reason a carved deck reads as ONE surface rather
	// than a lattice of cells: merge the vertices arithmetic split apart, then dissolve the edges between faces
	// that were only ever one face. Emitting per cell is how a carve stays exact; leaving those seams in the mesh
	// is how it ends up with doubled edges a hair apart and a hundred coplanar tiles. Roles are kept apart - a
	// deck dissolved into a tread would lose its material - so an edge goes only when both sides wear the same
	// material and the same texture axes, which is also what keeps the 128 px/m density intact.
	void Tidy()
	{
		// NO vertex merge here. Welding across the whole canvas fuses solids that merely touch, which puts four
		// faces on one edge - the very thing Welding() is scoped per solid to avoid. Vertices that arithmetic
		// split apart are welded where they are made, in the carve's own vertex table.
		//
		// One edge at a time, sweeping until nothing more will go: merging two cells changes what its neighbours
		// would merge into, so a batch decided up front would take a seam whose answer had already changed.
		for ( var sweep = 0; sweep < Sweeps && Dissolved() > 0; sweep++ )
		{
		}

		// Only what did NOT author its own. A swept strip's whole point is that a projected plane cannot carry a tile
		// across a rib on a bend, so recomputing the lot here undid every weave the sweep had just written.
		mesh.ComputeFaceTextureCoordinatesFromParameters( mesh.FaceHandles.Where( face => !authored.Contains( face ) ) );
	}

	// A lattice collapses a row at a time, so the passes needed are the width of the widest run of cells - and
	// a run wider than this is a wall of tiles nobody will count. PASSES, not seams: stopping after this many
	// dissolves left every canvas past the first two dozen seams as the tile field it was emitted as.
	const int Sweeps = 24;

	// A pass over every seam standing when the pass began, each re-read as it is reached rather than decided up
	// front, and dissolved handles skipped by their own validity.
	// One buffer for every sweep of every canvas: the seams standing at the start of a pass have to be copied out
	// because the pass mutates the mesh, and growing a fresh list per pass was an allocation per sweep per part.
	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;

			// One of the pair: the same seam is offered from both sides.
			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;
			}

			// Not DissolveEdge: that leaves the seam's own ends standing in the merged face, so two rectangles
			// came back as a six-vertex rectangle. This one takes any vertex the merge left holding two
			// collinear edges - which is the same pass that clears a station nothing needs any more.
			mesh.DissolveEdges( new[] { edge }, true, PolygonMesh.DissolveRemoveVertexCondition.Colinear );

			taken++;
		}

		return taken;
	}

	// PROPER QUADS, not merely flat ones: two cells side by side make one quad when their outer edges line up,
	// and an L six corners round when they do not. A dissolve that took every coplanar seam would leave the deck
	// as one ragged n-gon, which is neat in a face count and horrible in a mesh.
	//
	// Corners are counted as VERTICES, not as turns. A rectangle with a neighbour's station sitting in the middle
	// of one side is a seven-sided face however square it measures - the vertices are real, they cost what any
	// vertex costs, and every one of them is a place the surface can crack.
	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 );
	}

	// The seam's own ends go WITH the seam whenever nothing else holds them - three edges before the dissolve is
	// two after, and a vertex left holding two collinear edges is one the dissolve removes. So they are not
	// corners of the answer and counting them refused to merge two rectangles into the one quad they plainly are.
	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 five degrees the engine's own dissolve calls collinear, so whenever this says a vertex goes
	// the dissolve agrees. The other way round is safe: the merge is simply refused.
	const float Straight = 0.9999f;

	// The merged face MEASURED, not inferred from the two normals it was made of. A station manufactured on a
	// slanted edge lands on the vertex grid an eighth of an inch off the line it was cut from, so the two halves
	// of one straight run arrive a fifth of a degree apart - flat enough for any normal test to call them one
	// face, and warped enough that the six-cornered result is not a plane at all. Whether the answer is planar
	// is the question, so it is the thing to ask.
	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 );
	}

	// The grain the audit and the contact pass judge a shared plane by - one answer to "is this one surface".
	const float Coplanar = 0.05f;

	// The merged face's own loop, seam ends KEPT. Dropping them counted the corners of a shape neither face has:
	// two trapezoids either side of a chord read as a quad and dissolved into a hexagon, which is how a ring of
	// four quads round a hole came back as two six-sided faces. Whether a seam end is a corner of the merged face
	// is the whole question - the straightness test answers it once the loop is right.
	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;
	}

	// The loop opened at the seam: starts where the seam ends and runs the long way round back to where it began.
	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 );
	}

	// UVs run ALONG the strip, so the texture does not shear round a bend.
	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 )
	{
		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 );
	}

	// A triangle - the one face a quad grid cannot avoid where it closes on a point.
	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 ) );

		// The whole box, whichever of its faces were asked for: a box with its bottom left off is still a box to
		// stand on, and physics has no business knowing which faces the mesh drew.
		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 );
	}

	// Winding normalised here - a ring wound against the sweep turns every side inside out.
	// A side left out by `walls` is a face buried in whatever meets it there - a mitred board's end, which
	// the board round the corner covers exactly. Emitting it anyway is a coplanar pair, and the pair puts a
	// third face on every edge around it.
	// A prism standing on a run of its own - a portal band, a coping - hands over the direction it RUNS IN, or its
	// faces are mapped to the world's axes and the courses cross a band laid at any angle but a right one diagonally.
	public void Prism( IReadOnlyList<Vector3> bottom, IReadOnlyList<Vector3> top, ArchBrush brush, bool cap = true, IReadOnlyList<bool> walls = null, Vector3 tangent = default )
	{
		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 );
		}

		// A prism is convex only where its SECTION is: swept from an L, the hull over its corners fills the notch,
		// and a wall panel that quietly grew into its own doorway is worse than no solid at all.
		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 );
		}

		if ( !cap )
		{
			return;
		}

		Polygon( bottom, brush, true, tangent );
		Polygon( top, brush, false, tangent );
	}

	// Every turn the same way round, in the loop's own plane. Read off the cross products rather than a winding
	// answer, because a section arriving either way round is normalised above and both are convex or neither is.
	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;
	}

	// Reversing a loop reverses its edges too: edge i of the reversed loop is edge n-2-i of the original.
	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;
	}

	// Lofted, not boxed - a box over a diagonal line came out a square block.
	// The folds are ArchBandGen.Fold's, per inch of offset: with both zero this is a square-ended box, and
	// with a corner's own fold at each end the two boards round it meet on one bisector plane. A folded end
	// grows no cap - the board coming the other way covers it - which is ArchBandGen.Run's rule as well.
	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
		} );
	}

	// Each end carries its own bottom and top, so a rail on a slope is one lofted board.
	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 );
	}

	// Section square to its own axis - Beam and Rake hold theirs upright, shearing a diagonal.
	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 );
	}

	// (radius, height) pairs read bottom to top - a baluster is authored as its side-on silhouette.
	// Rings start half a step round, so an even side count lands flats square to the 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 );
	}

	// Hipped cap - a box plus a lid would z-fight.
	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;
		}

		// Clockwise section winds against the path - start cap as authored, only the end flips.
		Polygon( rings[0], brush );
		Polygon( rings[^1], brush, true );
	}

}