Editor/Prism/Preview/PreviewMeshes.cs

Editor utility that procedurally builds and caches preview meshes (sphere, cube, plane, quad, cylinder, cone, torus, ground) for the Prism shader editor. It constructs Mesh objects with full vertex data, creates index buffers, loads materials, caches models, and exposes named presets and icons.

File AccessNative Interop
using Editor.Prism.Core;
using EngineModel = Sandbox.Model;

namespace Editor.Prism.Preview;

/// <summary>
/// The preview geometry Prism generates at runtime.
/// <para>
/// Engine content has no mesh good enough to author a shader against: <c>models/dev/sphere.vmdl</c>
/// is far too low-poly for normal mapping or vertex displacement to read, and there is no cylinder,
/// cone or torus at all. So every preview primitive here is built from scratch with a full vertex
/// format — position, normal, a proper tangent frame, two independent texture coordinate sets and a
/// vertex colour — which is exactly what a shader graph needs to exercise.
/// </para>
/// <para>
/// Winding follows the engine's convention, verified against the stock preview meshes: a triangle
/// <c>A, B, C</c> is front-facing when <c>(B - A) × (C - A)</c> points the same way as its normal.
/// Tangents carry <c>w = -1</c>, matching every mesh the engine ships, so a normal map sampled in the
/// preview orients the same way it will in game.
/// </para>
/// <para>
/// Models are built lazily and cached for the lifetime of the assembly. <see cref="Flush"/> drops the
/// cache and must run on hotload, because a <c>Model</c> holds native resources bound to the outgoing
/// assembly's material handles.
/// </para>
/// </summary>
public static class PreviewMeshes
{
	/// <summary>Radius of the generated sphere, and the half-extent everything else is sized against.</summary>
	public const float Radius = 32f;

	/// <summary>Longitude segments of the generated sphere.</summary>
	public const int SphereSegments = 64;

	/// <summary>Latitude segments of the generated sphere.</summary>
	public const int SphereRings = 64;

	/// <summary>The material a generated preview mesh carries before an override is applied.</summary>
	public const string DefaultMaterial = "materials/core/shader_editor.vmat";

	/// <summary>The material the ground plane is drawn with.</summary>
	public const string GroundMaterial = "materials/dev/gray_grid_8.vmat";

	static readonly Dictionary<string, EngineModel> s_cache = new( StringComparer.OrdinalIgnoreCase );
	static readonly object s_lock = new();

	/// <summary>
	/// Every built-in mesh name, in toolbar order. A superset of
	/// <see cref="Model.PreviewState.Meshes"/> — anything not in that list still round-trips, because
	/// the document stores the name verbatim.
	/// </summary>
	public static IReadOnlyList<string> Names { get; } =
	[
		"Sphere", "Cube", "Plane", "Quad", "Cylinder", "Cone", "Torus"
	];

	/// <summary>A material icon for each built-in mesh, for the viewport toolbar.</summary>
	public static string IconFor( string name ) => ( name ?? string.Empty ).ToLowerInvariant() switch
	{
		"cube" or "box" => "view_in_ar",
		"plane" => "crop_landscape",
		"quad" => "crop_square",
		"cylinder" => "database",
		"cone" => "change_history",
		"torus" => "donut_large",
		_ => "circle"
	};

	/// <summary>A 64x64 tessellated UV sphere with a continuous tangent frame.</summary>
	public static EngineModel Sphere => Get( "Sphere" );

	/// <summary>A cube with hard edges: 24 vertices, per-face normals and tangents, 0..1 UVs per face.</summary>
	public static EngineModel Cube => Get( "Cube" );

	/// <summary>A flat square lying in the XY plane, facing up.</summary>
	public static EngineModel Plane => Get( "Plane" );

	/// <summary>A square standing in the YZ plane, facing the default camera. The flat-shader view.</summary>
	public static EngineModel Quad => Get( "Quad" );

	/// <summary>A capped cylinder standing on the Z axis.</summary>
	public static EngineModel Cylinder => Get( "Cylinder" );

	/// <summary>A capped cone standing on the Z axis.</summary>
	public static EngineModel Cone => Get( "Cone" );

	/// <summary>A torus lying in the XY plane. The best primitive for reading a tangent frame.</summary>
	public static EngineModel Torus => Get( "Torus" );

	/// <summary>The grid-textured ground plane drawn under the subject.</summary>
	public static EngineModel Ground => Get( "Ground" );

	/// <summary>
	/// The model for a built-in mesh name, falling back to the sphere for anything unrecognised and to
	/// the engine's own primitives if generation fails outright. Never returns null and never throws.
	/// </summary>
	public static EngineModel Resolve( string name )
	{
		var model = Get( string.IsNullOrWhiteSpace( name ) ? "Sphere" : name.Trim() );

		if ( model is not null ) return model;

		return PrismLog.Guard( "Loading the fallback preview model", () => EngineModel.Sphere, null );
	}

	/// <summary>Build, or fetch from the cache, one named mesh. Returns null only if generation failed.</summary>
	public static EngineModel Get( string name )
	{
		if ( string.IsNullOrWhiteSpace( name ) ) name = "Sphere";

		lock ( s_lock )
		{
			if ( s_cache.TryGetValue( name, out var cached ) && cached is not null ) return cached;

			var built = PrismLog.Guard( $"Building the '{name}' preview mesh", () => Build( name ), null );

			if ( built is null && !string.Equals( name, "Sphere", StringComparison.OrdinalIgnoreCase ) )
			{
				// A broken primitive must not take the whole viewport down with it.
				built = PrismLog.Guard( "Building the fallback preview mesh", () => Build( "Sphere" ), null );
			}

			if ( built is not null ) s_cache[name] = built;

			return built;
		}
	}

	/// <summary>Drop every cached model. Must run on hotload; the models hold native material handles.</summary>
	public static void Flush()
	{
		lock ( s_lock )
		{
			s_cache.Clear();
		}
	}

	/// <summary>Flushes the cache when the editor hotloads this assembly.</summary>
	[EditorEvent.Hotload]
	static void OnHotload() => Flush();

	// ---- construction ------------------------------------------------------

	static EngineModel Build( string name ) => ( name ?? string.Empty ).ToLowerInvariant() switch
	{
		"cube" or "box" => FromMesh( BuildCube( Radius ) ),
		"plane" => FromMesh( BuildPlane( Radius * 4f, 1f, DefaultMaterial ) ),
		"ground" => FromMesh( BuildPlane( Radius * 12f, 8f, GroundMaterial ) ),
		"quad" => FromMesh( BuildQuad( Radius ) ),
		"cylinder" => FromMesh( BuildCylinder( Radius * 0.75f, Radius * 2f, 64 ) ),
		"cone" => FromMesh( BuildCone( Radius * 0.9f, Radius * 2f, 64 ) ),
		"torus" => FromMesh( BuildTorus( Radius * 0.75f, Radius * 0.3f, 96, 48 ) ),
		_ => FromMesh( BuildSphere( SphereSegments, SphereRings, Radius ) )
	};

	static EngineModel FromMesh( Mesh mesh )
	{
		if ( mesh is null ) return null;

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

	static Material LoadMaterial( string path )
	{
		var material = PrismLog.Guard( $"Loading '{path}'", () => Material.Load( path ), null );

		return material ?? PrismLog.Guard( "Loading the error material", () => Material.Load( "materials/dev/reflectivity_50.vmat" ), null );
	}

	static Mesh NewMesh( string material ) => new( LoadMaterial( material ) );

	/// <summary>
	/// A UV sphere. The parameterisation walks longitude on the outer loop and latitude on the inner
	/// one, which is what makes the grid indices below wind outward.
	/// </summary>
	static Mesh BuildSphere( int segments, int rings, float radius )
	{
		segments = Math.Clamp( segments, 3, 512 );
		rings = Math.Clamp( rings, 2, 512 );

		var mesh = NewMesh( DefaultMaterial );
		var columns = rings + 1;          // latitude samples, pole to pole
		var rows = segments + 1;          // longitude samples, all the way around

		mesh.CreateVertexBuffer<Vertex>( rows * columns );
		mesh.CreateIndexBuffer( 6 * segments * rings );
		mesh.Bounds = BBox.FromPositionAndSize( Vector3.Zero, radius * 2f );

		mesh.LockVertexBuffer<Vertex>( vertices =>
		{
			var i = 0;

			for ( int row = 0; row < rows; row++ )
			{
				var phi01 = row / (float)segments;
				var phi = phi01 * MathF.PI * 2f;
				var sinPhi = MathF.Sin( phi );
				var cosPhi = MathF.Cos( phi );

				for ( int column = 0; column < columns; column++ )
				{
					var theta01 = column / (float)rings;
					var theta = theta01 * MathF.PI;
					var sinTheta = MathF.Sin( theta );
					var cosTheta = MathF.Cos( theta );

					var normal = new Vector3( sinTheta * cosPhi, sinTheta * sinPhi, cosTheta );
					var tangent = new Vector3( -sinPhi, cosPhi, 0f ).Normal;

					vertices[i++] = MakeVertex( normal * radius, normal, tangent,
						new Vector2( phi01, theta01 ), normal.z * 0.5f + 0.5f );
				}
			}
		} );

		// Rows walk longitude and columns walk latitude, so the grid's default winding faces inward here.
		mesh.LockIndexBuffer( indices => Grid( indices, rows, columns, true ) );

		return mesh;
	}

	/// <summary>A cube with split vertices so each face keeps its own normal, tangent and UV square.</summary>
	static Mesh BuildCube( float extent )
	{
		var mesh = NewMesh( DefaultMaterial );

		mesh.CreateVertexBuffer<Vertex>( 24 );
		mesh.CreateIndexBuffer( 36 );
		mesh.Bounds = BBox.FromPositionAndSize( Vector3.Zero, extent * 2f );

		// right x up == normal, so the quad below always winds outward.
		var faces = new (Vector3 Normal, Vector3 Right, Vector3 Up)[]
		{
			(new Vector3( 1, 0, 0 ),  new Vector3( 0, 1, 0 ),  new Vector3( 0, 0, 1 )),
			(new Vector3( -1, 0, 0 ), new Vector3( 0, -1, 0 ), new Vector3( 0, 0, 1 )),
			(new Vector3( 0, 1, 0 ),  new Vector3( 0, 0, 1 ),  new Vector3( 1, 0, 0 )),
			(new Vector3( 0, -1, 0 ), new Vector3( 0, 0, -1 ), new Vector3( 1, 0, 0 )),
			(new Vector3( 0, 0, 1 ),  new Vector3( 1, 0, 0 ),  new Vector3( 0, 1, 0 )),
			(new Vector3( 0, 0, -1 ), new Vector3( -1, 0, 0 ), new Vector3( 0, 1, 0 ))
		};

		mesh.LockVertexBuffer<Vertex>( vertices =>
		{
			var i = 0;

			foreach ( var (normal, right, up) in faces )
			{
				var centre = normal * extent;

				vertices[i++] = MakeVertex( centre - right * extent - up * extent, normal, right, new Vector2( 0, 1 ), 0f );
				vertices[i++] = MakeVertex( centre + right * extent - up * extent, normal, right, new Vector2( 1, 1 ), 0.33f );
				vertices[i++] = MakeVertex( centre + right * extent + up * extent, normal, right, new Vector2( 1, 0 ), 0.66f );
				vertices[i++] = MakeVertex( centre - right * extent + up * extent, normal, right, new Vector2( 0, 0 ), 1f );
			}
		} );

		mesh.LockIndexBuffer( indices =>
		{
			var i = 0;

			for ( int face = 0; face < 6; face++ )
			{
				var b = face * 4;

				indices[i++] = b;
				indices[i++] = b + 1;
				indices[i++] = b + 2;
				indices[i++] = b;
				indices[i++] = b + 2;
				indices[i++] = b + 3;
			}
		} );

		return mesh;
	}

	/// <summary>A flat square in the XY plane facing up, with <paramref name="tiling"/> UV repeats.</summary>
	static Mesh BuildPlane( float extent, float tiling, string material )
	{
		var mesh = NewMesh( material );

		mesh.CreateVertexBuffer<Vertex>( 4 );
		mesh.CreateIndexBuffer( 6 );
		mesh.Bounds = BBox.FromPositionAndSize( Vector3.Zero, new Vector3( extent * 2f, extent * 2f, 1f ) );

		var normal = Vector3.Up;
		var tangent = Vector3.Forward;

		mesh.LockVertexBuffer<Vertex>( vertices =>
		{
			vertices[0] = MakeVertex( new Vector3( -extent, -extent, 0 ), normal, tangent, new Vector2( 0, tiling ), 0f );
			vertices[1] = MakeVertex( new Vector3( extent, -extent, 0 ), normal, tangent, new Vector2( tiling, tiling ), 0.33f );
			vertices[2] = MakeVertex( new Vector3( extent, extent, 0 ), normal, tangent, new Vector2( tiling, 0 ), 0.66f );
			vertices[3] = MakeVertex( new Vector3( -extent, extent, 0 ), normal, tangent, new Vector2( 0, 0 ), 1f );
		} );

		mesh.LockIndexBuffer( indices =>
		{
			indices[0] = 0;
			indices[1] = 1;
			indices[2] = 2;
			indices[3] = 0;
			indices[4] = 2;
			indices[5] = 3;
		} );

		return mesh;
	}

	/// <summary>A square standing in the YZ plane facing -X, so the default camera looks straight at it.</summary>
	static Mesh BuildQuad( float extent )
	{
		var mesh = NewMesh( DefaultMaterial );

		mesh.CreateVertexBuffer<Vertex>( 4 );
		mesh.CreateIndexBuffer( 6 );
		mesh.Bounds = BBox.FromPositionAndSize( Vector3.Zero, new Vector3( 1f, extent * 2f, extent * 2f ) );

		var normal = Vector3.Backward;
		var right = new Vector3( 0, -1, 0 );
		var up = Vector3.Up;

		mesh.LockVertexBuffer<Vertex>( vertices =>
		{
			vertices[0] = MakeVertex( -right * extent - up * extent, normal, right, new Vector2( 0, 1 ), 0f );
			vertices[1] = MakeVertex( right * extent - up * extent, normal, right, new Vector2( 1, 1 ), 0.33f );
			vertices[2] = MakeVertex( right * extent + up * extent, normal, right, new Vector2( 1, 0 ), 0.66f );
			vertices[3] = MakeVertex( -right * extent + up * extent, normal, right, new Vector2( 0, 0 ), 1f );
		} );

		mesh.LockIndexBuffer( indices =>
		{
			indices[0] = 0;
			indices[1] = 1;
			indices[2] = 2;
			indices[3] = 0;
			indices[4] = 2;
			indices[5] = 3;
		} );

		return mesh;
	}

	/// <summary>A capped cylinder on the Z axis. Caps are fans; the side is a grid strip.</summary>
	static Mesh BuildCylinder( float radius, float height, int segments )
	{
		segments = Math.Clamp( segments, 3, 512 );

		var mesh = NewMesh( DefaultMaterial );
		var half = height * 0.5f;
		var rim = segments + 1;

		// side grid + two fans, each fan being a centre plus its own rim ring
		var vertexCount = rim * 2 + ( segments + 1 ) * 2 + 2;
		var indexCount = segments * 6 + segments * 3 * 2;

		mesh.CreateVertexBuffer<Vertex>( vertexCount );
		mesh.CreateIndexBuffer( indexCount );
		mesh.Bounds = BBox.FromPositionAndSize( Vector3.Zero, new Vector3( radius * 2f, radius * 2f, height ) );

		mesh.LockVertexBuffer<Vertex>( vertices =>
		{
			var i = 0;

			for ( int s = 0; s < rim; s++ )
			{
				var u = s / (float)segments;
				var phi = u * MathF.PI * 2f;
				var dir = new Vector3( MathF.Cos( phi ), MathF.Sin( phi ), 0f );
				var tangent = new Vector3( -MathF.Sin( phi ), MathF.Cos( phi ), 0f );

				vertices[i++] = MakeVertex( dir * radius + Vector3.Up * -half, dir, tangent, new Vector2( u, 1 ), 0f );
				vertices[i++] = MakeVertex( dir * radius + Vector3.Up * half, dir, tangent, new Vector2( u, 0 ), 1f );
			}

			i = AppendFan( vertices, i, Vector3.Up * half, Vector3.Up, new Vector3( 1, 0, 0 ), radius, segments, false );
			AppendFan( vertices, i, Vector3.Up * -half, Vector3.Down, new Vector3( 1, 0, 0 ), radius, segments, true );
		} );

		mesh.LockIndexBuffer( indices =>
		{
			var i = 0;

			for ( int s = 0; s < segments; s++ )
			{
				var b = s * 2;

				indices[i++] = b;
				indices[i++] = b + 2;
				indices[i++] = b + 1;

				indices[i++] = b + 2;
				indices[i++] = b + 3;
				indices[i++] = b + 1;
			}

			var topBase = rim * 2;
			var bottomBase = topBase + segments + 2;

			i = FanIndices( indices, i, topBase, segments, false );
			FanIndices( indices, i, bottomBase, segments, true );
		} );

		return mesh;
	}

	/// <summary>A capped cone on the Z axis, apex up. The apex is split per segment so normals stay right.</summary>
	static Mesh BuildCone( float radius, float height, int segments )
	{
		segments = Math.Clamp( segments, 3, 512 );

		var mesh = NewMesh( DefaultMaterial );
		var half = height * 0.5f;
		var rim = segments + 1;
		var slant = MathF.Sqrt( radius * radius + height * height );
		var normalZ = slant > 0.0001f ? radius / slant : 0f;
		var normalR = slant > 0.0001f ? height / slant : 1f;

		var vertexCount = rim * 2 + segments + 2;
		var indexCount = segments * 3 + segments * 3;

		mesh.CreateVertexBuffer<Vertex>( vertexCount );
		mesh.CreateIndexBuffer( indexCount );
		mesh.Bounds = BBox.FromPositionAndSize( Vector3.Zero, new Vector3( radius * 2f, radius * 2f, height ) );

		mesh.LockVertexBuffer<Vertex>( vertices =>
		{
			var i = 0;

			for ( int s = 0; s < rim; s++ )
			{
				var u = s / (float)segments;
				var phi = u * MathF.PI * 2f;
				var dir = new Vector3( MathF.Cos( phi ), MathF.Sin( phi ), 0f );
				var tangent = new Vector3( -MathF.Sin( phi ), MathF.Cos( phi ), 0f );
				var normal = ( dir * normalR + Vector3.Up * normalZ ).Normal;

				vertices[i++] = MakeVertex( dir * radius + Vector3.Up * -half, normal, tangent, new Vector2( u, 1 ), 0f );
				vertices[i++] = MakeVertex( Vector3.Up * half, normal, tangent, new Vector2( u, 0 ), 1f );
			}

			AppendFan( vertices, i, Vector3.Up * -half, Vector3.Down, new Vector3( 1, 0, 0 ), radius, segments, true );
		} );

		mesh.LockIndexBuffer( indices =>
		{
			var i = 0;

			for ( int s = 0; s < segments; s++ )
			{
				var b = s * 2;

				indices[i++] = b;
				indices[i++] = b + 2;
				indices[i++] = b + 1;
			}

			FanIndices( indices, i, rim * 2, segments, true );
		} );

		return mesh;
	}

	/// <summary>A torus in the XY plane. The primitive that shows a broken tangent frame instantly.</summary>
	static Mesh BuildTorus( float major, float minor, int segments, int rings )
	{
		segments = Math.Clamp( segments, 3, 512 );
		rings = Math.Clamp( rings, 3, 512 );

		var mesh = NewMesh( DefaultMaterial );
		var rows = segments + 1;
		var columns = rings + 1;

		mesh.CreateVertexBuffer<Vertex>( rows * columns );
		mesh.CreateIndexBuffer( 6 * segments * rings );
		mesh.Bounds = BBox.FromPositionAndSize( Vector3.Zero,
			new Vector3( ( major + minor ) * 2f, ( major + minor ) * 2f, minor * 2f ) );

		mesh.LockVertexBuffer<Vertex>( vertices =>
		{
			var i = 0;

			for ( int row = 0; row < rows; row++ )
			{
				var u01 = row / (float)segments;
				var u = u01 * MathF.PI * 2f;
				var cosU = MathF.Cos( u );
				var sinU = MathF.Sin( u );
				var tangent = new Vector3( -sinU, cosU, 0f );

				for ( int column = 0; column < columns; column++ )
				{
					var v01 = column / (float)rings;
					var v = v01 * MathF.PI * 2f;
					var cosV = MathF.Cos( v );
					var sinV = MathF.Sin( v );

					var normal = new Vector3( cosV * cosU, cosV * sinU, sinV );
					var position = new Vector3( ( major + minor * cosV ) * cosU,
						( major + minor * cosV ) * sinU, minor * sinV );

					vertices[i++] = MakeVertex( position, normal, tangent, new Vector2( u01, v01 ), v01 );
				}
			}
		} );

		mesh.LockIndexBuffer( indices => Grid( indices, rows, columns, false ) );

		return mesh;
	}

	// ---- shared helpers ----------------------------------------------------

	/// <summary>
	/// One vertex with a full frame. <c>TexCoord1</c> is deliberately a different mapping from
	/// <c>TexCoord0</c> — a graph that reads the wrong set should look obviously wrong, not subtly so.
	/// </summary>
	static Vertex MakeVertex( Vector3 position, Vector3 normal, Vector3 tangent, Vector2 uv, float gradient )
	{
		var n = normal.Normal;
		var t = tangent.Normal;

		return new Vertex
		{
			Position = position,
			Normal = n,
			Tangent = new Vector4( t.x, t.y, t.z, -1f ),
			TexCoord0 = new Vector4( uv.x, uv.y, 0f, 0f ),
			TexCoord1 = new Vector4( uv.x * 2f, uv.y * 2f, 0f, 0f ),
			Color = new Color( uv.x, uv.y, Math.Clamp( gradient, 0f, 1f ), 1f )
		};
	}

	/// <summary>
	/// Index a <paramref name="rows"/> x <paramref name="columns"/> vertex grid where the outer loop
	/// filled rows. Set <paramref name="flip"/> to reverse the winding for an inward-facing surface.
	/// </summary>
	static void Grid( Span<int> indices, int rows, int columns, bool flip )
	{
		var i = 0;

		for ( int row = 0; row + 1 < rows; row++ )
		{
			for ( int column = 0; column + 1 < columns; column++ )
			{
				var a = row * columns + column;
				var b = ( row + 1 ) * columns + column;
				var c = row * columns + column + 1;
				var d = ( row + 1 ) * columns + column + 1;

				if ( flip )
				{
					indices[i++] = a;
					indices[i++] = c;
					indices[i++] = b;

					indices[i++] = b;
					indices[i++] = c;
					indices[i++] = d;
				}
				else
				{
					indices[i++] = a;
					indices[i++] = b;
					indices[i++] = c;

					indices[i++] = b;
					indices[i++] = d;
					indices[i++] = c;
				}
			}
		}
	}

	/// <summary>Write a cap fan: one centre vertex followed by <c>segments + 1</c> rim vertices.</summary>
	static int AppendFan( Span<Vertex> vertices, int start, Vector3 centre, Vector3 normal, Vector3 tangent,
		float radius, int segments, bool flip )
	{
		var i = start;

		vertices[i++] = MakeVertex( centre, normal, tangent, new Vector2( 0.5f, 0.5f ), flip ? 0f : 1f );

		for ( int s = 0; s <= segments; s++ )
		{
			var phi = s / (float)segments * MathF.PI * 2f;
			var cos = MathF.Cos( phi );
			var sin = MathF.Sin( phi );
			var offset = new Vector3( cos, sin, 0f ) * radius;

			vertices[i++] = MakeVertex( centre + offset, normal, tangent,
				new Vector2( cos * 0.5f + 0.5f, sin * 0.5f + 0.5f ), flip ? 0f : 1f );
		}

		return i;
	}

	/// <summary>Index a cap fan written by <see cref="AppendFan"/>.</summary>
	static int FanIndices( Span<int> indices, int start, int baseVertex, int segments, bool flip )
	{
		var i = start;

		for ( int s = 0; s < segments; s++ )
		{
			var a = baseVertex + 1 + s;
			var b = baseVertex + 2 + s;

			indices[i++] = baseVertex;

			if ( flip )
			{
				indices[i++] = b;
				indices[i++] = a;
			}
			else
			{
				indices[i++] = a;
				indices[i++] = b;
			}
		}

		return i;
	}
}