Editor/ImagetoMaterial.cs

Editor window tool that bulk-converts image folders into material (.vmat) files. It provides a UI to pick a source folder, read image layers by filename suffixes, construct material text with references and write material files to disk.

File AccessHttp Calls
🌐 https://sbox.game/sturnus, https://sbox.game/u/AustinDotCodes
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Reflection;
using System.Reflection.Emit;
using System.Runtime.CompilerServices;
using System.Security.Cryptography.X509Certificates;
using Editor;
using Editor.ShaderGraph;
using Editor.ShaderGraph.Nodes;
using Editor.Widgets;
using Sandbox;
using Sandbox.Engine.Shaders;
using Sandbox.UI;
using Sandbox.Utility;
using static Sandbox.Material;

[EditorApp( "Image to Material", "transform", "Tool for converting images to materials in bulk." )]

public class ImagetoMaterial : BaseWindow
{

	public string GenerationPath { get; set; } = Editor.FileSystem.Content.GetFullPath( "" ) + "\\ImageToMaterial\\";
	public string GenerationPathInput { get; set; } = Editor.FileSystem.Content.GetFullPath( "" ) + "\\ImageToMaterial\\Input";
	public string GenerationPathOutput { get; set; } = Editor.FileSystem.Content.GetFullPath( "" ) + "\\ImageToMaterial\\Output";

	public string GenerationLocalPath { get; set; } = "\\ImageToMaterial\\";
	public string ExportPath { get; set; } = Project.Current.RootDirectory + "\\Assets\\";


	TestClass instance;

	private static readonly HashSet<string> ImageFileExtensions = new HashSet<string>
	{
		".psd", ".tga", ".tif", ".pkm", ".mks", ".png", ".jpg", ".exr"
	};

	private static readonly HashSet<string> ShaderLayerSuffix = new HashSet<string>
	{
		"_color", "_normal", "_rough", "_ao", "_trans", "_refl", "_gloss", "_metal"
	};

	public class TestClass
	{
		[ResourceType( "shader" )]
		public string Shader { get; set; } = "shaders/generic.shader";

		public bool Color = true;
		public bool Normal = false;
		public bool Roughness = false;
		public bool AmbientOcclusion = false;

		[Category( "Advanced" )] public bool Translucent = false;

		public string OutputFilePrefix = "";
		public string OutputFileSuffix = "";
		[Display( Name = "Output Folder" )]
		public OutputLocation Output = OutputLocation.SameFolder;
	}

	public enum OutputLocation
	{
		[Display( Name = "Same folder" )]
		SameFolder,
		[Display( Name = "One folder up" )]
		ParentFolder
	}



	public ImagetoMaterial()
	{

		WindowTitle = "Image to Material";
		SetWindowIcon( "transform" );
		Size = new Vector2( 800,650 );

		Layout = Layout.Column();

		///
		/// Create Directories
		/// 
		/*Directory.CreateDirectory( GenerationPath );
		Directory.CreateDirectory( GenerationPathInput );
		Directory.CreateDirectory( GenerationPathOutput );*/

		///
		/// Header
		///
		var Header = Layout.Column();
		Header.Spacing = 0;
		Header.Margin = 20;
		var Title = new Editor.Label.Subtitle( "Image to Material" );
		Header.Add( Title, 0 );
		var SubTitle = new Editor.Label.Body( "Tool for converting images to materials in bulk." );
		Header.Add( SubTitle, 0 );

		///
		/// Body
		///
		var Body = Layout.Column();
		Body.Margin = 20;
		var BodySections = Layout.Row();

		Body.Add(BodySections, 0 );

		var BodySectionsLeft = Layout.Column();
		var BodySectionsCenter = Layout.Column();

		BodySectionsLeft.Add( new Editor.Label.Subtitle( "Source" ) );
		
		
		BodySectionsLeft.Alignment = Sandbox.TextFlag.CenterHorizontally;

		var BodySectionsLeftCanvas = new Widget( null );
		BodySectionsLeftCanvas.Layout = Layout.Row();
		BodySectionsLeftCanvas.Layout.Spacing = 32;

		var BodySectionsLeftView = new TreeView( BodySectionsLeftCanvas );
		BodySectionsLeftView.HorizontalSizeMode = SizeMode.CanGrow;
		BodySectionsLeftView.SmoothScrolling = true;
		BodySectionsLeftView.MultiSelect = false;
		string sourceRoot = Project.Current.GetAssetsPath();
		var BodySectionsLeftFolder = BodySectionsLeftView.AddItem( new FilesystemTreeNode( sourceRoot ) );

		BodySectionsLeftView.Open( BodySectionsLeftFolder );

		BodySectionsLeft.Add( BodySectionsLeftView, 0 );
		var SourceWarning = new WarningBox( "Please select source" );
		BodySectionsLeft.Add( SourceWarning );


		BodySectionsCenter.Add( new Editor.Label.Subtitle( "Material Settings" ) );
		BodySectionsCenter.Alignment = Sandbox.TextFlag.CenterHorizontally;

		instance = new TestClass();
		var so = instance.GetSerialized();

		// Default: Generic //////////////////
		instance.Color = true;
		instance.Normal = true;
		instance.Roughness = false;
		instance.AmbientOcclusion = false;
		//instance.Metalness = false;
		instance.Translucent = false;
		//////////////////////////////////////
		so.OnPropertyChanged += _ =>
		{
			// Clear any shader-driven layer defaults the user might still see
		};

		var property = new Editor.ControlSheet();
		property.AddObject( so, x => x.Name is not "Output" and not "OutputFilePrefix" and not "OutputFileSuffix" );

		BodySectionsCenter.Add( property,1 );

		var OutputSection = Layout.Column();
		OutputSection.Margin = new Sandbox.UI.Margin( 0, 16, 0, 0 );
		OutputSection.Add( new Editor.Label.Subtitle( "Output" ) );
		var outputProperty = new Editor.ControlSheet();
		outputProperty.AddObject( so, x => x.Name is "Output" or "OutputFilePrefix" or "OutputFileSuffix" );
		OutputSection.Add( outputProperty, 1 );
		BodySectionsCenter.Add( OutputSection, 1 );

		BodySections.Add( BodySectionsLeft, 1 );
		BodySections.Add( BodySectionsCenter, 1 );

		///
		/// Actions
		///
		var Actions = Layout.Column();
		Actions.Margin = 20;
		var ConvertButton = new Editor.Button.Primary( "Convert", "autorenew" );
		ConvertButton.Enabled = false;
		void UpdateConvertButtonState()
		{
			ConvertButton.Enabled = BodySectionsLeftView.Selection.Count != 0;
		}
		void UpdateSourceError()
		{
			SourceWarning.Visible = BodySectionsLeftView.Selection.Count == 0;
		}

		BodySectionsLeftView.Selection.OnItemAdded += _ => { UpdateConvertButtonState(); UpdateSourceError(); };
		BodySectionsLeftView.Selection.OnItemRemoved += _ => { UpdateConvertButtonState(); UpdateSourceError(); };
		ConvertButton.Clicked += () =>
		{
			ConvertFiles( BodySectionsLeftView.Selection );

			var PopUp = new PopupWindow( "Export Complete", "Materials exported to the folder selected in Material Settings.", "Okay" );
			PopUp.Show();
		};

		Actions.Add( ConvertButton );

		var helpdocs = new WebWidget( null );

		helpdocs.Surface.Url = "https://sbox.game/sturnus";

		var HelpButton = new Editor.Button( "Documentation" );
		HelpButton.Tint = "#8c7ae6";
		HelpButton.Clicked = () =>
		{
			var PopUp = new PopupWindow( "Help", $"This tool allows you to bulk convert images to materials.\n" +
				$"\nPrep: Create folders for each material you want to create, once the folders are created, rename each image to it's appropriate layer ie(_color.png, _normal.png, etc...)\n" +
				$"\nSource: Select the parent folder where all of your material folders live\n" +
				$"\nOutput: In Material Settings, choose if the material file is saved in the same folder as the source images, or one folder up\n" +
				$"", "Okay"
			);
			PopUp.Show();
		};


		///
		/// Footer
		///
		var Footer = Layout.Row();
		Footer.Margin = 5;
		Footer.Add( new Editor.Label.Body( "🔨 with ❤️ by <a href=\"https://sbox.game/u/AustinDotCodes\">Austin</a>" ) );
		Footer.Add( HelpButton );

		Layout.Add( Header, 0 );
		Layout.Add( Body, 0 );
		Layout.Add( Actions, 0 );
		Layout.Add( Footer, 0 );

}
	public HashSet<string> GetValidImageFiles( IEnumerable<object> selection )
	{
		var imageFiles = new HashSet<string>();
		foreach ( var obj in selection )
		{
			if ( obj is FilesystemTreeNode node )
			{
				if ( node.Info is DirectoryInfo directory )
				{
					foreach ( var file in directory.GetFiles( "*", SearchOption.AllDirectories ) )
					{
						var relativePath = file.FullName.Replace( Project.Current.RootDirectory.ToString(), "" ).TrimStart( Path.DirectorySeparatorChar );
						if ( ImageFileExtensions.Contains( file.Extension.ToLower() ) )
						{
							imageFiles.Add( relativePath );
						}
					}
				}
				else if ( node.Info is FileInfo file )
				{
					var relativePath = file.FullName.Replace( Project.Current.RootDirectory.ToString(), "" ).TrimStart( Path.DirectorySeparatorChar );
					if ( ImageFileExtensions.Contains( file.Extension.ToLower() ) )
					{
						imageFiles.Add( relativePath );
					}
				}
			}
		}
		return imageFiles;
	}

	public void ConvertFiles( IEnumerable<object> source )
	{

		if ( instance == null )
		{
			Log.Error( "Instance is null. Ensure it is initialized before using." );
			return;
		}

		if ( source == null || !source.Any() )
		{
			Log.Warning( "Source selection is empty or null." );
			return;
		}

		var selectedNode = source.OfType<FilesystemTreeNode>().FirstOrDefault();
		if ( selectedNode == null || selectedNode.Info == null )
		{
			Log.Warning( "Source selection is not a valid directory." );
			return;
		}

		// Allow selecting a single image file - use its containing folder
		DirectoryInfo selectedDir;
		if ( selectedNode.Info is DirectoryInfo dirInfo )
		{
			selectedDir = dirInfo;
		}
		else if ( selectedNode.Info is FileInfo fileInfo && fileInfo.Directory != null )
		{
			selectedDir = fileInfo.Directory;
		}
		else
		{
			Log.Warning( "Source selection is not a valid directory." );
			return;
		}

		string assetsRoot = Project.Current.GetAssetsPath()?.TrimEnd( '\\' ) ?? Path.Combine( Project.Current.GetRootPath(), "Assets" );
		var materialFolders = GetMaterialFolders( selectedDir );

		foreach ( var folder in materialFolders )
		{
			var imageFiles = folder.GetFiles()
				.Where( f => ImageFileExtensions.Contains( f.Extension.ToLower() ) )
				.ToList();

			if ( !imageFiles.Any() )
			{
				Log.Warning( $"No valid image files found in {folder.FullName}" );
				continue;
			}

			string relativeFolderPath = folder.FullName.Replace( assetsRoot + "\\", "" ).Replace( '\\', '/' );
			string materialName = folder.Name;
			string fileExtension = imageFiles[0].Extension;

			foreach ( var imageFile in imageFiles )
			{
				string relativeTexturePath = imageFile.FullName.Replace( assetsRoot + "\\", "" ).Replace( '\\', '/' );
				if ( !File.Exists( imageFile.FullName ) || Texture.Load( relativeTexturePath ) == null )
				{
					Log.Warning( $"Failed to load texture: {imageFile.FullName}" );
					continue;
				}
			}

			string outputPath = instance.Output == OutputLocation.ParentFolder
				? folder.Parent?.FullName ?? folder.FullName
				: folder.FullName;

			SaveMaterial( materialName, relativeFolderPath, folder.FullName, fileExtension, outputPath );
		}
	}

	IEnumerable<DirectoryInfo> GetMaterialFolders( DirectoryInfo directory )
	{
		foreach ( var subDirectory in directory.GetDirectories() )
		{
			foreach ( var folder in GetMaterialFolders( subDirectory ) )
			{
				yield return folder;
			}
		}

		if ( directory.GetFiles().Any( f => ImageFileExtensions.Contains( f.Extension.ToLower() ) ) )
		{
			yield return directory;
		}
	}

	public void SaveMaterial( string materialName, string relativeFolderPath, string folderPath, string destinationExtension, string destinationPath )
	{
		string TextureRef( string suffix )
		{
			string fullFile = Path.Combine( folderPath, suffix + destinationExtension );
			return File.Exists( fullFile ) ? $"{relativeFolderPath}/{suffix}{destinationExtension}" : null;
		}

		string materialContent = $@"
		Layer0
		{{
			""shader"" ""{instance.Shader}""
			{(instance.Color ? (TextureRef( "_color" ) != null ? $"\t\"TextureColor\" \"{TextureRef( "_color" )}\"" : $"\t\"TextureColor\" \"materials/default/default_color.tga\"") : "")}
			{(instance.Normal ? (TextureRef( "_normal" ) != null ? $"\t\"TextureNormal\" \"{TextureRef( "_normal" )}\"" : $"\t\"TextureNormal\" \"materials/default/default_normal.tga\"") : "")}
			{(instance.Roughness ? (TextureRef( "_rough" ) != null ? $"\t\"TextureRoughness\" \"{TextureRef( "_rough" )}\"" : $"\t\"TextureRoughness\" \"materials/default/default_rough.tga\"") : "")}
			{(instance.AmbientOcclusion ? (TextureRef( "_ao" ) != null ? $"\t\"TextureAmbientOcclusion\" \"{TextureRef( "_ao" )}\"" : $"\t\"TextureAmbientOcclusion\" \"materials/default/default_ao.tga\"") : "")}

			{(instance.Translucent ? $"//---- Translucent ----" : "")}
			{(instance.Translucent ? $"F_TRANSLUCENT 1" : "")}
			{(instance.Translucent ? $"g_flOpacityScale \"1.000\"" : "")}
			{(instance.Translucent ? (TextureRef( "_trans" ) != null ? $"\t\"TextureTranslucency\" \"{TextureRef( "_trans" )}\"" : $"\t\"TextureTranslucency\" \"materials/default/default_trans.tga\"") : "")}
		}}
		";
		Directory.CreateDirectory( destinationPath );
		string savePath = Path.Combine( destinationPath, instance.OutputFilePrefix+materialName + instance.OutputFileSuffix+".vmat" );
		File.WriteAllText( savePath, materialContent );
	}




	class FilesystemTreeNode : TreeNode
	{
		public System.IO.FileSystemInfo Info;

		bool IsFolder => Info is System.IO.DirectoryInfo;

		public FilesystemTreeNode( string path )
		{
			if ( System.IO.Directory.Exists( path ) ) Info = new System.IO.DirectoryInfo( path );
			else if ( System.IO.File.Exists( path ) ) Info = new System.IO.FileInfo( path );
			else throw new Exception( "Invalid path" );
		}

		public override void OnPaint( VirtualWidget item )
		{
			PaintSelection( item );

			Paint.SetPen( IsFolder ? Theme.Yellow : Theme.Text );
			Paint.DrawIcon( item.Rect, IsFolder ? "folder" : "description", 18, TextFlag.LeftCenter );

			Paint.SetPen( Theme.Text );
			Paint.DrawText( item.Rect.Shrink( 24, 0, 0, 0 ), $"{Info.Name}", TextFlag.LeftCenter );
		}

		public int Order => Info is System.IO.DirectoryInfo ? 0 : 1;

		protected override void BuildChildren()
		{
			if ( Info is not System.IO.DirectoryInfo dirInfo )
				return;

			Clear();

			var infos = dirInfo.GetFileSystemInfos().Select( x => new FilesystemTreeNode( x.FullName ) );
			infos = infos.OrderBy( x => x.Order ).ThenBy( x => x.Info.Name );

			AddItems( infos );
		}
	}
}