Doors/DebrisMesh.cs

Utility class that builds a 3D prism model and collision from a 2D polygon footprint. It triangulates a possibly concave polygon via ear clipping, creates top and bottom caps, builds wall quads, sets mesh/model bounds, and adds per-triangle convex collision hulls.

Native Interop
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// Turns a drawn footprint into a solid barrier — mesh and collision.
///
/// ⛔ THE FOOTPRINT IS THE SHAPE, NOT A HINT AT ONE. Blocks used to be an
/// oriented BOX measured from the clicked corners, so four corners describing an
/// L, a wedge or any non-rectangle were flattened into the rectangle that
/// enclosed them. Reported as "it always makes a rectangle with the 2 furthest
/// from each other" — which is what an enclosing box looks like when the corners
/// were never meant to be one.
///
/// Now the polygon is extruded verbatically: top and bottom faces triangulated,
/// one quad per edge, and convex collision pieces per triangle.
///
/// ⚠️ CONCAVE IS SUPPORTED, which is why this triangulates by ear clipping
/// rather than fanning from a corner. A fan is two lines shorter and silently
/// fills in any dent the polygon has — the exact failure being fixed.
/// </summary>
public static class DebrisMesh
{
	/// <summary>
	/// Build a prism from a footprint.
	///
	/// <paramref name="local"/> is the footprint in the barrier's own space, XY,
	/// centred on its origin. The prism runs from z=0 up to <paramref name="height"/>.
	/// </summary>
	public static Model Build( IReadOnlyList<Vector2> local, float height, Material material )
	{
		if ( local is null || local.Count < 3 ) return null;

		// Ear clipping needs a known winding, and the caller has no reason to
		// guarantee one — corners get clicked whichever way round the player
		// walks. Normalise to counter-clockwise rather than demand it.
		var poly = local.ToList();
		if ( SignedArea( poly ) < 0f ) poly.Reverse();

		var tris = Triangulate( poly );
		if ( tris.Count == 0 ) return null;

		var verts = new List<Vertex>();
		var idx = new List<int>();

		AddCap( verts, idx, poly, tris, height, top: true );
		AddCap( verts, idx, poly, tris, 0f, top: false );
		AddWalls( verts, idx, poly, height );

		// ⛔ BOUNDS ARE NOT DERIVED FROM THE VERTEX BUFFER — THEY MUST BE SET.
		//
		// `Mesh.Bounds` is documented as "Sets AABB bounds for this mesh", and
		// `ModelBuilder.WithViewBounds` as "if not set, the bounds are calculated
		// from the model's meshes" — so leaving the mesh's bounds empty leaves the
		// MODEL's bounds empty, and the renderer frustum-culls against an empty
		// box at the origin. The barrier then renders only when that one point
		// happens to be on screen, which from the outside is "the debris is
		// sometimes invisible depending on my distance to it or angle".
		//
		// ⚠️ Nothing about this fails loudly. There is no warning, no pink error
		// model, and the mesh is perfectly correct — it is simply not submitted.
		// Any runtime-built mesh in this project needs this.
		var lo = new Vector3( float.MaxValue );
		var hi = new Vector3( float.MinValue );

		foreach ( var p in poly )
		{
			lo = Vector3.Min( lo, new Vector3( p.x, p.y, 0f ) );
			hi = Vector3.Max( hi, new Vector3( p.x, p.y, height ) );
		}

		var bounds = new BBox( lo, hi );

		var mesh = new Mesh( material );
		mesh.CreateVertexBuffer( verts.Count, verts );
		mesh.CreateIndexBuffer( idx.Count, idx );

		// After the buffers, not before — creating a buffer is exactly the kind of
		// call that would recompute or clear whatever was set beforehand.
		mesh.Bounds = bounds;

		var builder = Model.Builder.AddMesh( mesh );

		// ⚠️ ONE CONVEX HULL PER TRIANGLE, NOT ONE FOR THE WHOLE SHAPE. A single
		// hull over every point is the convex hull — for a concave footprint that
		// is exactly the over-filled box this class exists to avoid, just with
		// more steps. Per-triangle prisms are individually convex, so together
		// they match the drawn shape however dented it is.
		foreach ( var t in tris )
		{
			builder.AddCollisionHull( new List<Vector3>
			{
				new( poly[t.a].x, poly[t.a].y, 0f ),
				new( poly[t.b].x, poly[t.b].y, 0f ),
				new( poly[t.c].x, poly[t.c].y, 0f ),
				new( poly[t.a].x, poly[t.a].y, height ),
				new( poly[t.b].x, poly[t.b].y, height ),
				new( poly[t.c].x, poly[t.c].y, height ),
			}, null, null );
		}

		// ⚠️ STATED ON THE MODEL TOO, not left to be derived from the mesh above.
		// These are the documented overrides — view bounds drive visibility, hull
		// bounds drive "physics and gameplay queries", which includes the traces
		// the tool aims with and the reach check that decides whether a barrier
		// can be bought. Saying both outright costs two lines and removes the
		// whole class of bug where a barrier is there but nothing can see or
		// find it.
		builder.WithViewBounds( bounds );
		builder.WithHullBounds( bounds );

		return builder.Create();
	}

	/// <summary>
	/// Does the outline cross over itself?
	///
	/// ⚠️ CHECKED BEFORE STORING A FOOTPRINT, not after. Corners are used in the
	/// order they were CLICKED — that is what makes "the shape I draw" the shape
	/// you get — but clicking the four corners of a doorway diagonally gives a
	/// bowtie, which has no interior and no valid ear to clip. Triangulate() only
	/// bails out silently; this lets the caller say what went wrong and how to fix
	/// it, which is the difference between a confusing result and a typo.
	/// </summary>
	public static bool IsSimple( IReadOnlyList<Vector2> p )
	{
		if ( p is null || p.Count < 3 ) return false;

		for ( int i = 0; i < p.Count; i++ )
		{
			var a1 = p[i];
			var a2 = p[(i + 1) % p.Count];

			for ( int j = i + 1; j < p.Count; j++ )
			{
				// Edges that share a corner always "touch" — only edges with no
				// corner in common can cross.
				if ( (j + 1) % p.Count == i ) continue;
				if ( j == (i + 1) % p.Count ) continue;

				if ( Crosses( a1, a2, p[j], p[(j + 1) % p.Count] ) )
					return false;
			}
		}

		return true;
	}

	static bool Crosses( Vector2 a1, Vector2 a2, Vector2 b1, Vector2 b2 )
	{
		float d1 = Cross( b2 - b1, a1 - b1 );
		float d2 = Cross( b2 - b1, a2 - b1 );
		float d3 = Cross( a2 - a1, b1 - a1 );
		float d4 = Cross( a2 - a1, b2 - a1 );

		return ((d1 > 0f) != (d2 > 0f)) && ((d3 > 0f) != (d4 > 0f));
	}

	static float Cross( Vector2 a, Vector2 b ) => (a.x * b.y) - (a.y * b.x);

	/// <summary>
	/// Does the outline turn the same way at every corner?
	///
	/// Only asked so the tool can warn about the one place the drawn shape and
	/// the barrier's behaviour still disagree: the nav block is a box, so a
	/// concave shape blocks pathing through its own notch. Convex shapes have no
	/// notch, so nothing to warn about.
	/// </summary>
	public static bool IsConvex( IReadOnlyList<Vector2> p )
	{
		if ( p is null || p.Count < 4 ) return true;

		bool neg = false, pos = false;

		for ( int i = 0; i < p.Count; i++ )
		{
			var a = p[i];
			var b = p[(i + 1) % p.Count];
			var c = p[(i + 2) % p.Count];

			float turn = Cross( b - a, c - b );

			// Three points in a line turn neither way — a corner clicked onto an
			// edge is not a dent, and counting it as one would warn on shapes that
			// are perfectly convex.
			if ( turn > 0.01f ) pos = true;
			else if ( turn < -0.01f ) neg = true;

			if ( neg && pos ) return false;
		}

		return true;
	}

	readonly record struct Tri( int a, int b, int c );

	/// <summary>Positive for counter-clockwise. Also the area, which is why a
	/// degenerate footprint can be spotted with the same number.</summary>
	static float SignedArea( IReadOnlyList<Vector2> p )
	{
		float sum = 0f;
		for ( int i = 0; i < p.Count; i++ )
		{
			var a = p[i];
			var b = p[(i + 1) % p.Count];
			sum += (a.x * b.y) - (b.x * a.y);
		}
		return sum * 0.5f;
	}

	/// <summary>
	/// Ear clipping. O(n²), which for a footprint someone clicked out by hand is
	/// nothing — the alternative algorithms only start paying at hundreds of
	/// points.
	/// </summary>
	static List<Tri> Triangulate( List<Vector2> p )
	{
		var tris = new List<Tri>();
		var live = Enumerable.Range( 0, p.Count ).ToList();

		// ⚠️ GUARDED AGAINST NEVER FINDING AN EAR. A self-intersecting footprint
		// — two clicks that cross the shape over itself — has no valid ear, and
		// without this the loop would spin forever and hang the editor on a
		// misclick. Bail out and let the caller fall back to a box.
		int guard = p.Count * p.Count + 8;

		while ( live.Count > 3 && guard-- > 0 )
		{
			bool clipped = false;

			for ( int i = 0; i < live.Count; i++ )
			{
				int i0 = live[(i - 1 + live.Count) % live.Count];
				int i1 = live[i];
				int i2 = live[(i + 1) % live.Count];

				if ( !IsEar( p, live, i0, i1, i2 ) ) continue;

				tris.Add( new Tri( i0, i1, i2 ) );
				live.RemoveAt( i );
				clipped = true;
				break;
			}

			if ( !clipped ) break;
		}

		if ( live.Count == 3 )
			tris.Add( new Tri( live[0], live[1], live[2] ) );

		return tris;
	}

	static bool IsEar( List<Vector2> p, List<int> live, int i0, int i1, int i2 )
	{
		var a = p[i0];
		var b = p[i1];
		var c = p[i2];

		// Reflex corners are not ears. Cross <= 0 on a CCW polygon means the
		// corner points into the shape.
		float cross = ((b.x - a.x) * (c.y - a.y)) - ((b.y - a.y) * (c.x - a.x));
		if ( cross <= 0f ) return false;

		// And no other vertex may sit inside the candidate triangle, or clipping
		// it would cut across the polygon.
		foreach ( var k in live )
		{
			if ( k == i0 || k == i1 || k == i2 ) continue;
			if ( InTriangle( p[k], a, b, c ) ) return false;
		}

		return true;
	}

	static bool InTriangle( Vector2 pt, Vector2 a, Vector2 b, Vector2 c )
	{
		float d1 = Side( pt, a, b );
		float d2 = Side( pt, b, c );
		float d3 = Side( pt, c, a );

		bool neg = d1 < 0f || d2 < 0f || d3 < 0f;
		bool pos = d1 > 0f || d2 > 0f || d3 > 0f;

		return !(neg && pos);
	}

	static float Side( Vector2 p, Vector2 a, Vector2 b )
		=> ((p.x - b.x) * (a.y - b.y)) - ((a.x - b.x) * (p.y - b.y));

	/// <summary>Top or bottom face. The bottom is wound backwards so it faces
	/// down — a cap wound the same way both ends is invisible from below.</summary>
	static void AddCap( List<Vertex> verts, List<int> idx, List<Vector2> poly,
		List<Tri> tris, float z, bool top )
	{
		var normal = top ? Vector3.Up : Vector3.Down;
		var tangent = Vector3.Forward;
		int start = verts.Count;

		foreach ( var v in poly )
		{
			// UVs from world units so texture scale stays constant whatever size
			// the barrier is drawn at — 128 units per tile.
			verts.Add( new Vertex( new Vector3( v.x, v.y, z ), normal, tangent,
				new Vector4( v.x / 128f, v.y / 128f, 0f, 0f ) ) );
		}

		foreach ( var t in tris )
		{
			if ( top )
			{
				idx.Add( start + t.a ); idx.Add( start + t.b ); idx.Add( start + t.c );
			}
			else
			{
				idx.Add( start + t.c ); idx.Add( start + t.b ); idx.Add( start + t.a );
			}
		}
	}

	/// <summary>One quad per edge, each with its own vertices so the sides get a
	/// hard normal instead of being smoothed into the caps.</summary>
	static void AddWalls( List<Vertex> verts, List<int> idx, List<Vector2> poly, float height )
	{
		float run = 0f;

		for ( int i = 0; i < poly.Count; i++ )
		{
			var a = poly[i];
			var b = poly[(i + 1) % poly.Count];

			var edge = b - a;
			float len = edge.Length;
			if ( len < 0.01f ) continue;

			// Outward normal for a CCW polygon is the edge turned right.
			var n = new Vector3( edge.y, -edge.x, 0f ).Normal;
			var tangent = new Vector3( edge.x, edge.y, 0f ).Normal;

			int s = verts.Count;
			float u0 = run / 128f;
			float u1 = (run + len) / 128f;
			float v1 = height / 128f;

			verts.Add( new Vertex( new Vector3( a.x, a.y, 0f ), n, tangent, new Vector4( u0, 0f, 0f, 0f ) ) );
			verts.Add( new Vertex( new Vector3( b.x, b.y, 0f ), n, tangent, new Vector4( u1, 0f, 0f, 0f ) ) );
			verts.Add( new Vertex( new Vector3( b.x, b.y, height ), n, tangent, new Vector4( u1, v1, 0f, 0f ) ) );
			verts.Add( new Vertex( new Vector3( a.x, a.y, height ), n, tangent, new Vector4( u0, v1, 0f, 0f ) ) );

			idx.Add( s + 0 ); idx.Add( s + 1 ); idx.Add( s + 2 );
			idx.Add( s + 0 ); idx.Add( s + 2 ); idx.Add( s + 3 );

			run += len;
		}
	}
}