Editor UI tab for Model Pro that finds .vmdl files in a selected folder and lets the user batch-edit mesh entry properties (import scale, origin alignment, default material). It loads/saves UI settings, previews the found files and mesh counts, applies edits by writing files and triggers asset recompilation.
using System;
using System.Globalization;
using System.IO;
using ModelPro.Vmdl;
namespace ModelPro.Editor;
/// <summary>
/// The VMDL tab of Model Pro. Pick a folder, it recursively finds every .vmdl
/// file, and you can apply uniform scale and align-origin X/Y/Z changes to all
/// their mesh entries at once.
/// </summary>
public class VmdlEditTab : Widget
{
TreeView _folderTree;
TreeView _fileList;
Label _statusLabel;
LineEdit _scaleEdit;
ComboBox _scaleUnit;
ScaleUnit _lastScaleUnit = ScaleUnit.Inches;
ComboBox _alignX;
ComboBox _alignY;
ComboBox _alignZ;
ControlSheet _materialSheet;
DefaultMaterialModel _materialModel = new();
Button _applyButton;
Label _resultLabel;
List<string> _foundVmdlFiles = new();
public VmdlEditTab( Widget parent ) : base( parent )
{
BuildUI();
LoadSettings();
}
private void BuildUI()
{
Layout = Layout.Column();
Layout.Spacing = 4;
var split = Layout.AddRow();
split.Spacing = 8;
var left = split.AddColumn();
left.Spacing = 4;
left.Add( new Label( "<b>Folder</b>" ) );
var treeScroll = left.Add( new ScrollArea( this ), 1 );
treeScroll.Canvas = new Widget();
treeScroll.Canvas.Layout = Layout.Column();
treeScroll.Canvas.Layout.Margin = new Sandbox.UI.Margin( 2 );
_folderTree = new TreeView( treeScroll.Canvas );
treeScroll.Canvas.Layout.Add( _folderTree );
_folderTree.ExpandForSelection = true;
AddFolderNodes();
var right = split.AddColumn();
right.Spacing = 4;
_statusLabel = right.Add( new Label( "Select a folder to search for models." ) );
_statusLabel.WordWrap = true;
var listScroll = right.Add( new ScrollArea( this ), 1 );
listScroll.Canvas = new Widget();
listScroll.Canvas.Layout = Layout.Column();
listScroll.Canvas.Layout.Margin = new Sandbox.UI.Margin( 2 );
_fileList = new TreeView( listScroll.Canvas );
listScroll.Canvas.Layout.Add( _fileList );
var group = right.Add( new Widget() );
var grid = Layout.Grid();
grid.Spacing = 4;
group.Layout = grid;
int row = 0;
grid.AddCell( 0, row, new Label( "Scale" ) );
var scaleRow = Layout.Row();
scaleRow.Spacing = 4;
_scaleEdit = new LineEdit() { Text = "1.0", FixedWidth = 90 };
_scaleUnit = new ComboBox() { FixedWidth = 130 };
foreach ( var unit in Enum.GetValues<ScaleUnit>() )
{
var u = unit;
_scaleUnit.AddItem( u.ToDisplayString(), onSelected: () => OnScaleUnitChanged( u ) );
}
_scaleUnit.TrySelectNamed( ScaleUnit.Inches.ToDisplayString() );
scaleRow.Add( _scaleEdit );
scaleRow.Add( _scaleUnit );
grid.AddCell( 1, row, scaleRow );
row++;
grid.AddCell( 0, row, new Label( "Align Origin X" ) );
_alignX = CreateAlignCombo();
grid.AddCell( 1, row, _alignX );
row++;
grid.AddCell( 0, row, new Label( "Align Origin Y" ) );
_alignY = CreateAlignCombo();
grid.AddCell( 1, row, _alignY );
row++;
grid.AddCell( 0, row, new Label( "Align Origin Z" ) );
_alignZ = CreateAlignCombo();
grid.AddCell( 1, row, _alignZ );
row++;
// The material picker has its own "Material" label, so give it a fixed
// width row below the grid - otherwise it stretches the whole window.
var materialWrap = new Widget() { FixedWidth = 280 };
materialWrap.Layout = Layout.Column();
_materialSheet = new ControlSheet();
_materialSheet.AddProperty( _materialModel, x => x.Material );
materialWrap.Layout.Add( _materialSheet );
right.Add( materialWrap );
_applyButton = new Button( "Apply to N models", "check" );
_applyButton.Clicked += ApplyEdits;
right.Add( _applyButton );
_resultLabel = new Label( "" );
_resultLabel.WordWrap = true;
right.Add( _resultLabel );
}
private ComboBox CreateAlignCombo()
{
var combo = new ComboBox() { FixedWidth = 120 };
combo.AddItem( "None" );
combo.AddItem( "BoundsCenter" );
combo.AddItem( "BoundsMin" );
combo.AddItem( "BoundsMax" );
combo.TrySelectNamed( "None" );
return combo;
}
/// <summary>Restore the last used values into the controls.</summary>
private void LoadSettings()
{
var s = ModelProSettings.Current;
_scaleEdit.Text = s.VmdlScale;
_lastScaleUnit = s.VmdlScaleUnit;
_scaleUnit.TrySelectNamed( s.VmdlScaleUnit.ToDisplayString() );
_alignX.TrySelectNamed( s.VmdlAlignX.ToKv3Value() );
_alignY.TrySelectNamed( s.VmdlAlignY.ToKv3Value() );
_alignZ.TrySelectNamed( s.VmdlAlignZ.ToKv3Value() );
_materialModel.Material = s.VmdlDefaultMaterial;
}
/// <summary>Remember the current values for next time.</summary>
private void SaveSettings()
{
var s = ModelProSettings.Current;
s.VmdlScale = _scaleEdit.Text;
s.VmdlScaleUnit = ParseScaleUnit( _scaleUnit.CurrentText );
s.VmdlAlignX = ParseAlign( _alignX.CurrentText ).Value;
s.VmdlAlignY = ParseAlign( _alignY.CurrentText ).Value;
s.VmdlAlignZ = ParseAlign( _alignZ.CurrentText ).Value;
s.VmdlDefaultMaterial = _materialModel.Material ?? "";
s.Save();
}
private void AddFolderNodes()
{
var rootDir = Sandbox.Project.Current?.RootDirectory;
if ( rootDir is null )
return;
var root = new FolderNode( rootDir.FullName, OnFolderSelected );
_folderTree.AddItem( root );
_folderTree.Open( root );
}
private void OnFolderSelected( string folder )
{
_statusLabel.Text = $"Searching <b>{folder}</b>...";
_resultLabel.Text = "";
_foundVmdlFiles = Directory.GetFiles( folder, "*.vmdl", SearchOption.AllDirectories )
.OrderBy( x => x, StringComparer.OrdinalIgnoreCase )
.ToList();
_fileList.Clear();
int meshEntryTotal = 0;
foreach ( var file in _foundVmdlFiles )
{
int count = 0;
try
{
var editor = VmdlBulkEditor.LoadFile( file );
count = editor.MeshEntryCount;
meshEntryTotal += count;
}
catch ( Exception e )
{
Log.Warning( e, $"Model Pro: failed to parse {file}" );
}
_fileList.AddItem( new FileNode( file, count ) );
}
_statusLabel.Text = $"Found <b>{_foundVmdlFiles.Count}</b> models in <b>{folder}</b> — {meshEntryTotal} mesh entries.";
_applyButton.Text = _foundVmdlFiles.Count == 0
? "Apply to 0 models"
: $"Apply to {_foundVmdlFiles.Count} models";
}
private void ApplyEdits()
{
if ( _foundVmdlFiles.Count == 0 )
return;
var props = new MeshEntryProperties
{
ImportScale = ReadScale(),
AlignOriginX = ParseAlign( _alignX.CurrentText ),
AlignOriginY = ParseAlign( _alignY.CurrentText ),
AlignOriginZ = ParseAlign( _alignZ.CurrentText ),
GlobalDefaultMaterial = string.IsNullOrWhiteSpace( _materialModel.Material ) ? null : _materialModel.Material.Trim()
};
Log.Info( $"Model Pro: applying scale={props.ImportScale?.ToString( "0.0########", CultureInfo.InvariantCulture ) ?? "off"} " +
$"align=({_alignX.CurrentText},{_alignY.CurrentText},{_alignZ.CurrentText}) to {_foundVmdlFiles.Count} file(s)" );
int modified = 0;
int totalEntries = 0;
int noMeshEntries = 0;
int alreadyMatched = 0;
var errors = new List<string>();
foreach ( var file in _foundVmdlFiles )
{
try
{
var editor = VmdlBulkEditor.LoadFile( file );
if ( editor.MeshEntryCount == 0 )
{
noMeshEntries++;
Log.Info( $"Model Pro: {Path.GetFileName( file )} - no RenderMeshFile entries found" );
}
else if ( editor.Apply( props ) )
{
File.WriteAllText( file, editor.Source );
modified++;
Log.Info( $"Model Pro: updated {Path.GetFileName( file )}" );
RecompileModel( file );
}
else
{
alreadyMatched++;
Log.Info( $"Model Pro: {Path.GetFileName( file )} - values already match" );
}
totalEntries += editor.MeshEntryCount;
}
catch ( Exception e )
{
errors.Add( $"{Path.GetFileName( file )}: {e.Message}" );
Log.Warning( e, $"Model Pro: failed to process {file}" );
}
}
var msg = $"Updated <b>{modified}</b> of {_foundVmdlFiles.Count} models ({totalEntries} mesh entries).";
if ( modified == 0 && errors.Count == 0 )
{
var reason = noMeshEntries > 0 && alreadyMatched == 0
? "None of the files contain RenderMeshFile entries."
: "The values already match what's set above. Change a value and try again.";
msg = $"No models needed updating — {reason}";
}
if ( errors.Count > 0 )
msg += $"\n\nErrors ({errors.Count}):\n{string.Join( "\n", errors.Take( 8 ) )}";
_resultLabel.Text = msg;
SaveSettings();
}
private static AlignOrigin? ParseAlign( string text )
{
return text switch
{
"Center" => AlignOrigin.Center,
"Mins" => AlignOrigin.Mins,
"Maxs" => AlignOrigin.Maxs,
"BoundsCenter" => AlignOrigin.BoundsCenter,
"BoundsMin" => AlignOrigin.BoundsMin,
"BoundsMax" => AlignOrigin.BoundsMax,
_ => AlignOrigin.None
};
}
/// <summary>
/// Force the editor to recompile the model from its (just-written) source file
/// so the asset system and any open ModelDoc pick up the change.
/// </summary>
private static void RecompileModel( string absolutePath )
{
try
{
var project = Sandbox.Project.Current;
var assetsRoot = project?.GetAssetsPath();
if ( string.IsNullOrEmpty( assetsRoot ) )
return;
// Find the asset by its content-relative path - FindByPath expects the
// relative asset path (e.g. "models/vehicle_01_a.vmdl"), not a Windows path.
var relative = absolutePath.Replace( '\\', '/' );
if ( relative.StartsWith( assetsRoot.Replace( '\\', '/' ), StringComparison.OrdinalIgnoreCase ) )
relative = relative.Substring( assetsRoot.Replace( '\\', '/' ).Length ).TrimStart( '/' );
var asset = AssetSystem.FindByPath( relative );
if ( asset is null )
{
// Fall back to matching by absolute path.
asset = AssetSystem.FindByPath( absolutePath );
}
if ( asset is null )
{
Log.Warning( $"Model Pro: couldn't find asset for '{absolutePath}' to recompile" );
return;
}
asset.Compile( true );
}
catch ( Exception e )
{
Log.Warning( e, $"Model Pro: failed to recompile '{absolutePath}'" );
}
}
private float? ReadScale()
{
if ( !float.TryParse( _scaleEdit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) )
return null;
var unit = ParseScaleUnit( _scaleUnit.CurrentText );
return new ScaleInput { Value = value, Unit = unit }.ToImportScale();
}
/// <summary>
/// When the unit dropdown changes, convert the current scale value so the
/// import scale stays the same - just like the create-model popup does.
/// </summary>
private void OnScaleUnitChanged( ScaleUnit newUnit )
{
if ( newUnit == _lastScaleUnit )
return;
if ( float.TryParse( _scaleEdit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var current ) )
{
var converted = new ScaleInput { Value = current, Unit = _lastScaleUnit }.ConvertTo( newUnit );
_scaleEdit.Text = converted.Value.ToString( "0.0########", CultureInfo.InvariantCulture );
}
_lastScaleUnit = newUnit;
}
private static ScaleUnit ParseScaleUnit( string text )
{
foreach ( var unit in Enum.GetValues<ScaleUnit>() )
{
if ( string.Equals( unit.ToDisplayString(), text, StringComparison.OrdinalIgnoreCase ) )
return unit;
}
return ScaleUnit.Inches;
}
}