Editor/EffigyEditor/EffigyPreview.cs

Editor utility that converts an in-memory PolyMesh into runtime Model(s) for a live preview viewport. It computes corner normals, groups faces by resolved material to build submeshes, triangulates faces, and exposes a LivePreview class that can rebuild or update vertex buffers in-place for interactive edits.

File AccessNetworking
using Effigy;
using Sandbox;
using System;
using System.Collections.Generic;

namespace Marionette.EditorTools;

/// <summary>
/// Turns the Part Studio's in-memory PolyMesh straight into a runtime Model, so the viewport can
/// show the result of a feature the moment it is added.
///
/// The alternative — the path Export/Compile still take — is OBJ on disk, a generated .vmdl, and
/// a call into the asset compiler. That takes hundreds of milliseconds and writes files, which is
/// fine for producing a placeable prop and hopeless as the response to dragging a slider. This
/// path never touches the disk.
///
/// It is deliberately NOT a second geometry pipeline: it shares MeshNormals with ObjWriter, so
/// what you see here and what the compiler bakes are smoothed by the same rule.
///
/// MATERIALS RENDER HERE NOW. A face carries a slot number and PartStudio.MaterialNames binds the
/// slot to a vmat, so the preview groups faces by the material they resolve to and builds one
/// submesh per material — the same grouping the exporters express as usemtl runs. Faces on slot 0,
/// on a slot nothing is bound to, or on a slot whose vmat will not load fall back to the flat
/// placeholder rather than rendering as nothing. Pass no resolver — the sculpt preview does — and
/// the whole model is the placeholder, which is the old behaviour.
/// </summary>
internal static class EffigyPreview
{
	/// <summary>
	/// FLAT grey, with no pattern on it at all. The fallback for a face whose slot names no
	/// material, or names one that will not load.
	///
	/// It used to be dev/reflectivity_30, and that material actively lies about scale: its texture
	/// is a grid with the number "30" printed in every tile - the material's reflectivity, nothing
	/// to do with size - and caps take plane coordinates straight through as UVs, so it tiles once
	/// per sketch unit. A 30x30 face therefore came out covered in thirty-odd squares each labelled
	/// "30", which reads as a part 900 units across. gray_50's texture is a single flat colour.
	/// </summary>
	private const string PreviewMaterial = "materials/dev/gray_50.vmat";

	/// <summary>
	/// Build a Model from the mesh, optionally resolving each face's material slot to a real vmat.
	/// </summary>
	/// <param name="materialForSlot">Slot number to bound material path, or null / empty for an
	/// unbound slot. Pass null to render everything on the placeholder.</param>
	public static Model Build( PolyMesh mesh, Func<int, string> materialForSlot = null,
		float smoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees )
	{
		if ( mesh is null || mesh.FaceCount == 0 || mesh.VertexCount == 0 )
			return null;

		var (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );

		var placeholder = Material.Load( PreviewMaterial );
		var bounds = BoundsOf( mesh );

		// Faces bucketed by the material they render with. One drop of brushed steel onto three
		// faces is one bucket and one submesh; a part nobody assigned is one bucket on the
		// placeholder. The slot is resolved once and cached, not once per face.
		var buckets = new Dictionary<Material, List<int>>();
		var slotCache = new Dictionary<int, Material>();

		for ( var fi = 0; fi < mesh.FaceCount; fi++ )
		{
			if ( mesh.Faces[fi].Count < 3 )
				continue;

			var material = ResolveMaterial( mesh.Faces[fi].Material, materialForSlot, placeholder, slotCache );

			if ( !buckets.TryGetValue( material, out var faces ) )
				buckets[material] = faces = new List<int>();

			faces.Add( fi );
		}

		var builder = Model.Builder;
		var any = false;

		foreach ( var (material, faces) in buckets )
		{
			if ( BuildSubmesh( mesh, faces, cornerNormals, normals, material, bounds ) is { } sub )
			{
				builder.AddMesh( sub );
				any = true;
			}
		}

		return any ? builder.Create() : null;
	}

	/// <summary>
	/// Build a Model from the mesh wearing ONE material on every face, slot resolution ignored.
	///
	/// THIS IS THE PAINT-PREVIEW PATH. While painting, the whole part wears the painted atlas — a
	/// <see cref="Material.CreateCopy"/> with the live canvas texture bound — so the slot bucketing
	/// above is the wrong answer there. One material, one submesh, the same smoothing as the ordinary
	/// preview, and the caller owns the material's lifetime across rebuilds.
	/// </summary>
	public static Model Build( PolyMesh mesh, Material material,
		float smoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees )
	{
		if ( mesh is null || mesh.FaceCount == 0 || mesh.VertexCount == 0 || material is null )
			return null;

		var (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );
		var bounds = BoundsOf( mesh );

		var faces = new List<int>( mesh.FaceCount );

		for ( var fi = 0; fi < mesh.FaceCount; fi++ )
		{
			if ( mesh.Faces[fi].Count >= 3 )
				faces.Add( fi );
		}

		var sub = BuildSubmesh( mesh, faces, cornerNormals, normals, material, bounds );

		if ( sub is null )
			return null;

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

		return builder.Create();
	}

	/// <summary>
	/// A preview Model that can be rewritten in place when only vertex positions change — a body
	/// being dragged by the Transform handle. Rebuilding the Model, its buffers and its GameObject
	/// every frame is what made dragging a dense import sluggish, so this retains the meshes and
	/// overwrites their vertex buffers instead. Buckets, triangulation and buffer allocation are
	/// done once; a drag only recomputes normals and re-uploads the vertex data.
	/// </summary>
	internal sealed class LivePreview
	{
		public Model Model { get; private set; }

		private readonly List<Mesh> _meshes = new();
		private readonly List<List<SimpleVertex>> _vertices = new();
		private readonly Dictionary<Material, int> _bucketIndex = new();
		private int[] _faceMesh;
		private int[] _faceCorners;
		private int _faceCount;

		// The corner normals the vertex buffers were last built with. Kept so a position-only update
		// (a sculpt stroke, which moves vertices without changing topology) can reuse them instead of
		// re-running the O(vertices) fan pass every dab. The normals snap correct on the next full
		// update.
		private int[][] _cornerNormals;
		private List<Vec3> _normals;

		private LivePreview() { }

		public static LivePreview Build( PolyMesh mesh, Func<int, string> materialForSlot,
			float smoothingAngleDegrees )
		{
			var live = new LivePreview { _faceCount = mesh.FaceCount };
			live.Rebuild( mesh, materialForSlot, smoothingAngleDegrees );
			return live;
		}

		/// <summary>
		/// Rewrite the vertex buffers from a mesh whose topology matches the one this was built
		/// from. Returns false — the caller must rebuild — when the face count, material layout or
		/// corner count changed, because any of those moves the bucketing and the index buffers.
		/// </summary>
		public bool TryUpdate( PolyMesh mesh, Func<int, string> materialForSlot, float smoothingAngleDegrees )
		{
			if ( Model is null || mesh.FaceCount != _faceCount || mesh.HasVertexColors )
				return false;

			var placeholder = Material.Load( PreviewMaterial );
			var slotCache = new Dictionary<int, Material>();
			var faceLists = new List<List<int>>( _meshes.Count );

			for ( var i = 0; i < _meshes.Count; i++ )
				faceLists.Add( new List<int>() );

			for ( var fi = 0; fi < mesh.FaceCount; fi++ )
			{
				// The index buffers are laid out corner by corner, so two faces swapping corner
				// counts keeps the per-bucket vertex total the same while scrambling the triangles.
				if ( mesh.Faces[fi].Count != _faceCorners[fi] )
					return false;

				if ( mesh.Faces[fi].Count < 3 )
					continue;

				var material = ResolveMaterial( mesh.Faces[fi].Material, materialForSlot, placeholder, slotCache );

				// A face on a bucket that was not there at build time means the material layout
				// changed, which an in-place update cannot express.
				if ( !_bucketIndex.TryGetValue( material, out var bi ) || bi != _faceMesh[fi] )
					return false;

				faceLists[bi].Add( fi );
			}

			var (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );
			_cornerNormals = cornerNormals;
			_normals = normals;
			var bounds = BoundsOf( mesh );

			for ( var i = 0; i < _meshes.Count; i++ )
			{
				var vertices = BuildVertices( mesh, faceLists[i], cornerNormals, normals );

				if ( vertices.Count != _vertices[i].Count )
					return false;

				_meshes[i].SetVertexBufferData( vertices, 0 );
				_meshes[i].Bounds = bounds;
			}

			return true;
		}

		/// <summary>
		/// Rewrite positions only, reusing the normals and UVs the buffers were last built with. A
		/// sculpt stroke moves vertices without changing topology, and this skips the O(vertices)
		/// corner-normal fan pass that a full update re-runs every dab — the normals snap correct on
		/// the next full update (stroke end). Returns false when the face layout has changed, which
		/// means a full rebuild instead.
		/// </summary>
		public bool TryUpdatePositions( PolyMesh mesh )
		{
			if ( Model is null || mesh.FaceCount != _faceCount || _cornerNormals is null || mesh.HasVertexColors )
				return false;

			var faceLists = new List<List<int>>( _meshes.Count );

			for ( var i = 0; i < _meshes.Count; i++ )
				faceLists.Add( new List<int>() );

			for ( var fi = 0; fi < mesh.FaceCount; fi++ )
			{
				if ( mesh.Faces[fi].Count != _faceCorners[fi] )
					return false;

				if ( mesh.Faces[fi].Count < 3 )
					continue;

				faceLists[_faceMesh[fi]].Add( fi );
			}

			for ( var i = 0; i < _meshes.Count; i++ )
			{
				var vertices = BuildVertices( mesh, faceLists[i], _cornerNormals, _normals );

				if ( vertices.Count != _vertices[i].Count )
					return false;

				_meshes[i].SetVertexBufferData( vertices, 0 );
				_vertices[i] = vertices;
			}

			return true;
		}

		void Rebuild( PolyMesh mesh, Func<int, string> materialForSlot, float smoothingAngleDegrees )
		{
			_meshes.Clear();
			_vertices.Clear();
			_bucketIndex.Clear();

			var (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );
			_cornerNormals = cornerNormals;
			_normals = normals;
			var bounds = BoundsOf( mesh );
			var placeholder = Material.Load( PreviewMaterial );
			var slotCache = new Dictionary<int, Material>();

			var buckets = new List<(Material Material, List<int> Faces)>();
			_faceMesh = new int[mesh.FaceCount];
			_faceCorners = new int[mesh.FaceCount];

			for ( var fi = 0; fi < mesh.FaceCount; fi++ )
			{
				_faceCorners[fi] = mesh.Faces[fi].Count;

				if ( mesh.Faces[fi].Count < 3 )
					continue;

				var material = ResolveMaterial( mesh.Faces[fi].Material, materialForSlot, placeholder, slotCache );

				if ( !_bucketIndex.TryGetValue( material, out var bi ) )
				{
					bi = buckets.Count;
					_bucketIndex[material] = bi;
					buckets.Add( (material, new List<int>()) );
				}

				buckets[bi].Faces.Add( fi );
				_faceMesh[fi] = bi;
			}

			var builder = Model.Builder;

			foreach ( var (material, faces) in buckets )
			{
				var vertices = BuildVertices( mesh, faces, cornerNormals, normals );

				if ( vertices.Count == 0 )
					continue;

				var indices = TriangulateIndices( mesh, faces );
				var sbMesh = new Mesh( material );

				if ( mesh.HasVertexColors )
				{
					var coloured = BuildColouredVertices( mesh, faces, cornerNormals, normals );
					sbMesh.CreateVertexBuffer<Vertex>( coloured.Count, coloured );
				}
				else
					sbMesh.CreateVertexBuffer<SimpleVertex>( vertices.Count, vertices );
				sbMesh.CreateIndexBuffer( indices.Count, indices );
				sbMesh.Bounds = bounds;

				builder.AddMesh( sbMesh );
				_meshes.Add( sbMesh );
				_vertices.Add( vertices );
			}

			Model = _meshes.Count > 0 ? builder.Create() : null;
		}
	}

	/// <summary>
	/// The material a slot renders with: the vmat bound to it, or the flat placeholder.
	///
	/// Slot 0 is the slot every face starts on and never carries a material, so it is the
	/// placeholder without a lookup. A named slot whose vmat will not load — a path into a package
	/// that is not installed, a material deleted since it was assigned — also falls back rather
	/// than rendering the part as nothing, which is the failure the single-material preview used
	/// to avoid wholesale.
	/// </summary>
	private static Material ResolveMaterial( int slot, Func<int, string> materialForSlot,
		Material placeholder, Dictionary<int, Material> cache )
	{
		if ( slot <= 0 || materialForSlot is null )
			return placeholder;

		if ( cache.TryGetValue( slot, out var cached ) )
			return cached;

		var name = materialForSlot( slot );
		var material = string.IsNullOrWhiteSpace( name ) ? null : Material.Load( name );

		// A named slot whose vmat would not load is the one case the fallback hides — the face
		// renders as placeholder grey and nothing says why. Say why, once per slot per build.
		if ( material is null && !string.IsNullOrWhiteSpace( name ) )
			Log.Warning( $"[effigy-preview] slot {slot}: material '{name}' failed to load — using placeholder" );

		return cache[slot] = material ?? placeholder;
	}

	/// <summary>
	/// One Mesh over a subset of the faces, all sharing one material.
	///
	/// ONE VERTEX FORMAT, since paint stopped being vertex colour. There was a second path here that
	/// built the richer <c>Vertex</c> so the engine could multiply a material by a per-vertex colour;
	/// it was chosen by <c>PolyMesh.HasVertexColors</c>, which nothing sets any more, so it rendered
	/// nothing that this path does not. Paint reaches the viewport as the painted body's material
	/// now, the same way a dropped material does.
	/// </summary>
	private static Mesh BuildSubmesh( PolyMesh mesh, List<int> faceIndices, int[][] cornerNormals,
		List<Vec3> normals, Material material, BBox bounds )
	{
		var vertices = BuildVertices( mesh, faceIndices, cornerNormals, normals );
		var indices = TriangulateIndices( mesh, faceIndices );

		if ( indices.Count == 0 )
			return null;

		var sbMesh = new Mesh( material );

		if ( mesh.HasVertexColors )
		{
			var coloured = BuildColouredVertices( mesh, faceIndices, cornerNormals, normals );
			sbMesh.CreateVertexBuffer<Vertex>( coloured.Count, coloured );
		}
		else
			sbMesh.CreateVertexBuffer<SimpleVertex>( vertices.Count, vertices );

		sbMesh.CreateIndexBuffer( indices.Count, indices );
		sbMesh.Bounds = bounds;

		return sbMesh;
	}

	/// <summary>
	/// The same corners as <see cref="BuildVertices"/>, carrying the mesh's vertex colour.
	///
	/// BACK FOR FUR. The vertex-colour path was retired when paint became a texture; it returns
	/// because s&box's fur.shader reads the mesh COLOR stream - red is each shell's height - and
	/// without it every shell reads as the root layer and the fur renders as a solid lump. Every other
	/// shader ignores the stream, so the rest of a furred part looks exactly as it did.
	/// </summary>
	private static List<Vertex> BuildColouredVertices( PolyMesh mesh, List<int> faceIndices,
		int[][] cornerNormals, List<Vec3> normals )
	{
		var colours = mesh.VertexColors;
		var vertices = new List<Vertex>( faceIndices.Count * 4 );

		foreach ( var fi in faceIndices )
		{
			var face = mesh.Faces[fi];
			var corners = cornerNormals[fi];

			for ( var c = 0; c < face.Count; c++ )
			{
				var p = mesh.Positions[face.Indices[c]];
				var n = normals[corners[c]];
				var uv = face.UVs is not null && c < face.UVs.Length ? face.UVs[c] : default;
				var tint = colours[face.Indices[c]].Tint();
				var normal = new Vector3( n.x, n.y, n.z );
				var tangent = MathF.Abs( normal.z ) < 0.9f
					? Vector3.Cross( Vector3.Up, normal ).Normal
					: Vector3.Cross( Vector3.Forward, normal ).Normal;

				vertices.Add( new Vertex
				{
					Position = new Vector3( p.x, p.y, p.z ),
					Normal = normal,
					Tangent = new Vector4( tangent, 1f ),
					TexCoord0 = new Vector2( uv.x, uv.y ),
					Color = new Color32(
						(byte)MathF.Round( Math.Clamp( tint.x, 0f, 1f ) * 255f ),
						(byte)MathF.Round( Math.Clamp( tint.y, 0f, 1f ) * 255f ),
						(byte)MathF.Round( Math.Clamp( tint.z, 0f, 1f ) * 255f ),
						255 ),
				} );
			}
		}

		return vertices;
	}

	/// <summary>
	/// One vertex per face corner rather than per position, in the same order
	/// <see cref="TriangulateIndices"/> indexes them. Corner normals are the whole point of
	/// MeshNormals - sharing a vertex between two faces that disagree about the normal is exactly
	/// what rounds off a box's edges.
	/// </summary>
	private static List<SimpleVertex> BuildVertices( PolyMesh mesh, List<int> faceIndices,
		int[][] cornerNormals, List<Vec3> normals )
	{
		var vertices = new List<SimpleVertex>( faceIndices.Count * 4 );

		foreach ( var fi in faceIndices )
		{
			var face = mesh.Faces[fi];
			var corners = cornerNormals[fi];

			for ( var c = 0; c < face.Count; c++ )
			{
				var p = mesh.Positions[face.Indices[c]];
				var n = normals[corners[c]];
				var uv = face.UVs is not null && c < face.UVs.Length ? face.UVs[c] : default;

				var position = new Vector3( p.x, p.y, p.z );
				var normal = new Vector3( n.x, n.y, n.z );

				vertices.Add( new SimpleVertex( position, normal, Vector3.Forward, new Vector2( uv.x, uv.y ) ) );
			}
		}

		return vertices;
	}

	/// <summary>
	/// Ear-clipping, not a fan. This used to fan from corner 0 on the grounds that every face the
	/// kernel produces is convex. Extrude caps are not: they are whatever closed region was drawn,
	/// and fanning a concave one fills its notches in — draw a dart and the solid came back as a
	/// quadrilateral with the concave corner swallowed.
	///
	/// A TRIANGLE BYPASSES THE CLIPPER. An import is overwhelmingly triangles, and ear clipping a
	/// 3-corner face is still a Newell normal, a flatten, a signed-area and several allocations per
	/// face — millions of them on a dense import. A triangle is always itself, so it is emitted
	/// straight through.
	/// </summary>
	private static List<int> TriangulateIndices( PolyMesh mesh, List<int> faceIndices )
	{
		var indices = new List<int>( faceIndices.Count * 6 );
		var first = 0;

		foreach ( var fi in faceIndices )
		{
			var face = mesh.Faces[fi];

			if ( face.Count == 3 )
			{
				indices.Add( first );
				indices.Add( first + 1 );
				indices.Add( first + 2 );
			}
			else
			{
				var polygon = new List<Vec3>( face.Count );

				for ( var k = 0; k < face.Count; k++ )
					polygon.Add( mesh.Positions[face.Indices[k]] );

				foreach ( var (a, b, cc) in Triangulate.Face( polygon ) )
				{
					indices.Add( first + a );
					indices.Add( first + b );
					indices.Add( first + cc );
				}
			}

			first += face.Count;
		}

		return indices;
	}

	private static BBox BoundsOf( PolyMesh mesh )
	{
		var min = new Vector3( float.MaxValue );
		var max = new Vector3( float.MinValue );

		foreach ( var p in mesh.Positions )
		{
			var v = new Vector3( p.x, p.y, p.z );
			min = Vector3.Min( min, v );
			max = Vector3.Max( max, v );
		}

		return new BBox( min, max );
	}
}