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.
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}\"";
}