Effigy/VmdlMaterials.cs
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( "\"", "\\\"" ) + "\"";
}
}