Editor/VmdlEditTab.cs

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.

File AccessExternal Download
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;
	}
}