Editor/Import/Kv3Writer.cs

Utility that generates kv3-formatted text for s&box model (.vmdl) and material (.vmat) files. It builds material properties and a model document including material remaps, collision hull, FBX render mesh reference, and an auto-LOD chain by concatenating strings into StringBuilder.

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

namespace Editor.UnrealImporter;

/// <summary>
/// Generates sbox .vmat and .vmdl (kv3 text) from processed import data.
/// Structure mirrors Assets/prefabs/capture_point/sm_flagpole_tall_01a.vmdl + .vmat.
/// </summary>
public static class Kv3Writer
{
	static string F( float v ) => v.ToString( "0.0######", CultureInfo.InvariantCulture );

	/// <summary>
	/// A complex.shader material. Texture arguments are Content-relative paths (forward slashes),
	/// or null to omit that slot.
	/// </summary>
	public static string VmatText( string color, string normal, string roughness, string metallic, string ao, string alpha = null )
	{
		var sb = new StringBuilder();
		sb.AppendLine( "// THIS FILE IS AUTO-GENERATED (unreal_importer)" );
		sb.AppendLine();
		sb.AppendLine( "Layer0" );
		sb.AppendLine( "{" );
		sb.AppendLine( "\tshader \"shaders/complex.shader\"" );
		sb.AppendLine();
		sb.AppendLine( "\t//---- PBR ----" );

		if ( !string.IsNullOrEmpty( metallic ) )
		{
			sb.AppendLine( "\tF_METALNESS_TEXTURE 1" );
		}
		
		sb.AppendLine( "\tF_SPECULAR 1" );

		if ( !string.IsNullOrEmpty( alpha ) )
		{
			sb.AppendLine();
			sb.AppendLine( "\t//---- Alpha ----" );
			sb.AppendLine( "\tF_TRANSLUCENT 1" );
			sb.AppendLine( $"\tTextureTranslucency \"{alpha}\"" );
		}

		if ( !string.IsNullOrEmpty( ao ) )
		{
			sb.AppendLine();
			sb.AppendLine( "\t//---- Ambient Occlusion ----" );
			sb.AppendLine( "\tg_flAmbientOcclusionDirectDiffuse \"0.000\"" );
			sb.AppendLine( "\tg_flAmbientOcclusionDirectSpecular \"0.000\"" );
			sb.AppendLine( $"\tTextureAmbientOcclusion \"{ao}\"" );
		}

		sb.AppendLine();
		sb.AppendLine( "\t//---- Color ----" );
		sb.AppendLine( "\tg_flModelTintAmount \"1.000\"" );
		sb.AppendLine( "\tg_vColorTint \"[1.000000 1.000000 1.000000 0.000000]\"" );
		if ( !string.IsNullOrEmpty( color ) )
			sb.AppendLine( $"\tTextureColor \"{color}\"" );

		sb.AppendLine();
		sb.AppendLine( "\t//---- Fog ----" );
		sb.AppendLine( "\tg_bFogEnabled \"1\"" );

		if ( !string.IsNullOrEmpty( metallic ) )
		{
			sb.AppendLine();
			sb.AppendLine( "\t//---- Metalness ----" );
			sb.AppendLine( $"\tTextureMetalness \"{metallic}\"" );
		}

		if ( !string.IsNullOrEmpty( normal ) )
		{
			sb.AppendLine();
			sb.AppendLine( "\t//---- Normal ----" );
			sb.AppendLine( $"\tTextureNormal \"{normal}\"" );
		}

		if ( !string.IsNullOrEmpty( roughness ) )
		{
			sb.AppendLine();
			sb.AppendLine( "\t//---- Roughness ----" );
			sb.AppendLine( "\tg_flRoughnessScaleFactor \"1.000\"" );
			sb.AppendLine( $"\tTextureRoughness \"{roughness}\"" );
		}

		sb.AppendLine();
		sb.AppendLine( "\t//---- Texture Coordinates ----" );
		sb.AppendLine( "\tg_vTexCoordOffset \"[0.000 0.000]\"" );
		sb.AppendLine( "\tg_vTexCoordScale \"[1.000 1.000]\"" );
		sb.AppendLine( "\tg_vTexCoordScrollSpeed \"[0.000 0.000]\"" );
		sb.AppendLine( "}" );

		return sb.ToString();
	}

	/// <summary>
	/// A static model referencing an FBX, with per-slot material remaps, a hull-from-render
	/// collision shape, and a 5-level auto-LOD chain (matches the flagpole reference).
	/// </summary>
	public static string VmdlText( string fbxContentPath, float importScale, IReadOnlyList<(string slot, string vmat)> remaps )
	{
		var sb = new StringBuilder();
		sb.AppendLine( "<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->" );
		sb.AppendLine( "{" );
		sb.AppendLine( "\trootNode =" );
		sb.AppendLine( "\t{" );
		sb.AppendLine( "\t\t_class = \"RootNode\"" );
		sb.AppendLine( "\t\tchildren =" );
		sb.AppendLine( "\t\t[" );

		// --- Material groups (remaps) ---
		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 (slot, vmat) in remaps )
		{
			sb.AppendLine( "\t\t\t\t\t\t\t{" );
			sb.AppendLine( $"\t\t\t\t\t\t\t\tfrom = \"{slot}\"" );
			sb.AppendLine( $"\t\t\t\t\t\t\t\tto = \"{vmat}\"" );
			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}," );
		sb.AppendLine( "\t\t\t\t]" );
		sb.AppendLine( "\t\t\t}," );

		// --- Collision (hull from render mesh) ---
		sb.AppendLine( "\t\t\t{" );
		sb.AppendLine( "\t\t\t\t_class = \"PhysicsShapeList\"" );
		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 = \"PhysicsHullFromRender\"" );
		sb.AppendLine( "\t\t\t\t\t\tparent_bone = \"\"" );
		sb.AppendLine( "\t\t\t\t\t\tsurface_prop = \"default\"" );
		sb.AppendLine( "\t\t\t\t\t\tcollision_tags = \"solid\"" );
		sb.AppendLine( "\t\t\t\t\t\tfaceMergeAngle = 20.0" );
		sb.AppendLine( "\t\t\t\t\t\tmaxHullVertices = 32" );
		sb.AppendLine( "\t\t\t\t\t\thull_mode = \"HullPerElement\"" );
		sb.AppendLine( "\t\t\t\t\t}," );
		sb.AppendLine( "\t\t\t\t]" );
		sb.AppendLine( "\t\t\t}," );

		// --- Render mesh (FBX) ---
		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[" );
		sb.AppendLine( "\t\t\t\t\t{" );
		sb.AppendLine( "\t\t\t\t\t\t_class = \"RenderMeshFile\"" );
		sb.AppendLine( $"\t\t\t\t\t\tfilename = \"{fbxContentPath}\"" );
		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 = {F( importScale )}" );
		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}," );

		// --- Auto LODs ---
		AppendLodGroupList( sb );

		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();
	}

	static void AppendLodGroupList( StringBuilder sb )
	{
		// (switch_threshold, simplify_mode, reduction, lock_border, permissive, protect_uv, meshes-on-lod0)
		var lods = new (float thr, int mode, float red, bool lockBorder, bool permissive, bool protectUv, bool hasMesh)[]
		{
			( 0.0f, 0, 0.5f, true, false, true, true ),
			( 25.0f, 1, 0.5f, true, false, true, false ),
			( 40.0f, 1, 0.5f, false, true, true, false ),
			( 60.0f, 1, 0.45f, false, true, false, false ),
			( 80.0f, 1, 0.4f, false, true, false, false ),
		};

		sb.AppendLine( "\t\t\t{" );
		sb.AppendLine( "\t\t\t\t_class = \"LODGroupList\"" );
		sb.AppendLine( "\t\t\t\tchildren =" );
		sb.AppendLine( "\t\t\t\t[" );

		foreach ( var l in lods )
		{
			sb.AppendLine( "\t\t\t\t\t{" );
			sb.AppendLine( "\t\t\t\t\t\t_class = \"LODGroup\"" );
			sb.AppendLine( $"\t\t\t\t\t\tswitch_threshold = {F( l.thr )}" );
			sb.AppendLine( $"\t\t\t\t\t\tauto_simplify_mode = {l.mode}" );
			sb.AppendLine( $"\t\t\t\t\t\tauto_reduction = {F( l.red )}" );
			sb.AppendLine( "\t\t\t\t\t\tauto_max_error = 0.0" );
			sb.AppendLine( $"\t\t\t\t\t\tauto_lock_border_vertices = {B( l.lockBorder )}" );
			sb.AppendLine( $"\t\t\t\t\t\tauto_permissive_simplification = {B( l.permissive )}" );
			sb.AppendLine( $"\t\t\t\t\t\tauto_protect_uv_seams = {B( l.protectUv )}" );
			sb.AppendLine( "\t\t\t\t\t\tauto_regularize = 1" );
			sb.AppendLine( "\t\t\t\t\t\tauto_prune_isolated_components = false" );
			sb.AppendLine( "\t\t\t\t\t\tauto_strip_vertex_color = false" );
			sb.AppendLine( "\t\t\t\t\t\tauto_material_culling_enabled = false" );
			sb.AppendLine( "\t\t\t\t\t\tmeshes =" );
			if ( l.hasMesh )
			{
				sb.AppendLine( "\t\t\t\t\t\t[" );
				sb.AppendLine( "\t\t\t\t\t\t\t\"unnamed_1\"," );
				sb.AppendLine( "\t\t\t\t\t\t]" );
			}
			else
			{
				sb.AppendLine( "\t\t\t\t\t\t[  ]" );
			}
			sb.AppendLine( "\t\t\t\t\t\tmaterial_culls = [  ]" );
			sb.AppendLine( "\t\t\t\t\t}," );
		}

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

	static string B( bool v ) => v ? "true" : "false";
}