Effigy/VmdlMaterials.cs

Utility class that builds a MaterialGroupList KV3 node for .vmdl exports. It computes remapping pairs from mesh material slots to vmat assets, emits defaults for unbound slots, and formats the KV3 node text used by the compiler.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace Effigy;

/// <summary>
/// The MaterialGroupList node a .vmdl needs so faces keep the materials they were given.
///
/// WHAT THIS CLOSES. Faces carry a slot number and PartStudio.MaterialNames binds that number to a
/// vmat. Every exporter writes the name — OBJ as <c>usemtl</c>, DMX as <c>mtlName</c>, SMD as the
/// triangle's material line — and that was as far as it went. The .vmdl Compile writes had no
/// MaterialGroupList at all. ModelDoc fills a missing one in with
/// <c>use_global_default = true</c> and <c>materials/default.vmat</c>, which is why a part that
/// rendered in brushed steel in the viewport compiled to a blank grey prop.
///
/// COPIED, NOT GUESSED. The remap shape is the one this project's own lightswitch and first-person
/// arms models already ship:
///
/// <code>
/// from = "sw_plate.vmat"
/// to   = "materials/lightswitch/sw_plate.vmat"
/// </code>
///
/// <c>from</c> is the name the mesh file carries. <c>to</c> is the asset the compiler should bind.
/// <c>use_global_default</c> stays false: true is the switch that replaces every slot with default,
/// which is the failure this exists to stop.
///
/// WHY SEVERAL <c>from</c> SPELLINGS. The mesh writers emit whatever NameForSlot returns, which for
/// a bound slot is the full vmat path. ModelDoc's importer is documented as dropping everything
/// after a period in a material name (the Blender <c>.001</c> rule), and an OBJ <c>usemtl</c> with
/// slashes is sometimes taken as the last segment only. The lightswitch files remap from the
/// filename. Emitting the path, the filename, and both with <c>.vmat</c> stripped means whichever
/// spelling the importer keeps still hits a remap.
///
/// WHY THE KERNEL AND NOT THE EDITOR. Same reason as VmdlPhysics: it is text, it has no engine
/// types, and a headless test can say whether the node names the slots the mesh actually uses.
/// </summary>
public static class VmdlMaterials
{
	/// <summary>
	/// What an unbound slot compiles to.
	///
	/// EVERY SLOT MUST NAME A REAL ASSET. Leaving an unbound slot alone meant the compiled model
	/// carried the mesh's own name for it - <c>material_0</c> - which resolves to no asset at all,
	/// so the model rendered in the bright red missing-material shader. That is the first thing
	/// anybody saw after their first export, and it reads as the exporter having failed.
	///
	/// IT DOES NOT SHOW PAINT, and an earlier version of this comment claimed it did. The claim
	/// was that <c>complex.vfx</c>'s <c>g_flModelTintAmount 1.0</c> would multiply a vertex colour
	/// in. It does not: model tint is a per-draw constant and the tint MASK is a texture, and
	/// neither is a per-vertex COLOR stream. That was true of the vertex-colour paint this once
	/// carried and it is why painting appeared to do nothing at all.
	///
	/// PAINT NO LONGER ARRIVES HERE. It is a texture: <see cref="PaintMaterial"/> writes the canvas
	/// out as a PNG wrapped in a .vmat, and that .vmat is bound to the painted body's slot through
	/// MaterialNames like any dropped material. A bound slot never takes a fallback, so there is no
	/// longer such a thing as a painted slot arriving unbound and needing a material that reads
	/// paint.
	/// </summary>
	public const string DefaultMaterial = "materials/default.vmat";

	/// <summary>
	/// What an unbound slot compiles to when a paint layer asks to cover rather than tint.
	///
	/// White albedo, so the vertex-colour multiply lands on 1.0 and the result IS the paint colour.
	/// That is the whole trick: covering needs no shader, only a surface with nothing of its own to
	/// show through. Verified white rather than assumed - its generated colour texture is the same
	/// asset <c>bright_100.vmat</c> uses.
	/// </summary>
	public const string ReplaceMaterial = "materials/default/white.vmat";

	/// <summary>
	/// Which fallback an unbound slot in this studio should take.
	///
	/// THE MESH GETS NO VOTE, and it used to. A mesh carrying vertex colours took a third answer -
	/// vertex_color.vmat, the one material that read the COLOR stream - because paint was per-vertex
	/// colour and every ordinary material threw it away. Paint is a texture now, bound to its body's
	/// slot as an ordinary .vmat, so a painted slot reaches export BOUND and a fallback is only ever
	/// asked about slots nobody has given a material to. There is nothing left for the mesh to say.
	///
	/// Whole-document by design: see PaintFeature.Blend. A suppressed or failed paint layer does
	/// not get a vote, the same rule the rest of the exporters apply to a feature that produced no
	/// geometry.
	/// </summary>
	public static string FallbackFor( PartStudio studio )
	{
		// Paint is a bound texture now. Blend used to pick white.vmat so vertex colour would show
		// on an unbound slot; that path is gone, and consulting Blend here made an unbound slot
		// compile white whenever a paint layer existed anywhere in the tree — including one that
		// had never been brushed. Unbound is default.vmat. A painted body reaches export bound.
		_ = studio;
		return DefaultMaterial;
	}

	/// <summary>
	/// The MaterialGroupList node, indented to sit among a RootNode's children.
	///
	/// ALWAYS A NODE, even when nothing is bound. An omitted list is what ModelDoc replaces with
	/// the global default, and every slot the mesh uses is named here explicitly - bound ones to
	/// their vmat, the rest to whichever fallback <see cref="FallbackFor"/> picked - so nothing is
	/// left to resolve on a name no asset answers to.
	/// </summary>
	public static string GroupList( PartStudio studio, PolyMesh mesh )
	{
		if ( studio is null )
			return GroupList( mesh, null, null );

		return GroupList( mesh, studio.NameForSlot, studio.MaterialNames, FallbackFor( studio ) );
	}

	/// <summary>
	/// The same node, from the two facts export already has: what the mesh writers will call each
	/// slot, and which slots have a vmat bound.
	/// </summary>
	public static string GroupList( PolyMesh mesh, Func<int, string> nameForSlot,
		IReadOnlyDictionary<int, string> materialNames, string fallback = DefaultMaterial )
	{
		var remaps = Remaps( mesh, nameForSlot, materialNames, fallback );
		var sb = new StringBuilder();

		sb.Append( "\t\t\t{\n" );
		sb.Append( "\t\t\t\t_class = \"MaterialGroupList\"\n" );
		sb.Append( "\t\t\t\tchildren = \n" );
		sb.Append( "\t\t\t\t[\n" );
		sb.Append( "\t\t\t\t\t{\n" );
		sb.Append( "\t\t\t\t\t\t_class = \"DefaultMaterialGroup\"\n" );
		sb.Append( "\t\t\t\t\t\tremaps = \n" );
		sb.Append( "\t\t\t\t\t\t[\n" );

		foreach ( var (from, to) in remaps )
		{
			sb.Append( "\t\t\t\t\t\t\t{\n" );
			sb.Append( $"\t\t\t\t\t\t\t\tfrom = {Quote( from )}\n" );
			sb.Append( $"\t\t\t\t\t\t\t\tto = {Quote( to )}\n" );
			sb.Append( "\t\t\t\t\t\t\t},\n" );
		}

		sb.Append( "\t\t\t\t\t\t]\n" );
		sb.Append( "\t\t\t\t\t\tuse_global_default = false\n" );
		sb.Append( "\t\t\t\t\t\tglobal_default_material = \"\"\n" );
		sb.Append( "\t\t\t\t\t},\n" );
		sb.Append( "\t\t\t\t]\n" );
		sb.Append( "\t\t\t},\n" );

		return sb.ToString();
	}

	/// <summary>
	/// Every <c>from → to</c> pair the node will write, in the order they appear.
	///
	/// Public so a test can count remaps without scraping KV3, and so the editor can log how many
	/// slots actually went out.
	/// </summary>
	public static List<(string From, string To)> Remaps( PolyMesh mesh, Func<int, string> nameForSlot,
		IReadOnlyDictionary<int, string> materialNames, string fallback = DefaultMaterial )
	{
		var remaps = new List<(string, string)>();
		var seenFrom = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		foreach ( var slot in SlotsOn( mesh ) )
		{
			var written = nameForSlot is not null ? nameForSlot( slot ) : ObjWriter.DefaultMaterialName( slot );

			// AN UNBOUND SLOT IS STILL A REMAP, to the default. Skipping it left the compiled model
			// asking for `material_0`, which is not an asset - see DefaultMaterial for why that is
			// the red model, and why paint needs this to show at all. A slot carrying a hand-typed
			// display name ("anodised") lands here too: there is no asset by that name either, and
			// the default is a better answer than the missing-material shader.
			if ( !TryBoundVmat( slot, materialNames, out var target ) )
			{
				if ( seenFrom.Add( written ) )
					remaps.Add( (written, string.IsNullOrWhiteSpace( fallback ) ? DefaultMaterial : fallback) );

				continue;
			}

			foreach ( var from in FromAliases( written, target ) )
			{
				if ( !seenFrom.Add( from ) )
					continue;

				remaps.Add( (from, target) );
			}
		}

		return remaps;
	}

	/// <summary>Slots the mesh actually paints. A name sitting on a slot no face wears does not
	/// reach the compiled model, so it is not a remap.</summary>
	static SortedSet<int> SlotsOn( PolyMesh mesh )
	{
		var slots = new SortedSet<int>();

		if ( mesh?.Faces is null )
			return slots;

		foreach ( var face in mesh.Faces )
			slots.Add( face.Material );

		return slots;
	}

	/// <summary>
	/// The vmat a slot should compile to, or nothing.
	///
	/// A hand-typed display name — "anodised", "brushed steel" — is what the mesh writers already
	/// emit, and there is no asset to point <c>to</c> at. Only a path that looks like a material
	/// asset is remappable.
	/// </summary>
	static bool TryBoundVmat( int slot, IReadOnlyDictionary<int, string> materialNames, out string path )
	{
		path = null;

		if ( materialNames is null || !materialNames.TryGetValue( slot, out var name )
			|| string.IsNullOrWhiteSpace( name ) )
			return false;

		var n = name.Trim().Replace( '\\', '/' );

		if ( !n.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase )
			&& !n.StartsWith( "materials/", StringComparison.OrdinalIgnoreCase ) )
			return false;

		path = n;
		return true;
	}

	/// <summary>
	/// Every spelling of <paramref name="written"/> the importer might keep, plus the filename of
	/// <paramref name="target"/> — see the class comment for why there is more than one.
	/// </summary>
	static List<string> FromAliases( string written, string target )
	{
		var names = new List<string>();
		var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		void Add( string value )
		{
			if ( string.IsNullOrWhiteSpace( value ) )
				return;

			var n = value.Trim().Replace( '\\', '/' );

			if ( seen.Add( n ) )
				names.Add( n );
		}

		Add( written );
		Add( target );
		Add( FileName( target ) );
		Add( StripVmat( FileName( target ) ) );
		Add( StripVmat( target ) );

		return names;
	}

	static string FileName( string path )
	{
		if ( string.IsNullOrEmpty( path ) )
			return path;

		var n = path.Replace( '\\', '/' );
		var cut = n.LastIndexOf( '/' );

		return cut >= 0 && cut < n.Length - 1 ? n[(cut + 1)..] : n;
	}

	static string StripVmat( string name )
	{
		if ( string.IsNullOrEmpty( name ) )
			return name;

		return name.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase )
			? name[..^5]
			: name;
	}

	static string Quote( string value )
	{
		if ( value is null )
			return "\"\"";

		return "\"" + value.Replace( "\\", "\\\\" ).Replace( "\"", "\\\"" ) + "\"";
	}
}