Editor/Conversion/Pipeline/MdlPipeline.cs

Editor pipeline for converting Source MDL/VVD/VTX model files into VMDL and FBX assets. It loads MDL/VVD/VTX, resolves materials, builds meshes and skin groups, writes FBX per mesh and a .vmdl, and optionally exports materials or logs animation sequence names.

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

namespace GmodAssetImporter;

/// <summary>MDL/VVD/VTX to VMDL + FBX (ModelDoc-preferred) conversion.</summary>
public static class MdlPipeline
{
	public static ConversionResult ConvertModel( string mdlPath, ConversionOptions options )
	{
		var result = new ConversionResult();
		var writeLog = options.OnLog ?? (_ => { });

		try
		{
			if ( string.IsNullOrWhiteSpace( options.OutputRoot ) )
				throw new InvalidOperationException( "Output root is required" );

			options.GmodRoot = string.IsNullOrWhiteSpace( options.GmodRoot )
				? PathResolve.DetectGmodRoot( mdlPath )
				: options.GmodRoot;

			writeLog( $"Loading {mdlPath}" );
			var mdl = MdlReader.Load( mdlPath );

			var vvdPath = Path.ChangeExtension( mdlPath, ".vvd" );
			if ( !File.Exists( vvdPath ) )
				throw new FileNotFoundException( "Companion .vvd not found", vvdPath );

			var vtxPath = VtxReader.FindVtxPath( mdlPath );
			if ( vtxPath == null )
				throw new FileNotFoundException( "Companion .vtx not found", Path.ChangeExtension( mdlPath, ".vtx" ) );

			var vvd = VvdReader.Load( vvdPath );
			var vtx = VtxReader.Load( vtxPath );
			writeLog( $"VVD verts={vvd.Vertices.Count}, VTX bodyparts={vtx.BodyParts.Count}" );

			var modelDirUnderOutput = options.PreservePaths
				? PathResolve.GetModelsRelativeDirectory( mdlPath, options.GmodRoot )
				: "models/converted";
			var modelName = PathResolve.SanitizeAssetName( Path.GetFileNameWithoutExtension( mdlPath ) );
			var absModelDir = Path.Combine( options.OutputRoot, modelDirUnderOutput.Replace( '/', Path.DirectorySeparatorChar ) );
			Directory.CreateDirectory( absModelDir );

			// VMDL paths must be Assets-relative (e.g. test/models/...), not only models/...
			var modelAssetDirectory = PathResolve.MakeAssetsRelative( absModelDir, options.OutputRoot );

			var context = new ModelBuildContext
			{
				ModelName = modelName,
				ModelAssetDirectory = modelAssetDirectory,
				Scale = options.ModelScale,
				IncludeBindPose = true
			};
			context.Bones.AddRange( mdl.Bones );
			context.Attachments.AddRange( mdl.Attachments );

			// Materials
			if ( !options.ImportMaterials && mdl.MaterialNames.Count > 0 )
				writeLog( $"Skipping {mdl.MaterialNames.Count} material(s) (Import materials off); model keeps expected .vmat paths" );

			var materialResults = new Dictionary<int, MaterialBuildResult>();
			for ( var i = 0; i < mdl.MaterialNames.Count; i++ )
			{
				var token = ResolveMaterialToken( mdl.MaterialNames[i], mdl.CdMaterials, options.GmodRoot );
				var mat = options.ImportMaterials
					? MaterialPipeline.ConvertMaterial( token, options, mdl.CdMaterials, result )
					: MaterialPipeline.GetExpectedMaterial( token, options );
				materialResults[i] = mat;
				context.MaterialRemaps.Add( new MaterialRemap
				{
					// Must match FBX material slot name + ".vmat" (ModelDoc import convention)
					From = PathResolve.MaterialRemapFromName( mdl.MaterialNames[i] ),
					To = mat.VmatAssetPath
				} );
			}

			BuildSkinGroups( mdl, materialResults, context );

			// Geometry from bodyparts / VTX → FBX (preferred by ModelDoc; SMD is deprecated)
			for ( var bpIndex = 0; bpIndex < mdl.BodyParts.Count; bpIndex++ )
			{
				var bp = mdl.BodyParts[bpIndex];
				var bodyGroup = new ImporterBodyGroup { Name = PathResolve.StripMeshSourceExtension( bp.Name ) };

				for ( var modelIndex = 0; modelIndex < bp.Models.Count; modelIndex++ )
				{
					var studioModel = bp.Models[modelIndex];
					var meshName = PathResolve.SanitizeAssetName( $"{bp.Name}_{studioModel.Name}" );
					if ( string.IsNullOrEmpty( meshName ) )
						meshName = $"mesh_{bpIndex}_{modelIndex}";

					var export = BuildMeshExport( meshName, studioModel, mdl, vvd, vtx, bpIndex, modelIndex, materialResults );
					if ( export.Triangles.Count == 0 )
					{
						writeLog( $"Skipping empty mesh {meshName}" );
						continue;
					}

					var fbxFile = meshName + ".fbx";
					var fbxAbs = Path.Combine( absModelDir, fbxFile );
					FbxWriter.WriteMesh( fbxAbs, context.Bones, export );
					result.WrittenFiles.Add( fbxAbs );

					export.FileName = fbxFile;
					context.Meshes.Add( export );

					bodyGroup.Choices.Add( new ImporterBodyGroupChoice
					{
						Name = PathResolve.StripMeshSourceExtension( studioModel.Name ),
						MeshNames = { meshName }
					} );
				}

				if ( bodyGroup.Choices.Count > 0 )
					context.BodyGroups.Add( bodyGroup );
			}

			if ( context.Meshes.Count == 0 )
				throw new InvalidOperationException( "No meshes extracted from model" );

			if ( options.ExportAnimations )
				ExportSequences( mdl, context, writeLog );
			else if ( mdl.Sequences.Count > 0 )
				writeLog( $"Skipping {mdl.Sequences.Count} sequence clip(s) (AnimBindPose only; enable Export sequence clips for names log)" );

			var vmdlUnderOutput = PathResolve.CombineAsset( modelDirUnderOutput, modelName + ".vmdl" );
			var vmdlAbs = Path.Combine( options.OutputRoot, vmdlUnderOutput.Replace( '/', Path.DirectorySeparatorChar ) );
			var vmdlAsset = PathResolve.MakeAssetsRelative( vmdlAbs, options.OutputRoot );
			VmdlWriter.Write( vmdlAbs, context );
			result.WrittenFiles.Add( vmdlAbs );

			result.Success = true;
			result.Message = $"Converted {modelName} -> {vmdlAsset}";
			writeLog( result.Message );
		}
		catch ( Exception ex )
		{
			result.Success = false;
			result.Message = ex.Message;
			writeLog( $"Error: {ex.Message}" );
		}

		return result;
	}

	/// <summary>Write only the VMAT/PNG materials an .mdl references; no mesh or .vmdl.</summary>
	public static ConversionResult ConvertModelMaterials( string mdlPath, ConversionOptions options )
	{
		var result = new ConversionResult();
		var writeLog = options.OnLog ?? (_ => { });

		try
		{
			options.GmodRoot = string.IsNullOrWhiteSpace( options.GmodRoot )
				? PathResolve.DetectGmodRoot( mdlPath )
				: options.GmodRoot;

			writeLog( $"Loading materials for {mdlPath}" );
			var mdl = MdlReader.Load( mdlPath );
			foreach ( var name in mdl.MaterialNames )
			{
				var token = ResolveMaterialToken( name, mdl.CdMaterials, options.GmodRoot );
				MaterialPipeline.ConvertMaterial( token, options, mdl.CdMaterials, result );
			}

			result.Success = true;
			result.Message = $"Converted {mdl.MaterialNames.Count} material(s) for {Path.GetFileNameWithoutExtension( mdlPath )}";
			writeLog( result.Message );
		}
		catch ( Exception ex )
		{
			result.Success = false;
			result.Message = ex.Message;
			writeLog( $"Error: {ex.Message}" );
		}

		return result;
	}

	static string ResolveMaterialToken( string materialName, List<string> cdMaterials, string gmodRoot )
	{
		materialName = PathResolve.NormalizeSlashes( materialName ).Trim( '/' );
		if ( cdMaterials.Count == 0 )
			return materialName;

		// Source tries every $cdmaterials folder in order; the material name may itself contain subfolders
		var roots = PathResolve.EnumerateMaterialSearchRoots( gmodRoot, null ).ToList();
		foreach ( var cd in cdMaterials )
		{
			var candidate = PathResolve.CombineAsset( cd, materialName );
			if ( VmtParser.ResolveVmtPath( candidate, roots ) != null )
				return candidate;
		}

		return materialName.Contains( '/' )
			? materialName
			: PathResolve.CombineAsset( cdMaterials[0], materialName );
	}

	static void BuildSkinGroups( MdlModel mdl, Dictionary<int, MaterialBuildResult> materials, ModelBuildContext context )
	{
		if ( mdl.SkinFamilies.Count <= 1 ) return;

		for ( var family = 1; family < mdl.SkinFamilies.Count; family++ )
		{
			var row = mdl.SkinFamilies[family];
			var group = new MaterialGroupData { Name = $"skin_{family}" };
			for ( var slot = 0; slot < row.Length && slot < mdl.SkinReferenceCount; slot++ )
			{
				var matIndex = row[slot];
				if ( matIndex < 0 || matIndex >= mdl.MaterialNames.Count ) continue;
				if ( !materials.TryGetValue( matIndex, out var mat ) ) continue;

				// Remap the family-0 material for this slot → family material
				var baseIndex = ResolveSkinMaterial( mdl, slot );
				var sourceName = baseIndex >= 0 && baseIndex < mdl.MaterialNames.Count
					? mdl.MaterialNames[baseIndex]
					: mdl.MaterialNames[matIndex];

				group.Remaps.Add( new MaterialRemap
				{
					From = PathResolve.MaterialRemapFromName( sourceName ),
					To = mat.VmatAssetPath
				} );
			}

			if ( group.Remaps.Count > 0 )
				context.MaterialGroups.Add( group );
		}
	}

	/// <summary>Studio mesh material is a skin reference; family 0 maps it to a texture index.</summary>
	static int ResolveSkinMaterial( MdlModel mdl, int skinRef )
	{
		if ( mdl.SkinFamilies.Count == 0 ) return skinRef;
		var row = mdl.SkinFamilies[0];
		return skinRef >= 0 && skinRef < row.Length ? row[skinRef] : skinRef;
	}

	static MeshExport BuildMeshExport(
		string meshName,
		MdlStudioModel studioModel,
		MdlModel mdl,
		VvdFile vvd,
		VtxFile vtx,
		int bpIndex,
		int modelIndex,
		Dictionary<int, MaterialBuildResult> materials )
	{
		var export = new MeshExport { Name = meshName };
		var baseVertex = studioModel.VertexIndex;

		// Copy this model's vertices into local list
		for ( var i = 0; i < studioModel.VertexCount; i++ )
		{
			var srcIndex = baseVertex + i;
			if ( srcIndex < 0 || srcIndex >= vvd.Vertices.Count )
			{
				export.Vertices.Add( new MeshVertex() );
				continue;
			}

			var src = vvd.Vertices[srcIndex];
			var copy = new MeshVertex
			{
				Position = new Vec3( src.Position.X, src.Position.Y, src.Position.Z ),
				Normal = new Vec3( src.Normal.X, src.Normal.Y, src.Normal.Z ),
				Uv = new Vec2( src.Uv.X, src.Uv.Y )
			};
			foreach ( var w in src.Weights )
				copy.Weights.Add( new BoneWeight { Bone = w.Bone, Weight = w.Weight } );
			export.Vertices.Add( copy );
		}

		if ( bpIndex < vtx.BodyParts.Count
			&& modelIndex < vtx.BodyParts[bpIndex].Models.Count
			&& vtx.BodyParts[bpIndex].Models[modelIndex].Lods.Count > 0 )
		{
			var lod = vtx.BodyParts[bpIndex].Models[modelIndex].Lods[0];
			for ( var meshIdx = 0; meshIdx < lod.Meshes.Count; meshIdx++ )
			{
				var vtxMesh = lod.Meshes[meshIdx];
				// FBX material slot must match DefaultMaterialGroup remap "from" (sans .vmat)
				var materialName = "default";
				var meshVertexOffset = 0;
				if ( meshIdx < studioModel.Meshes.Count )
				{
					var studioMesh = studioModel.Meshes[meshIdx];
					meshVertexOffset = studioMesh.VertexOffset;
					var matId = ResolveSkinMaterial( mdl, studioMesh.Material );
					if ( matId >= 0 && matId < mdl.MaterialNames.Count )
						materialName = PathResolve.MaterialSlotName( mdl.MaterialNames[matId] );
					else if ( materials.TryGetValue( matId, out var mat ) )
						materialName = PathResolve.MaterialSlotName( mat.VmatAssetPath );
				}

				// VTX vertex ids are relative to the studio mesh, not the studio model
				var indices = vtxMesh.Indices;
				var vertexCount = export.Vertices.Count;
				for ( var i = 0; i + 2 < indices.Count; i += 3 )
				{
					var a = meshVertexOffset + indices[i];
					var b = meshVertexOffset + indices[i + 1];
					var c = meshVertexOffset + indices[i + 2];
					if ( a >= vertexCount || b >= vertexCount || c >= vertexCount )
						continue;

					export.Triangles.Add( new MeshTriangle
					{
						A = a,
						B = b,
						C = c,
						MaterialName = materialName
					} );
				}
			}
		}

		return export;
	}

	static void ExportSequences( MdlModel mdl, ModelBuildContext context, Action<string> writeLog )
	{
		// ModelDoc rejects SMD as AnimFile ("unrecognized file type"). Until we decode Source
		// anim RLE into FBX clips, keep AnimBindPose only and surface sequence names in the log.
		_ = context;
		var names = new List<string>();
		var used = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var seq in mdl.Sequences )
		{
			var animName = PathResolve.SanitizeAssetName( seq.Name );
			if ( string.IsNullOrEmpty( animName ) || !used.Add( animName ) )
				continue;
			names.Add( animName );
		}

		if ( names.Count > 0 )
			writeLog( $"Sequence names ({names.Count}, not exported — need FBX anims): {string.Join( ", ", names.Take( 24 ) )}{(names.Count > 24 ? "…" : "")}" );
	}
}