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.
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();
}
}