Editor/Vmdl/VmdlBulkEditor.cs

Editor utility for bulk-editing .vmdl model source files. It parses a Kv3Document, finds RenderMeshFile and related physics objects, and updates fields like import_scale, align_origin_*_type and DefaultMaterialGroup entries while preserving original formatting.

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

namespace ModelPro.Vmdl;

/// <summary>
/// Align origin options for each axis of a RenderMeshFile entry.
/// These match the values used in ModelDoc's align_origin_*_type fields.
/// </summary>
public enum AlignOrigin
{
	None,
	Center,
	Mins,
	Maxs,
	BoundsCenter,
	BoundsMin,
	BoundsMax
}

public static class AlignOriginExtensions
{
	public static string ToKv3Value( this AlignOrigin value )
	{
		return value switch
		{
			AlignOrigin.Center => "Center",
			AlignOrigin.Mins => "Mins",
			AlignOrigin.Maxs => "Maxs",
			AlignOrigin.BoundsCenter => "BoundsCenter",
			AlignOrigin.BoundsMin => "BoundsMin",
			AlignOrigin.BoundsMax => "BoundsMax",
			_ => "None"
		};
	}
}

/// <summary>
/// Collision modes for converting a mesh file into a model, matching the
/// options in the asset browser's create-model popup.
/// </summary>
public enum CollisionMode
{
	/// <summary>A convex hull generated from the render geometry (PhysicsHullFromRender).</summary>
	Hull,

	/// <summary>An exact triangle mesh from the render geometry (PhysicsMeshFromRender).</summary>
	Mesh,

	/// <summary>A hull file that references the source mesh file, with the import scale baked in (PhysicsHullFile).</summary>
	File,

	/// <summary>No collision.</summary>
	None
}

public static class CollisionModeExtensions
{
	public static string ToDisplayString( this CollisionMode mode )
	{
		return mode switch
		{
			CollisionMode.Hull => "Convex Hull",
			CollisionMode.Mesh => "Exact Mesh",
			CollisionMode.File => "File (Hull from FBX)",
			_ => "None"
		};
	}
}

/// <summary>
/// The unit the scale value is specified in, matching the units dropdown in the
/// asset browser's create-model popup. Import scale stored in the vmdl is always
/// in inches, so the entered value is multiplied by the conversion factor.
/// </summary>
public enum ScaleUnit
{
	Inches,
	Feet,
	Meters,
	Centimeters,
	Millimeters,
	Custom
}

public static class ScaleUnitExtensions
{
	/// <summary>How many inches one of these units is. Custom returns 1 - the value is used as-is.</summary>
	public static float ToInches( this ScaleUnit unit )
	{
		return unit switch
		{
			ScaleUnit.Feet => 12.0f,
			ScaleUnit.Meters => 39.3701f,
			ScaleUnit.Centimeters => 0.3937f,
			ScaleUnit.Millimeters => 0.03937f,
			_ => 1.0f
		};
	}

	public static string ToDisplayString( this ScaleUnit unit )
	{
		return unit switch
		{
			ScaleUnit.Feet => "Feet (ft)",
			ScaleUnit.Meters => "Meters (m)",
			ScaleUnit.Centimeters => "Centimeters (cm)",
			ScaleUnit.Millimeters => "Millimeters (mm)",
			ScaleUnit.Custom => "Custom",
			_ => "Inches (in)"
		};
	}
}

/// <summary>
/// The properties that can be bulk-edited on every RenderMeshFile entry
/// in a model's RenderMeshList, plus the model's default material group.
/// </summary>
public readonly struct MeshEntryProperties
{
	/// <summary>The final import scale in inches, already converted from the chosen unit.</summary>
	public float? ImportScale { get; init; }
	public AlignOrigin? AlignOriginX { get; init; }
	public AlignOrigin? AlignOriginY { get; init; }
	public AlignOrigin? AlignOriginZ { get; init; }

	/// <summary>
	/// The global default material for the model's DefaultMaterialGroup
	/// (e.g. "materials/default.vmat"). Null leaves it unchanged.
	/// </summary>
	public string GlobalDefaultMaterial { get; init; }
}

/// <summary>A scale typed in a chosen unit, plus the unit it's in.</summary>
public readonly struct ScaleInput
{
	public float Value { get; init; }
	public ScaleUnit Unit { get; init; }

	/// <summary>
	/// The value is the import_scale multiplier directly - what gets written to the
	/// vmdl. The unit is a display helper: it rescales the number when you switch
	/// units (1 inch shows as 0.3937 in cm, since 1 cm = 0.3937 inches).
	/// </summary>
	public float ToImportScale() => Value;

	/// <summary>
	/// Re-express this scale in another unit so the import scale stays the same.
	/// E.g. 1 inch converts to 0.3937 centimeters (1 cm = 0.3937 inches).
	/// </summary>
	public ScaleInput ConvertTo( ScaleUnit newUnit )
	{
		if ( newUnit == Unit )
			return this;

		var converted = Value * newUnit.ToInches() / Unit.ToInches();
		return new ScaleInput { Value = converted, Unit = newUnit };
	}

	/// <summary>
	/// Convert a raw import scale into a display value for a given unit.
	/// </summary>
	public static ScaleInput FromImportScale( float importScale, ScaleUnit unit )
	{
		return new ScaleInput { Value = importScale, Unit = unit };
	}
}

/// <summary>
/// Loads a .vmdl source file, finds all RenderMeshFile mesh entries and applies
/// bulk property edits to them, writing the result back while preserving the
/// original file formatting.
/// </summary>
public sealed class VmdlBulkEditor
{
	readonly Kv3Document _document;
	readonly List<Kv3Object> _meshEntries;
	string _source;

	public string Source => _source;

	public int MeshEntryCount => _meshEntries.Count;

	public bool HasRenderMeshList { get; }

	private VmdlBulkEditor( string source )
	{
		_source = source;
		_document = Kv3Document.Parse( source );

		HasRenderMeshList = _document.FindObjects( "RenderMeshList" ).Count > 0;
		_meshEntries = _document.FindObjects( "RenderMeshFile" );
	}

	public static VmdlBulkEditor Load( string source ) => new( source );

	public static VmdlBulkEditor LoadFile( string path )
	{
		return new VmdlBulkEditor( File.ReadAllText( path ) );
	}

	/// <summary>
	/// Apply the given properties to every mesh entry. Only properties that are
	/// non-null are changed. Returns true if anything was actually modified.
	/// </summary>
	public bool Apply( MeshEntryProperties props )
	{
		var edits = new List<(int Start, int End, string Text)>();

		foreach ( var entry in _meshEntries )
		{
			if ( props.ImportScale.HasValue )
			{
				SetField( entry, "import_scale", FormatNumber( props.ImportScale.Value ), edits );
			}

			if ( props.AlignOriginX.HasValue )
				SetField( entry, "align_origin_x_type", Quote( props.AlignOriginX.Value.ToKv3Value() ), edits );

			if ( props.AlignOriginY.HasValue )
				SetField( entry, "align_origin_y_type", Quote( props.AlignOriginY.Value.ToKv3Value() ), edits );

			if ( props.AlignOriginZ.HasValue )
				SetField( entry, "align_origin_z_type", Quote( props.AlignOriginZ.Value.ToKv3Value() ), edits );
		}

		// Align origin also needs to be set on the collision shapes so they stay
		// aligned with the render geometry - the physics nodes share the same
		// align_origin_*_type fields.
		if ( props.AlignOriginX.HasValue || props.AlignOriginY.HasValue || props.AlignOriginZ.HasValue )
		{
			foreach ( var shape in _document.FindObjects( "PhysicsHullFromRender" ) )
				ApplyAlignOrigin( shape, props, edits );
			foreach ( var shape in _document.FindObjects( "PhysicsMeshFromRender" ) )
				ApplyAlignOrigin( shape, props, edits );
			foreach ( var shape in _document.FindObjects( "PhysicsHullFile" ) )
				ApplyAlignOrigin( shape, props, edits );
			foreach ( var shape in _document.FindObjects( "PhysicsMeshFile" ) )
				ApplyAlignOrigin( shape, props, edits );
		}

		if ( props.GlobalDefaultMaterial is not null )
		{
			foreach ( var group in _document.FindObjects( "DefaultMaterialGroup" ) )
			{
				SetField( group, "global_default_material", Quote( props.GlobalDefaultMaterial ), edits );
				SetField( group, "use_global_default", "true", edits );
			}
		}

		if ( edits.Count == 0 )
			return false;

		var sb = new StringBuilder( _source );
		foreach ( var e in edits.OrderByDescending( x => x.Start ) )
		{
			sb.Remove( e.Start, e.End - e.Start );
			sb.Insert( e.Start, e.Text );
		}

		_source = sb.ToString();
		return true;
	}

	private void ApplyAlignOrigin( Kv3Object shape, MeshEntryProperties props, List<(int Start, int End, string Text)> edits )
	{
		if ( props.AlignOriginX.HasValue )
			SetField( shape, "align_origin_x_type", Quote( props.AlignOriginX.Value.ToKv3Value() ), edits );

		if ( props.AlignOriginY.HasValue )
			SetField( shape, "align_origin_y_type", Quote( props.AlignOriginY.Value.ToKv3Value() ), edits );

		if ( props.AlignOriginZ.HasValue )
			SetField( shape, "align_origin_z_type", Quote( props.AlignOriginZ.Value.ToKv3Value() ), edits );
	}

	/// <summary>Returns the current state of a mesh entry's editable properties (from the first entry).</summary>
	public MeshEntryProperties ReadFirstEntryProperties()
	{
		var entry = _meshEntries.FirstOrDefault();
		if ( entry is null )
			return default;

		return new MeshEntryProperties
		{
			ImportScale = ReadFloat( entry, "import_scale" ),
			AlignOriginX = ReadAlign( entry, "align_origin_x_type" ),
			AlignOriginY = ReadAlign( entry, "align_origin_y_type" ),
			AlignOriginZ = ReadAlign( entry, "align_origin_z_type" ),
			GlobalDefaultMaterial = ReadDefaultMaterial()
		};
	}

	/// <summary>The global default material path of the first DefaultMaterialGroup, or null.</summary>
	public string ReadDefaultMaterial()
	{
		var group = _document.FindObjects( "DefaultMaterialGroup" ).FirstOrDefault();
		if ( group is null )
			return null;

		return (group.FindField( "global_default_material" )?.Value as Kv3Scalar)?.Value;
	}

	private void SetField( Kv3Object obj, string key, string valueText, List<(int Start, int End, string Text)> edits )
	{
		var field = obj.FindField( key );

		if ( field?.Value is Kv3Scalar scalar )
		{
			if ( scalar.Raw != valueText )
				edits.Add( (scalar.Start, scalar.End, valueText) );
			return;
		}

		// Field doesn't exist - insert it right after the _class line.
		var classField = obj.FindField( "_class" );
		if ( classField?.Value is not Kv3Scalar classScalar )
			return;

		int lineEnd = _source.IndexOf( '\n', classScalar.End );
		if ( lineEnd < 0 )
			lineEnd = _source.Length;

		int lineStart = _source.LastIndexOf( '\n', Math.Max( 0, classField.KeyStart - 1 ) ) + 1;
		var indent = _source.Substring( lineStart, classField.KeyStart - lineStart );

		string insert = "\n" + indent + key + " = " + valueText;
		edits.Add( (lineEnd, lineEnd, insert) );
	}

	private static float? ReadFloat( Kv3Object obj, string key )
	{
		if ( obj.FindField( key )?.Value is Kv3Scalar s &&
			float.TryParse( s.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) )
		{
			return value;
		}

		return null;
	}

	private static AlignOrigin? ReadAlign( Kv3Object obj, string key )
	{
		if ( obj.FindField( key )?.Value is Kv3Scalar s )
		{
			return s.Value switch
			{
				"Center" => AlignOrigin.Center,
				"Mins" => AlignOrigin.Mins,
				"Maxs" => AlignOrigin.Maxs,
				"BoundsCenter" => AlignOrigin.BoundsCenter,
				"BoundsMin" => AlignOrigin.BoundsMin,
				"BoundsMax" => AlignOrigin.BoundsMax,
				_ => AlignOrigin.None
			};
		}

		return null;
	}

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

	private static string Quote( string value ) => $"\"{value}\"";
}