Editor/Ui/GmodImporterWindow.cs

Editor UI window for a GMod asset importer. It builds a dialog with fields to pick input/gmod/output paths and options, scans/describes selected paths, logs messages with colored HTML, and invokes ConversionPipeline.Convert with configured ConversionOptions to run the conversion.

File AccessHttp Calls
using System;
using System.IO;
using System.Net;
using System.Text.RegularExpressions;
using Editor;
using GmodAssetImporter;

/// <summary>
/// Editor UI for converting GMod Source1 models/textures to s&box VMDL/PNG.
/// GMod: .vtf = texture, .vmt = material recipe (textures are what we port).
/// </summary>
public sealed class GmodImporterWindow : Dialog
{
	static GmodImporterWindow _current;

	readonly LineEdit _inputEdit;
	readonly LineEdit _gmodEdit;
	readonly LineEdit _outputEdit;
	readonly Checkbox _preservePaths;
	readonly Checkbox _exportAnims;
	readonly Checkbox _recursive;
	readonly Checkbox _importMaterials;
	readonly Checkbox _importModels;
	readonly LineEdit _scaleEdit;
	readonly TextEdit _log;
	readonly Label _status;

	public static void Open()
	{
		if ( _current is { IsValid: true } )
		{
			_current.Show();
			return;
		}

		_current = new GmodImporterWindow();
	}

	GmodImporterWindow() : base( null )
	{
		Window.Title = "Gmod Asset Importer";
		Window.SetWindowIcon( "folder_open" );
		Window.Size = new Vector2( 760, 720 );
		Window.MinimumSize = new Vector2( 600, 560 );
		Window.StateCookie = "GmodAssetImporter";

		Layout = Layout.Column();
		Layout.Margin = 16;
		Layout.Spacing = 10;

		var subtitle = Layout.Add( new Label( "Convert GMod .mdl / .vmt / .vtf into s&box .vmdl, .vmat and PNG." )
		{
			WordWrap = true
		} );
		subtitle.SetStyles( "color: #9aa0a6; font-size: 12px;" );

		AddSection( "Paths" );

		_inputEdit = AddLabeledPathRow( "Input", "File or folder (.mdl, .vmt, .vtf)", out var inputActions );
		inputActions.Add( new Button( "File", "draft" ) { Clicked = BrowseInputFile } );
		inputActions.Add( new Button( "Folder", "folder_open" ) { Clicked = BrowseInputFolder } );

		_gmodEdit = AddLabeledPathRow( "GMod root", "…/garrysmod (contains models/ and materials/)", out var gmodActions );
		gmodActions.Add( new Button( "Detect", "travel_explore" )
		{
			ToolTip = "Guess GMod root from the input path",
			Clicked = DetectGmod
		} );
		gmodActions.Add( new Button( "...", "folder_open" ) { Clicked = BrowseGmod } );

		_outputEdit = AddLabeledPathRow( "Output", "Project Assets root", out var outputActions );
		outputActions.Add( new Button( "...", "folder_open" ) { Clicked = BrowseOutput } );
		var assetsPath = Sandbox.Project.Current?.GetAssetsPath();
		if ( !string.IsNullOrWhiteSpace( assetsPath ) )
			_outputEdit.Text = assetsPath;

		AddSection( "Import" );

		var importRow = Layout.AddRow();
		importRow.Spacing = 16;
		_importModels = importRow.Add( new Checkbox( "Models" ) { Value = true } );
		_importModels.ToolTip = "Off: skip .mdl meshes. With Materials on, a model's materials are still converted.";
		_importMaterials = importRow.Add( new Checkbox( "Materials" ) { Value = true } );
		_importMaterials.ToolTip = "Off: convert models only. The .vmdl still points at the expected .vmat paths.";
		_recursive = importRow.Add( new Checkbox( "Subfolders" ) { Value = true } );
		_recursive.ToolTip = "When input is a folder, also convert files in child folders.";
		importRow.AddStretchCell();

		AddSection( "Options" );

		var optionsRow = Layout.AddRow();
		optionsRow.Spacing = 16;
		_preservePaths = optionsRow.Add( new Checkbox( "Preserve paths" ) { Value = true } );
		_preservePaths.ToolTip = "Keep Source-relative models/… and materials/… folders under the output root.";
		_exportAnims = optionsRow.Add( new Checkbox( "Log sequences" ) { Value = false } );
		_exportAnims.ToolTip = "Log Source sequence names during convert (clips are not exported yet).";
		optionsRow.AddStretchCell();
		optionsRow.Add( new Label( "Scale" ) );
		_scaleEdit = optionsRow.Add( new LineEdit { Text = "1.0", PlaceholderText = "1.0" } );
		_scaleEdit.MaximumWidth = 72;
		_scaleEdit.ToolTip = "Global model scale applied in the VMDL.";

		Layout.AddSeparator( true );

		_status = Layout.Add( new Label( "Ready" ) { WordWrap = true } );
		_status.SetStyles( "color: #9aa0a6; font-size: 12px;" );

		var logHeader = Layout.AddRow();
		logHeader.Spacing = 8;
		var logLabel = logHeader.Add( new Label( "Log" ) );
		logLabel.SetStyles( "font-weight: 600;" );
		logHeader.AddStretchCell();
		logHeader.Add( new Button( "Clear", "delete_sweep" )
		{
			ToolTip = "Clear the log",
			Clicked = ClearLog
		} );

		_log = Layout.Add( new TextEdit(), 1 );
		_log.ReadOnly = true;
		_log.Editable = false;
		_log.TextSelectable = true;
		_log.SetStyles( "font-family: Consolas, monospace; padding: 10px; font-size: 12px; background-color: rgba(0,0,0,0.25);" );

		var footer = Layout.AddRow();
		footer.Spacing = 8;
		footer.AddStretchCell();
		footer.Add( new Button( "Scan", "search" )
		{
			ToolTip = "Count MDLs and materials in the input path",
			Clicked = ScanInput
		} );
		footer.Add( new Button.Primary( "Convert", "play_arrow" ) { Clicked = RunConvert } );

		Show();
	}

	void AddSection( string title )
	{
		Layout.AddSpacingCell( 4 );
		Layout.AddSeparator( true );
		var label = Layout.Add( new Label( title ) );
		label.SetStyles( "font-weight: 600; font-size: 12px; color: #d0d4d8;" );
	}

	LineEdit AddLabeledPathRow( string label, string placeholder, out Layout actions )
	{
		var row = Layout.AddRow();
		row.Spacing = 8;
		var name = row.Add( new Label( label ) );
		name.MinimumWidth = 84;
		name.SetStyles( "color: #c0c4c8;" );
		var edit = row.Add( new LineEdit { PlaceholderText = placeholder }, 1 );
		actions = row;
		return edit;
	}

	enum LogTone
	{
		Info,
		Detect,
		Issue
	}

	// Detect = cyan scan/summary lines; Issue = red failures/warnings; Info = normal grey.
	const string LogColorInfo = "#c8c8c8";
	const string LogColorDetect = "#5ec8ff";
	const string LogColorIssue = "#ff686b";
	const string LogColorModels = "#4da3ff";
	const string LogColorMaterials = "#ff8cc8";
	const string LogColorSuccess = "#6dce6d";

	static readonly Regex DetectedCountsRegex = new(
		@"^Detected\s+(\d+)\s+MDL\(s\),\s+(\d+)\s+material file\(s\)\s+\((\d+)\s+\.vmt,\s+(\d+)\s+\.vtf\)$",
		RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase );

	static readonly Regex DetectedMaterialsOnlyRegex = new(
		@"^Detected\s+(\d+)\s+material file\(s\)\s+\((\d+)\s+\.vmt,\s+(\d+)\s+\.vtf\)$",
		RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase );

	public override void OnDestroyed()
	{
		if ( _current == this )
			_current = null;
		base.OnDestroyed();
	}

	void BrowseInputFile()
	{
		var path = EditorUtility.OpenFileDialog( "Select MDL or VTF", "mdl;vtf;vmt", _inputEdit.Text );
		if ( string.IsNullOrWhiteSpace( path ) )
			return;

		_inputEdit.Text = path;
		if ( string.IsNullOrWhiteSpace( _gmodEdit.Text ) )
			_gmodEdit.Text = PathResolve.DetectGmodRoot( path );

		AppendLog( $"Input file: {path}" );
		DescribePath( path );
	}

	void BrowseInputFolder()
	{
		var folder = PickFolder( "Select input folder", _inputEdit.Text );
		if ( string.IsNullOrWhiteSpace( folder ) )
			return;

		_inputEdit.Text = folder;
		if ( string.IsNullOrWhiteSpace( _gmodEdit.Text ) )
			_gmodEdit.Text = PathResolve.DetectGmodRoot( folder );

		AppendLog( $"Input folder: {folder}" );
		DescribePath( folder );
	}

	void BrowseGmod()
	{
		var folder = PickFolder( "Select GMod content root (folder with models/ and materials/)", _gmodEdit.Text );
		if ( string.IsNullOrWhiteSpace( folder ) )
			return;

		var detected = PathResolve.DetectGmodRoot( folder );
		_gmodEdit.Text = string.IsNullOrWhiteSpace( detected ) ? folder : detected;
	}

	void BrowseOutput()
	{
		// Always open on this project's Assets folder (not the previously typed path).
		var start = Sandbox.Project.Current?.GetAssetsPath();
		if ( string.IsNullOrWhiteSpace( start ) || !Directory.Exists( start ) )
			start = _outputEdit.Text;

		var folder = PickFolder( "Select output Assets root", start );
		if ( !string.IsNullOrWhiteSpace( folder ) )
			_outputEdit.Text = folder;
	}

	/// <summary>
	/// OS folder picker — Open selects the folder you're in (unlike OpenFileDialog).
	/// </summary>
	static string PickFolder( string title, string defaultPath )
	{
		var start = ResolveBrowseDirectory( defaultPath );
		var fd = new FileDialog( null );
		fd.Title = title;
		if ( !string.IsNullOrWhiteSpace( start ) )
			fd.Directory = start;
		fd.SetFindDirectory();
		fd.SetModeOpen();

		if ( !fd.Execute() )
			return null;

		var selected = fd.SelectedFile;
		if ( string.IsNullOrWhiteSpace( selected ) )
			return null;

		return Directory.Exists( selected ) ? selected : Path.GetDirectoryName( selected );
	}

	static string ResolveBrowseDirectory( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) )
			return "";

		path = path.Trim();
		if ( Directory.Exists( path ) )
			return path;
		if ( File.Exists( path ) )
			return Path.GetDirectoryName( path ) ?? "";

		var parent = Path.GetDirectoryName( path );
		return Directory.Exists( parent ) ? parent : "";
	}

	void ScanInput()
	{
		var input = _inputEdit.Text?.Trim() ?? "";
		if ( string.IsNullOrWhiteSpace( input ) )
		{
			SetStatus( "Set an input file or folder first.", issue: true );
			return;
		}

		DescribePath( input );
	}

	void DescribePath( string path )
	{
		if ( Directory.Exists( path ) )
		{
			var counts = PathResolve.CountSourceAssets( path, _recursive.Value );
			var materialCount = counts.Vmt + counts.Vtf;
			SetStatus( $"Detected {counts.Mdl} MDL(s), {materialCount} material file(s) ({counts.Vmt} .vmt, {counts.Vtf} .vtf)" );
			AppendDetectedCounts( counts.Mdl, counts.Vmt, counts.Vtf );
			if ( counts.Vtf == 0 && counts.Vmt > 0 )
				AppendLog( "Note: no .vtf here — VMT textures will be looked up under the GMod root." );
			else if ( counts.Vtf > 0 || counts.Vmt > 0 )
				AppendLog( "Ready: each .vmt becomes a VMAT; loose .vtf become a basic VMAT (normal/mask maps stay PNG only)." );
			return;
		}

		if ( File.Exists( path ) )
		{
			var ext = Path.GetExtension( path ).ToLowerInvariant();
			var msg = ext switch
			{
				".vtf" => "VTF texture — will convert to PNG + VMAT (uses sibling .vmt if present)",
				".vmt" => "VMT material — will convert to VMAT + PNG textures",
				".mdl" => (_importModels.Value, _importMaterials.Value) switch
				{
					(true, true) => "MDL model — will convert with materials",
					(true, false) => "MDL model — will convert model only (Import materials off)",
					(false, true) => "MDL model — will convert materials only (Import models off)",
					_ => "MDL model — Import models and Import materials are both off"
				},
				_ => $"Selected {ext}"
			};
			SetStatus( msg );
			AppendLog( msg, LogTone.Detect );
			return;
		}

		SetStatus( "Path not found.", issue: true );
		AppendLog( "Path not found.", LogTone.Issue );
	}

	void DetectGmod()
	{
		var from = !string.IsNullOrWhiteSpace( _inputEdit.Text ) ? _inputEdit.Text : _gmodEdit.Text;
		var detected = PathResolve.DetectGmodRoot( from );
		if ( string.IsNullOrWhiteSpace( detected ) )
		{
			AppendLog( "Could not detect GMod root from input path.", LogTone.Issue );
			return;
		}

		_gmodEdit.Text = detected;
		AppendLog( $"Detected GMod root: {detected}", LogTone.Detect );
	}

	void RunConvert()
	{
		ClearLog();
		SetStatus( "Converting..." );

		var options = new ConversionOptions
		{
			GmodRoot = _gmodEdit.Text?.Trim() ?? "",
			OutputRoot = _outputEdit.Text?.Trim() ?? "",
			PreservePaths = _preservePaths.Value,
			ExportAnimations = _exportAnims.Value,
			Recursive = _recursive.Value,
			ImportMaterials = _importMaterials.Value,
			ImportModels = _importModels.Value,
			ModelScale = float.TryParse( _scaleEdit.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var s ) ? s : 1f,
			OnLog = AppendLog
		};

		if ( string.IsNullOrWhiteSpace( options.OutputRoot ) )
		{
			SetStatus( "Set an output Assets root.", issue: true );
			AppendLog( "Set an output Assets root.", LogTone.Issue );
			return;
		}

		if ( string.IsNullOrWhiteSpace( _inputEdit.Text ) )
		{
			SetStatus( "Set an input file or folder.", issue: true );
			AppendLog( "Set an input file or folder.", LogTone.Issue );
			return;
		}

		try
		{
			var input = _inputEdit.Text.Trim();
			DescribePath( input );
			var result = ConversionPipeline.Convert( input, options );
			SetStatus( result.Success ? result.Message : $"Failed: {result.Message}", issue: !result.Success );
			if ( !result.Success )
				AppendLog( $"Failed: {result.Message}", LogTone.Issue );
			else
				AppendLog( result.Message, LogTone.Detect );

			if ( result.Warnings.Count > 0 )
			{
				AppendLog( $"{result.Warnings.Count} warning(s):", LogTone.Issue );
				foreach ( var warning in result.Warnings )
					AppendLog( warning, LogTone.Issue );
			}
		}
		catch ( Exception ex )
		{
			SetStatus( $"Failed: {ex.Message}", issue: true );
			AppendLog( ex.ToString(), LogTone.Issue );
		}
	}

	void ClearLog()
	{
		_log.Clear();
		_status.Text = "Ready";
		_status.SetStyles( "color: #9aa0a6; font-size: 12px;" );
	}

	void SetStatus( string text, bool issue = false )
	{
		_status.Text = text ?? "";
		_status.SetStyles( issue
			? "color: #ff686b; font-size: 12px;"
			: "color: #9aa0a6; font-size: 12px;" );
	}

	void AppendLog( string line ) => AppendLog( line, ClassifyLog( line ) );

	void AppendLog( string line, LogTone tone )
	{
		if ( string.IsNullOrEmpty( line ) )
			return;

		if ( TryAppendDetectedCounts( line ) )
			return;

		if ( TryAppendBatchComplete( line ) )
			return;

		var color = tone switch
		{
			LogTone.Detect => LogColorDetect,
			LogTone.Issue => LogColorIssue,
			_ => LogColorInfo
		};

		var html = $"<span style=\"color:{color};\">{WebUtility.HtmlEncode( line )}</span>";
		_log.AppendHtml( html );
	}

	static readonly Regex BatchCompleteRegex = new(
		@"^Batch complete\s*-\s*(.+)$",
		RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase );

	bool TryAppendBatchComplete( string line )
	{
		var match = BatchCompleteRegex.Match( line );
		if ( !match.Success )
			return false;

		_log.AppendHtml(
			$"<span style=\"color:{LogColorSuccess};\">Batch complete</span>" +
			$"<span style=\"color:{LogColorInfo};\"> - {WebUtility.HtmlEncode( match.Groups[1].Value )}</span>" );
		return true;
	}

	void AppendDetectedCounts( int mdl, int vmt, int vtf )
	{
		var materials = vmt + vtf;
		_log.AppendHtml(
			$"<span style=\"color:{LogColorInfo};\">Detected </span>" +
			$"<span style=\"color:{LogColorModels};\">{mdl} MDL(s)</span>" +
			$"<span style=\"color:{LogColorInfo};\">, </span>" +
			$"<span style=\"color:{LogColorMaterials};\">{materials} material file(s) ({vmt} .vmt, {vtf} .vtf)</span>" );
	}

	bool TryAppendDetectedCounts( string line )
	{
		var full = DetectedCountsRegex.Match( line );
		if ( full.Success )
		{
			AppendDetectedCounts(
				int.Parse( full.Groups[1].Value ),
				int.Parse( full.Groups[3].Value ),
				int.Parse( full.Groups[4].Value ) );
			return true;
		}

		var matsOnly = DetectedMaterialsOnlyRegex.Match( line );
		if ( matsOnly.Success )
		{
			_log.AppendHtml(
				$"<span style=\"color:{LogColorInfo};\">Detected </span>" +
				$"<span style=\"color:{LogColorMaterials};\">{matsOnly.Groups[1].Value} material file(s) ({matsOnly.Groups[2].Value} .vmt, {matsOnly.Groups[3].Value} .vtf)</span>" );
			return true;
		}

		return false;
	}

	static LogTone ClassifyLog( string line )
	{
		if ( string.IsNullOrWhiteSpace( line ) )
			return LogTone.Info;

		if ( IsIssueLine( line ) )
			return LogTone.Issue;

		if ( IsDetectLine( line ) )
			return LogTone.Detect;

		return LogTone.Info;
	}

	static bool IsDetectLine( string line )
	{
		return line.StartsWith( "Detected ", StringComparison.OrdinalIgnoreCase )
			|| line.StartsWith( "Folder scan:", StringComparison.OrdinalIgnoreCase )
			|| line.StartsWith( "Materials:", StringComparison.OrdinalIgnoreCase )
			|| line.StartsWith( "Batch:", StringComparison.OrdinalIgnoreCase )
			|| line.StartsWith( "Converted ", StringComparison.OrdinalIgnoreCase )
			|| line.StartsWith( "Batch complete", StringComparison.OrdinalIgnoreCase )
			|| line.StartsWith( "MDL model", StringComparison.OrdinalIgnoreCase )
			|| line.StartsWith( "VTF texture", StringComparison.OrdinalIgnoreCase )
			|| line.StartsWith( "VMT material", StringComparison.OrdinalIgnoreCase );
	}

	static bool IsIssueLine( string line )
	{
		return line.StartsWith( "Error:", StringComparison.OrdinalIgnoreCase )
			|| line.StartsWith( "Failed:", StringComparison.OrdinalIgnoreCase )
			|| line.StartsWith( "Failed ", StringComparison.OrdinalIgnoreCase )
			|| line.EndsWith( "warning(s):", StringComparison.OrdinalIgnoreCase )
			|| line.Contains( "Missing VTF", StringComparison.OrdinalIgnoreCase )
			|| line.Contains( "VTF not found", StringComparison.OrdinalIgnoreCase )
			|| line.Contains( "VTF decode failed", StringComparison.OrdinalIgnoreCase )
			|| line.Contains( "VMT parse failed", StringComparison.OrdinalIgnoreCase )
			|| line.Contains( "Material not found", StringComparison.OrdinalIgnoreCase )
			|| line.Contains( "writing fallback VMAT", StringComparison.OrdinalIgnoreCase )
			|| line.Contains( "nothing to convert", StringComparison.OrdinalIgnoreCase )
			|| line.Contains( "Path not found", StringComparison.OrdinalIgnoreCase )
			|| line.Contains( "Could not detect", StringComparison.OrdinalIgnoreCase )
			|| line.Contains( "No .mdl", StringComparison.OrdinalIgnoreCase )
			|| line.StartsWith( "System.", StringComparison.Ordinal );
	}
}

public static class GmodImporterMenu
{
	[Menu( "Editor", "Gmod Asset Importer/Open..." )]
	public static void OpenImporter()
	{
		GmodImporterWindow.Open();
	}
}