Cards/CardMesh.cs

Static helper that procedurally builds a two-sided rounded-rectangle card mesh (shared Model) at a global size. It constructs vertex/index lists for front face, back face and rim, computes UVs so the front samples left half of an atlas and back samples right half, and exposes SetSize/Invalidate to rebuild the shared mesh.

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

namespace CardGames;

/// <summary>
/// Builds the two-sided card model in code (no .vmdl asset needed). Each face is a rounded
/// rectangle (a triangle fan around the centre) so the card has real rounded corners like a
/// physical card. The top face (front) samples the LEFT half of the atlas, the bottom face
/// (back) the RIGHT half. Each card overrides the material per-instance.
///
/// The size lives here as shared static state on purpose: there is one mesh shared by every card, so
/// there's one size. The active game sets it (<see cref="SetSize"/>) when it enables. This would only
/// be wrong with two differently-sized games live at once - impossible today (one active game), so it
/// stays a global rather than threading a size through every layout/collider call site.
/// </summary>
public static class CardMesh
{
	public const float DefaultWidth = 5.5f;   // world units (s&box is inches)

	/// <summary>
	/// Current card width. Games can shrink/grow cards via <see cref="SetSize"/>; layout reads this.
	/// </summary>
	public static float Width { get; private set; } = DefaultWidth;
	public static float Height => Width / CardFaceRenderer.Aspect;
	private static float CornerRadius => Width * 0.08f;

	private const int CornerSegments = 6;     // smoothness of each rounded corner
	private const float HalfThickness = 0.025f; // half the card's thickness (the side rim spans both faces)
	private const float EdgeBevel = 0.45f;    // how much the rim normal tilts toward the faces (soft rounded-edge highlight)
	private static readonly Vector4 RimUv = new( 0.004f, 0.5f, 0, 0 ); // sample the face-colour border for the edge

	private static Model _shared;
	public static Model Shared => _shared ??= Build();

	/// <summary>
	/// Drop the cached model so it rebuilds (e.g. after changing the size while iterating).
	/// </summary>
	public static void Invalidate() => _shared = null;

	/// <summary>
	/// Set the card world size (all cards share one mesh). Rebuilds on the next access.
	/// </summary>
	public static void SetSize( float width )
	{
		width = MathF.Max( width, 0.1f );
		if ( MathF.Abs( width - Width ) < 0.001f ) return;
		Width = width;
		_shared = null; // rebuild at the new size
	}

	private static Model Build()
	{
		var placeholder = Material.Create( "card_placeholder", "complex.shader" );
		var mesh = new Mesh( placeholder );

		float hw = Width / 2f, hh = Height / 2f;
		float r = MathF.Min( CornerRadius, MathF.Min( hw, hh ) );
		var outline = RoundedRectOutline( hw, hh, r );

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

		AddFan( verts, indices, outline, HalfThickness, Vector3.Up, new Vector3( 1, 0, 0 ), front: true, hw, hh );
		AddFan( verts, indices, outline, -HalfThickness, Vector3.Down, new Vector3( -1, 0, 0 ), front: false, hw, hh );
		AddRim( verts, indices, outline, HalfThickness, EdgeBevel );

		mesh.CreateVertexBuffer( verts.Count, verts );
		mesh.CreateIndexBuffer( indices.Count, indices );
		mesh.Bounds = BBox.FromPositionAndSize( Vector3.Zero, new Vector3( Width, Height, HalfThickness * 2 ) );

		return Model.Builder.AddMesh( mesh ).Create();
	}

	// Rounded-rectangle outline, counter-clockwise as seen from +Z.
	private static List<Vector2> RoundedRectOutline( float hw, float hh, float r )
	{
		var corners = new (Vector2 center, float startDeg)[]
		{
			(new Vector2( hw - r, hh - r ), 0f),     // top-right
			(new Vector2( -(hw - r), hh - r ), 90f), // top-left
			(new Vector2( -(hw - r), -(hh - r) ), 180f), // bottom-left
			(new Vector2( hw - r, -(hh - r) ), 270f),    // bottom-right
		};

		var pts = new List<Vector2>();
		foreach ( var (center, startDeg) in corners )
		{
			for ( int j = 0; j <= CornerSegments; j++ )
			{
				float a = MathX.DegreeToRadian( startDeg + 90f * j / CornerSegments );
				pts.Add( center + new Vector2( MathF.Cos( a ), MathF.Sin( a ) ) * r );
			}
		}
		return pts;
	}

	// Triangle fan around the face centre. Back faces flip winding so the normal points the other way.
	private static void AddFan( List<Vertex> verts, List<int> indices, List<Vector2> outline,
		float z, Vector3 normal, Vector3 tangent, bool front, float hw, float hh )
	{
		int center = verts.Count;
		verts.Add( MakeVertex( Vector2.Zero, z, normal, tangent, front, hw, hh ) );

		int first = verts.Count;
		foreach ( var p in outline )
			verts.Add( MakeVertex( p, z, normal, tangent, front, hw, hh ) );

		int n = outline.Count;
		for ( int i = 0; i < n; i++ )
		{
			int a = first + i;
			int b = first + (i + 1) % n;
			if ( front ) { indices.Add( center ); indices.Add( a ); indices.Add( b ); }
			else { indices.Add( center ); indices.Add( b ); indices.Add( a ); }
		}
	}

	// Connects the front and back outlines with a side wall so the card has real thickness. The rim
	// normals tilt slightly toward each face (a soft chamfer) so the sun catches the edge rather than
	// it reading as a flat decal. The whole rim samples the face-colour border of the texture.
	private static void AddRim( List<Vertex> verts, List<int> indices, List<Vector2> outline, float t, float bevel )
	{
		int n = outline.Count;
		int top = verts.Count;
		for ( int i = 0; i < n; i++ )
			verts.Add( RimVertex( outline, i, n, t, bevel ) );

		int bot = verts.Count;
		for ( int i = 0; i < n; i++ )
			verts.Add( RimVertex( outline, i, n, -t, bevel ) );

		for ( int i = 0; i < n; i++ )
		{
			int a = i, b = (i + 1) % n;
			// Outward-facing winding (verified against a +Y edge): (topA, botA, topB), (topB, botA, botB).
			indices.Add( top + a ); indices.Add( bot + a ); indices.Add( top + b );
			indices.Add( top + b ); indices.Add( bot + a ); indices.Add( bot + b );
		}
	}

	private static Vertex RimVertex( List<Vector2> outline, int i, int n, float z, float bevel )
	{
		var p = outline[i];
		var prev = outline[(i - 1 + n) % n];
		var next = outline[(i + 1) % n];
		var tan = next - prev;                          // CCW travel direction
		var outward = new Vector2( tan.y, -tan.x ).Normal; // outward = to the right of CCW travel
		var normal = new Vector3( outward.x, outward.y, z > 0 ? bevel : -bevel ).Normal;
		return new Vertex( new Vector3( p.x, p.y, z ), normal, new Vector3( outward.x, outward.y, 0 ), RimUv );
	}

	private static Vertex MakeVertex( Vector2 p, float z, Vector3 normal, Vector3 tangent, bool front, float hw, float hh )
	{
		float u = (p.x + hw) / (2f * hw) * 0.5f; // front → [0 .. 0.5]
		float v = (hh - p.y) / (2f * hh);        // v=0 at the top (+Y)
		if ( !front ) u = 1f - u;                // back → [0.5 .. 1]
		return new Vertex( new Vector3( p.x, p.y, z ), normal, tangent, new Vector4( u, v, 0, 0 ) );
	}
}