Editor/Conversion/Writers/VmdlWriter.cs

Writer for .vmdl (KV3 model) files used in the editor conversion pipeline. It builds a KV3 text representation from a ModelBuildContext and writes it to disk, emitting render meshes, body groups, material remaps/groups, animations, attachments and an optional scale modifier.

File Access
using System;
using System.Globalization;
using System.IO;
using System.Text;

namespace GmodAssetImporter;

public static class VmdlWriter
{
	const string Kv3Header =
		"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->";

	static readonly Encoding Utf8NoBom = new UTF8Encoding( false );

	public static string Build( ModelBuildContext context )
	{
		var sb = new StringBuilder();
		sb.AppendLine( Kv3Header );
		sb.AppendLine( "{" );
		sb.AppendLine( "\trootNode =" );
		sb.AppendLine( "\t{" );
		sb.AppendLine( "\t\t_class = \"RootNode\"" );
		sb.AppendLine( "\t\tchildren =" );
		sb.AppendLine( "\t\t[" );

		WriteRenderMeshList( sb, context );
		WriteBodyGroupList( sb, context );
		WriteMaterialGroupList( sb, context );
		WriteAnimationList( sb, context );
		WriteAttachmentList( sb, context );
		WriteScaleModifier( sb, context );

		sb.AppendLine( "\t\t]" );
		sb.AppendLine( "\t\tmodel_archetype = \"\"" );
		sb.AppendLine( "\t\tprimary_associated_entity = \"\"" );
		sb.AppendLine( "\t\tanim_graph_name = \"\"" );
		sb.AppendLine( "\t\tbase_model_name = \"\"" );
		sb.AppendLine( "\t}" );
		sb.AppendLine( "}" );
		return sb.ToString();
	}

	public static void Write( string path, ModelBuildContext context )
	{
		Directory.CreateDirectory( Path.GetDirectoryName( path )! );
		File.WriteAllText( path, Build( context ), Utf8NoBom );
	}

	static void WriteRenderMeshList( StringBuilder sb, ModelBuildContext context )
	{
		sb.AppendLine( "\t\t\t{" );
		sb.AppendLine( "\t\t\t\t_class = \"RenderMeshList\"" );
		sb.AppendLine( "\t\t\t\tchildren =" );
		sb.AppendLine( "\t\t\t\t[" );
		foreach ( var mesh in context.Meshes )
		{
			var file = AssetPath( context, mesh.FileName );
			sb.AppendLine( "\t\t\t\t\t{" );
			sb.AppendLine( "\t\t\t\t\t\t_class = \"RenderMeshFile\"" );
			sb.AppendLine( $"\t\t\t\t\t\tname = \"{Esc( mesh.Name )}\"" );
			sb.AppendLine( $"\t\t\t\t\t\tfilename = \"{Esc( file )}\"" );
			sb.AppendLine( "\t\t\t\t\t\timport_translation = [ 0.0, 0.0, 0.0 ]" );
			sb.AppendLine( "\t\t\t\t\t\timport_rotation = [ 0.0, 0.0, 0.0 ]" );
			sb.AppendLine( "\t\t\t\t\t\timport_scale = 1.0" );
			sb.AppendLine( "\t\t\t\t\t\talign_origin_x_type = \"None\"" );
			sb.AppendLine( "\t\t\t\t\t\talign_origin_y_type = \"None\"" );
			sb.AppendLine( "\t\t\t\t\t\talign_origin_z_type = \"None\"" );
			sb.AppendLine( "\t\t\t\t\t\tparent_bone = \"\"" );
			sb.AppendLine( "\t\t\t\t\t\timport_filter =" );
			sb.AppendLine( "\t\t\t\t\t\t{" );
			sb.AppendLine( "\t\t\t\t\t\t\texclude_by_default = false" );
			sb.AppendLine( "\t\t\t\t\t\t\texception_list = [ ]" );
			sb.AppendLine( "\t\t\t\t\t\t}" );
			sb.AppendLine( "\t\t\t\t\t}," );
		}
		sb.AppendLine( "\t\t\t\t]" );
		sb.AppendLine( "\t\t\t}," );
	}

	static void WriteBodyGroupList( StringBuilder sb, ModelBuildContext context )
	{
		if ( context.BodyGroups.Count == 0 ) return;
		sb.AppendLine( "\t\t\t{" );
		sb.AppendLine( "\t\t\t\t_class = \"BodyGroupList\"" );
		sb.AppendLine( "\t\t\t\tchildren =" );
		sb.AppendLine( "\t\t\t\t[" );
		foreach ( var group in context.BodyGroups )
		{
			sb.AppendLine( "\t\t\t\t\t{" );
			sb.AppendLine( "\t\t\t\t\t\t_class = \"BodyGroup\"" );
			sb.AppendLine( $"\t\t\t\t\t\tname = \"{Esc( group.Name )}\"" );
			sb.AppendLine( "\t\t\t\t\t\tchildren =" );
			sb.AppendLine( "\t\t\t\t\t\t[" );
			foreach ( var choice in group.Choices )
			{
				sb.AppendLine( "\t\t\t\t\t\t\t{" );
				sb.AppendLine( "\t\t\t\t\t\t\t\t_class = \"BodyGroupChoice\"" );
				sb.AppendLine( $"\t\t\t\t\t\t\t\tname = \"{Esc( choice.Name )}\"" );
				sb.AppendLine( "\t\t\t\t\t\t\t\tmeshes =" );
				sb.AppendLine( "\t\t\t\t\t\t\t\t[" );
				foreach ( var meshName in choice.MeshNames )
					sb.AppendLine( $"\t\t\t\t\t\t\t\t\t\"{Esc( meshName )}\"," );
				sb.AppendLine( "\t\t\t\t\t\t\t\t]" );
				sb.AppendLine( "\t\t\t\t\t\t\t}," );
			}
			sb.AppendLine( "\t\t\t\t\t\t]" );
			sb.AppendLine( "\t\t\t\t\t\thidden_in_tools = false" );
			sb.AppendLine( "\t\t\t\t\t}," );
		}
		sb.AppendLine( "\t\t\t\t]" );
		sb.AppendLine( "\t\t\t}," );
	}

	static void WriteMaterialGroupList( StringBuilder sb, ModelBuildContext context )
	{
		if ( context.MaterialRemaps.Count == 0 && context.MaterialGroups.Count == 0 ) return;
		sb.AppendLine( "\t\t\t{" );
		sb.AppendLine( "\t\t\t\t_class = \"MaterialGroupList\"" );
		sb.AppendLine( "\t\t\t\tchildren =" );
		sb.AppendLine( "\t\t\t\t[" );
		sb.AppendLine( "\t\t\t\t\t{" );
		sb.AppendLine( "\t\t\t\t\t\t_class = \"DefaultMaterialGroup\"" );
		sb.AppendLine( "\t\t\t\t\t\tremaps =" );
		sb.AppendLine( "\t\t\t\t\t\t[" );
		foreach ( var remap in context.MaterialRemaps )
		{
			sb.AppendLine( "\t\t\t\t\t\t\t{" );
			sb.AppendLine( $"\t\t\t\t\t\t\t\tfrom = \"{Esc( remap.From )}\"" );
			sb.AppendLine( $"\t\t\t\t\t\t\t\tto = \"{Esc( remap.To )}\"" );
			sb.AppendLine( "\t\t\t\t\t\t\t}," );
		}
		sb.AppendLine( "\t\t\t\t\t\t]" );
		sb.AppendLine( "\t\t\t\t\t\tuse_global_default = false" );
		sb.AppendLine( "\t\t\t\t\t\tglobal_default_material = \"\"" );
		sb.AppendLine( "\t\t\t\t\t}," );

		foreach ( var group in context.MaterialGroups )
		{
			sb.AppendLine( "\t\t\t\t\t{" );
			sb.AppendLine( "\t\t\t\t\t\t_class = \"MaterialGroup\"" );
			sb.AppendLine( $"\t\t\t\t\t\tname = \"{Esc( group.Name )}\"" );
			sb.AppendLine( "\t\t\t\t\t\tremaps =" );
			sb.AppendLine( "\t\t\t\t\t\t[" );
			foreach ( var remap in group.Remaps )
			{
				sb.AppendLine( "\t\t\t\t\t\t\t{" );
				sb.AppendLine( $"\t\t\t\t\t\t\t\tfrom = \"{Esc( remap.From )}\"" );
				sb.AppendLine( $"\t\t\t\t\t\t\t\tto = \"{Esc( remap.To )}\"" );
				sb.AppendLine( "\t\t\t\t\t\t\t}," );
			}
			sb.AppendLine( "\t\t\t\t\t\t]" );
			sb.AppendLine( "\t\t\t\t\t}," );
		}

		sb.AppendLine( "\t\t\t\t]" );
		sb.AppendLine( "\t\t\t}," );
	}

	static void WriteAnimationList( StringBuilder sb, ModelBuildContext context )
	{
		sb.AppendLine( "\t\t\t{" );
		sb.AppendLine( "\t\t\t\t_class = \"AnimationList\"" );
		sb.AppendLine( "\t\t\t\tchildren =" );
		sb.AppendLine( "\t\t\t\t[" );

		if ( context.IncludeBindPose )
		{
			sb.AppendLine( "\t\t\t\t\t{" );
			sb.AppendLine( "\t\t\t\t\t\t_class = \"AnimBindPose\"" );
			sb.AppendLine( "\t\t\t\t\t\tname = \"bindPose\"" );
			sb.AppendLine( "\t\t\t\t\t\tactivity_name = \"\"" );
			sb.AppendLine( "\t\t\t\t\t\tactivity_weight = 1" );
			sb.AppendLine( "\t\t\t\t\t\tweight_list_name = \"\"" );
			sb.AppendLine( "\t\t\t\t\t\tfade_in_time = 0.2" );
			sb.AppendLine( "\t\t\t\t\t\tfade_out_time = 0.2" );
			sb.AppendLine( "\t\t\t\t\t\tlooping = false" );
			sb.AppendLine( "\t\t\t\t\t\tdelta = false" );
			sb.AppendLine( "\t\t\t\t\t\tworldSpace = false" );
			sb.AppendLine( "\t\t\t\t\t\thidden = false" );
			sb.AppendLine( "\t\t\t\t\t\tanim_markup_ordered = false" );
			sb.AppendLine( "\t\t\t\t\t\tdisable_compression = false" );
			sb.AppendLine( "\t\t\t\t\t\tdisable_interpolation = false" );
			sb.AppendLine( "\t\t\t\t\t\tenable_scale = false" );
			sb.AppendLine( "\t\t\t\t\t\tframe_count = 1" );
			sb.AppendLine( "\t\t\t\t\t\tframe_rate = 30" );
			sb.AppendLine( "\t\t\t\t\t}," );
		}

		foreach ( var anim in context.Animations )
		{
			var file = AssetPath( context, anim.FileName );
			sb.AppendLine( "\t\t\t\t\t{" );
			sb.AppendLine( "\t\t\t\t\t\t_class = \"AnimFile\"" );
			sb.AppendLine( $"\t\t\t\t\t\tname = \"{Esc( anim.Name )}\"" );
			sb.AppendLine( "\t\t\t\t\t\tactivity_name = \"\"" );
			sb.AppendLine( "\t\t\t\t\t\tactivity_weight = 1" );
			sb.AppendLine( "\t\t\t\t\t\tweight_list_name = \"\"" );
			sb.AppendLine( "\t\t\t\t\t\tfade_in_time = 0.2" );
			sb.AppendLine( "\t\t\t\t\t\tfade_out_time = 0.2" );
			sb.AppendLine( $"\t\t\t\t\t\tlooping = {(anim.Looping ? "true" : "false")}" );
			sb.AppendLine( "\t\t\t\t\t\tdelta = false" );
			sb.AppendLine( "\t\t\t\t\t\tworldSpace = false" );
			sb.AppendLine( "\t\t\t\t\t\thidden = false" );
			sb.AppendLine( "\t\t\t\t\t\tanim_markup_ordered = false" );
			sb.AppendLine( "\t\t\t\t\t\tdisable_compression = false" );
			sb.AppendLine( "\t\t\t\t\t\tenable_scale = false" );
			sb.AppendLine( $"\t\t\t\t\t\tsource_filename = \"{Esc( file )}\"" );
			sb.AppendLine( "\t\t\t\t\t\tstart_frame = -1" );
			sb.AppendLine( "\t\t\t\t\t\tend_frame = -1" );
			sb.AppendLine( $"\t\t\t\t\t\tframerate = {Fmt( anim.FrameRate )}" );
			sb.AppendLine( "\t\t\t\t\t\ttake = 0" );
			sb.AppendLine( "\t\t\t\t\t\treverse = false" );
			sb.AppendLine( "\t\t\t\t\t}," );
		}

		sb.AppendLine( "\t\t\t\t]" );
		sb.AppendLine( "\t\t\t}," );
	}

	static void WriteAttachmentList( StringBuilder sb, ModelBuildContext context )
	{
		if ( context.Attachments.Count == 0 ) return;
		sb.AppendLine( "\t\t\t{" );
		sb.AppendLine( "\t\t\t\t_class = \"AttachmentList\"" );
		sb.AppendLine( "\t\t\t\tchildren =" );
		sb.AppendLine( "\t\t\t\t[" );
		foreach ( var att in context.Attachments )
		{
			sb.AppendLine( "\t\t\t\t\t{" );
			sb.AppendLine( "\t\t\t\t\t\t_class = \"Attachment\"" );
			sb.AppendLine( $"\t\t\t\t\t\tname = \"{Esc( att.Name )}\"" );
			sb.AppendLine( $"\t\t\t\t\t\tparent_bone = \"{Esc( att.ParentBone )}\"" );
			sb.AppendLine( $"\t\t\t\t\t\trelative_origin = {FmtVec( att.Origin )}" );
			sb.AppendLine( $"\t\t\t\t\t\trelative_angles = {FmtVec( att.Angles )}" );
			sb.AppendLine( "\t\t\t\t\t\tweight = 1.0" );
			sb.AppendLine( "\t\t\t\t\t\tignore_rotation = false" );
			sb.AppendLine( "\t\t\t\t\t}," );
		}
		sb.AppendLine( "\t\t\t\t]" );
		sb.AppendLine( "\t\t\t}," );
	}

	static void WriteScaleModifier( StringBuilder sb, ModelBuildContext context )
	{
		if ( MathF.Abs( context.Scale - 1.0f ) < 0.0001f ) return;
		sb.AppendLine( "\t\t\t{" );
		sb.AppendLine( "\t\t\t\t_class = \"ModelModifierList\"" );
		sb.AppendLine( "\t\t\t\tchildren =" );
		sb.AppendLine( "\t\t\t\t[" );
		sb.AppendLine( "\t\t\t\t\t{" );
		sb.AppendLine( "\t\t\t\t\t\t_class = \"ModelModifier_ScaleAndMirror\"" );
		sb.AppendLine( $"\t\t\t\t\t\tscale = {Fmt( context.Scale )}" );
		sb.AppendLine( "\t\t\t\t\t\tmirror_x = false" );
		sb.AppendLine( "\t\t\t\t\t\tmirror_y = false" );
		sb.AppendLine( "\t\t\t\t\t\tmirror_z = false" );
		sb.AppendLine( "\t\t\t\t\t\tflip_bone_forward = false" );
		sb.AppendLine( "\t\t\t\t\t\tswap_left_and_right_bones = false" );
		sb.AppendLine( "\t\t\t\t\t}," );
		sb.AppendLine( "\t\t\t\t]" );
		sb.AppendLine( "\t\t\t}," );
	}

	static string AssetPath( ModelBuildContext context, string fileName )
	{
		if ( string.IsNullOrWhiteSpace( context.ModelAssetDirectory ) )
			return PathResolve.NormalizeSlashes( fileName );
		return PathResolve.CombineAsset( context.ModelAssetDirectory, fileName );
	}

	static string Esc( string s ) => (s ?? "").Replace( "\\", "\\\\" ).Replace( "\"", "\\\"" );

	static string Fmt( float v ) => v.ToString( "0.######", CultureInfo.InvariantCulture );

	static string FmtVec( Vec3 v ) => $"[ {Fmt( v.X )}, {Fmt( v.Y )}, {Fmt( v.Z )} ]";
}