Editor/Vmdl/VmdlGenerator.cs

Editor utility that generates KV3-format .vmdl model text from a source mesh path and options. It builds string output for material group, physics shape list and render mesh list according to selected collision, scale and origin alignment options.

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

namespace ModelPro.Vmdl;

/// <summary>
/// Options for converting a mesh file (.fbx, .obj, .dmx) into a model (.vmdl),
/// matching the asset browser's create-model popup.
/// </summary>
public struct MeshToModelOptions
{
	/// <summary>The collision shape to generate for the model.</summary>
	public CollisionMode Collision { get; set; }

	/// <summary>The import scale (in inches) to apply to the mesh.</summary>
	public float ImportScale { get; set; }

	public AlignOrigin AlignOriginX { get; set; }
	public AlignOrigin AlignOriginY { get; set; }
	public AlignOrigin AlignOriginZ { get; set; }
}

/// <summary>
/// Generates the KV3 text of a model (.vmdl) file from a source mesh file,
/// writing the same structure ModelDoc produces so it compiles cleanly.
/// </summary>
public static class VmdlGenerator
{
	const string Header = "<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc30:version{8c2d7a91-9c42-4bf0-883a-5a3b1762d4f1} -->";

	/// <summary>
	/// Build the vmdl source text for a mesh file at the given content-relative
	/// path (e.g. "models/foo.fbx").
	/// </summary>
	public static string Generate( string meshRelativePath, MeshToModelOptions options )
	{
		var sb = new StringBuilder();

		sb.AppendLine( Header );
		sb.AppendLine( "{" );
		sb.AppendLine( "\trootNode = " );
		sb.AppendLine( "\t{" );
		sb.AppendLine( "\t\t_class = \"RootNode\"" );
		sb.AppendLine( "\t\tchildren = " );
		sb.AppendLine( "\t\t[" );

		// Material group
		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\tuse_global_default = true" );
		sb.AppendLine( "\t\t\t\t\t\tglobal_default_material = \"materials/default.vmat\"" );
		sb.AppendLine( "\t\t\t\t\t}," );
		sb.AppendLine( "\t\t\t\t]" );
		sb.AppendLine( "\t\t\t}," );

		// Physics shape list
		AppendPhysicsShapeList( sb, meshRelativePath, options );

		// Render mesh list
		AppendRenderMeshList( sb, meshRelativePath, options );

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

	private static void AppendRenderMeshList( StringBuilder sb, string meshRelativePath, MeshToModelOptions options )
	{
		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 = \"{meshRelativePath}\"" );
		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 = {FormatNumber( options.ImportScale )}" );
		sb.AppendLine( $"\t\t\t\t\t\talign_origin_x_type = \"{options.AlignOriginX.ToKv3Value()}\"" );
		sb.AppendLine( $"\t\t\t\t\t\talign_origin_y_type = \"{options.AlignOriginY.ToKv3Value()}\"" );
		sb.AppendLine( $"\t\t\t\t\t\talign_origin_z_type = \"{options.AlignOriginZ.ToKv3Value()}\"" );
		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}" );
		sb.AppendLine( "\t\t\t\t\t}," );
		sb.AppendLine( "\t\t\t\t]" );
		sb.AppendLine( "\t\t\t}," );
	}

	private static void AppendPhysicsShapeList( StringBuilder sb, string meshRelativePath, MeshToModelOptions options )
	{
		if ( options.Collision == CollisionMode.None )
			return;

		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{" );

		switch ( options.Collision )
		{
			case CollisionMode.Mesh:
				sb.AppendLine( "\t\t\t\t\t\t_class = \"PhysicsMeshFromRender\"" );
				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\talign_origin_x_type = \"{options.AlignOriginX.ToKv3Value()}\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_y_type = \"{options.AlignOriginY.ToKv3Value()}\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_z_type = \"{options.AlignOriginZ.ToKv3Value()}\"" );
				break;

			case CollisionMode.File:
				sb.AppendLine( "\t\t\t\t\t\t_class = \"PhysicsHullFile\"" );
				sb.AppendLine( $"\t\t\t\t\t\tfilename = \"{meshRelativePath}\"" );
				sb.AppendLine( $"\t\t\t\t\t\timport_scale = {FormatNumber( options.ImportScale )}" );
				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\talign_origin_x_type = \"{options.AlignOriginX.ToKv3Value()}\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_y_type = \"{options.AlignOriginY.ToKv3Value()}\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_z_type = \"{options.AlignOriginZ.ToKv3Value()}\"" );
				sb.AppendLine( "\t\t\t\t\t\tfaceMergeAngle = 20.0" );
				sb.AppendLine( "\t\t\t\t\t\tmaxHullVertices = 32" );
				break;

			case CollisionMode.Hull:
			default:
				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\talign_origin_x_type = \"{options.AlignOriginX.ToKv3Value()}\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_y_type = \"{options.AlignOriginY.ToKv3Value()}\"" );
				sb.AppendLine( $"\t\t\t\t\t\talign_origin_z_type = \"{options.AlignOriginZ.ToKv3Value()}\"" );
				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\"" );
				break;
		}

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

	private static string FormatNumber( float value )
	{
		return value.ToString( "0.0########", CultureInfo.InvariantCulture );
	}
}