Editor helper for the project's asset browser context menus. Builds right-click menus for files, folders and multi-selections, provides create/duplicate/delete/move/paste functionality, registers new assets, creates derived assets (materials, textures, sprites, sound events, models), and rewrites references when moving assets.
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace GeneralGame.Editor;
/// <summary>
/// Helper for building asset context menus with Create Material/Texture options.
/// </summary>
public static class AssetContextMenuHelper
{
// Builds the right-click menu for a single file/asset. Shared by the tree view and the icon grid
// so both show identical options. Rename UI differs per view, so it is passed in via onRename.
public static void BuildFileMenu(Menu menu, string fullPath, Asset asset, Action onRename, Action onChanged)
{
var fileName = Path.GetFileName(fullPath);
if (asset != null)
menu.AddOption("Open in Editor", "edit", () => asset.OpenInEditor());
else
menu.AddOption("Open", "open_in_new", () => EditorUtility.OpenFolder(fullPath));
menu.AddOption("Show in Explorer", "folder_open", () => EditorUtility.OpenFileFolder(fullPath));
menu.AddSeparator();
if (asset != null)
menu.AddOption("Copy Relative Path", "content_paste_go", () => EditorUtility.Clipboard.Copy(asset.RelativePath));
menu.AddOption("Copy Absolute Path", "content_paste", () => EditorUtility.Clipboard.Copy(fullPath));
// Asset-type specific options (Create Material, Create Texture, etc.)
AddAssetTypeOptions(menu, asset);
menu.AddSeparator();
menu.AddOption("Rename", "edit", () => onRename?.Invoke());
menu.AddOption("Duplicate", "file_copy", () =>
{
DuplicateFileWithRename(fullPath, () => onChanged?.Invoke());
});
menu.AddSeparator();
var parentFolder = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(parentFolder))
{
var createMenu = menu.AddMenu("Create", "add");
AssetCreator.AddOptions(createMenu, parentFolder);
menu.AddSeparator();
}
menu.AddOption("Delete", "delete", () =>
{
var confirm = new PopupWindow(
"Delete File",
$"Are you sure you want to delete '{fileName}'?",
"Cancel",
new Dictionary<string, Action>()
{
{ "Delete", () =>
{
try
{
DeleteFileWithCompiled(fullPath, asset);
onChanged?.Invoke();
}
catch (Exception ex)
{
Log.Error($"Failed to delete file: {ex.Message}");
}
}
}
}
);
confirm.Show();
});
}
// Builds the right-click menu for a folder. Shared by the tree view and the icon grid.
public static void BuildFolderMenu(Menu menu, string fullPath, string displayName, bool isRoot,
Action onOpen, Action onRename, Action onRefresh, Action onDeleted)
{
if (onOpen != null)
menu.AddOption("Open", "folder_open", () => onOpen());
menu.AddOption("Open in Explorer", "launch", () => EditorUtility.OpenFolder(fullPath));
menu.AddSeparator();
var createMenu = menu.AddMenu("Create", "add");
AssetCreator.AddOptions(createMenu, fullPath);
// Paste files copied from Windows Explorer into this folder
if (WindowsClipboard.HasFiles())
{
menu.AddOption("Paste", "content_paste", () =>
{
PasteFromClipboard(fullPath);
onRefresh?.Invoke();
});
}
menu.AddSeparator();
if (!isRoot)
menu.AddOption("Rename", "edit", () => onRename?.Invoke());
menu.AddOption("Copy Path", "content_copy", () => EditorUtility.Clipboard.Copy(fullPath));
menu.AddOption("Copy Relative Path", "content_copy", () =>
{
var relativePath = Path.GetRelativePath(Project.Current?.GetRootPath() ?? "", fullPath);
EditorUtility.Clipboard.Copy(relativePath);
});
menu.AddSeparator();
menu.AddOption("Refresh", "refresh", () => onRefresh?.Invoke());
if (!isRoot)
{
menu.AddSeparator();
menu.AddOption("Delete", "delete", () =>
{
var confirm = new PopupWindow(
"Delete Folder",
$"Are you sure you want to delete '{displayName}'?\nAll contents will be deleted.",
"Cancel",
new Dictionary<string, Action>()
{
{ "Delete", () =>
{
try
{
Directory.Delete(fullPath, recursive: true);
onDeleted?.Invoke();
}
catch (Exception ex)
{
Log.Error($"Failed to delete folder: {ex.Message}");
}
}
}
}
);
confirm.Show();
});
}
}
// Duplicates a file next to itself, finding a free "_copy" name.
public static string DuplicateFile(string filePath)
{
try
{
var directory = Path.GetDirectoryName(filePath);
var nameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
var extension = Path.GetExtension(filePath);
var newName = $"{nameWithoutExt}_copy{extension}";
var newPath = Path.Combine(directory, newName);
var counter = 1;
while (File.Exists(newPath))
{
newName = $"{nameWithoutExt}_copy{counter++}{extension}";
newPath = Path.Combine(directory, newName);
}
File.Copy(filePath, newPath);
// Register the new file so it gets compiled and recognised as an asset immediately
// (without this it shows up uncompiled until an editor restart or rename).
AssetSystem.RegisterFile(newPath);
return newPath;
}
catch (Exception ex)
{
Log.Error($"Failed to duplicate file: {ex.Message}");
return null;
}
}
// Duplicates a file and immediately opens a rename modal pre-filled with the copy's name.
public static void DuplicateFileWithRename(string filePath, Action onComplete = null)
{
var newPath = DuplicateFile(filePath);
if (string.IsNullOrEmpty(newPath))
return;
onComplete?.Invoke();
var extension = Path.GetExtension(newPath);
var dialog = new RenameDialog("Rename Duplicate", Path.GetFileNameWithoutExtension(newPath));
dialog.OnConfirm = (newName) =>
{
if (string.IsNullOrWhiteSpace(newName))
return;
// Preserve extension if the user didn't type one
if (!Path.HasExtension(newName))
newName += extension;
if (newName == Path.GetFileName(newPath))
return;
var renamedPath = Path.Combine(Path.GetDirectoryName(newPath), newName);
if (File.Exists(renamedPath))
{
Log.Warning($"A file named '{newName}' already exists");
return;
}
try
{
File.Move(newPath, renamedPath);
// Drop the freshly compiled _c file so it doesn't linger under the old name
var oldCompiled = newPath + "_c";
if (File.Exists(oldCompiled))
File.Delete(oldCompiled);
AssetSystem.RegisterFile(renamedPath);
onComplete?.Invoke();
}
catch (Exception ex)
{
Log.Error($"Failed to rename duplicate: {ex.Message}");
}
};
dialog.Show();
}
// Registers a freshly created/copied/moved path with the asset system so it compiles right away.
// Accepts a single file or a directory (registers every file inside, recursively).
public static void RegisterNewPath(string path)
{
try
{
if (Directory.Exists(path))
{
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
{
if (ShouldRegister(file))
AssetSystem.RegisterFile(file);
}
}
else if (File.Exists(path) && ShouldRegister(path))
{
AssetSystem.RegisterFile(path);
}
}
catch (Exception ex)
{
Log.Warning($"Failed to register '{path}': {ex.Message}");
}
}
// Copies the files currently on the Windows clipboard into the target folder (always copy, never move).
public static void PasteFromClipboard(string targetFolder)
{
if (string.IsNullOrEmpty(targetFolder) || !Directory.Exists(targetFolder))
return;
foreach (var file in WindowsClipboard.GetFiles())
{
if (string.IsNullOrEmpty(file))
continue;
try
{
var name = Path.GetFileName(file.TrimEnd('\\', '/'));
var destPath = Path.Combine(targetFolder, name);
// Don't paste a folder into itself
if (Path.GetFullPath(file).Equals(Path.GetFullPath(destPath), StringComparison.OrdinalIgnoreCase))
destPath = MakeUniquePath(destPath);
else if (File.Exists(destPath) || Directory.Exists(destPath))
destPath = MakeUniquePath(destPath);
if (Directory.Exists(file))
CopyDirectory(file, destPath);
else if (File.Exists(file))
File.Copy(file, destPath, overwrite: false);
else
continue;
RegisterNewPath(destPath);
}
catch (Exception ex)
{
Log.Error($"Failed to paste '{file}': {ex.Message}");
}
}
}
private static string MakeUniquePath(string path)
{
if (!File.Exists(path) && !Directory.Exists(path))
return path;
var directory = Path.GetDirectoryName(path);
var name = Path.GetFileNameWithoutExtension(path);
var ext = Path.GetExtension(path);
int counter = 1;
string candidate;
do
{
var suffix = counter == 1 ? "_copy" : $"_copy{counter}";
candidate = Path.Combine(directory, $"{name}{suffix}{ext}");
counter++;
}
while (File.Exists(candidate) || Directory.Exists(candidate));
return candidate;
}
private static void CopyDirectory(string sourceDir, string destDir)
{
Directory.CreateDirectory(destDir);
foreach (var file in Directory.GetFiles(sourceDir))
{
File.Copy(file, Path.Combine(destDir, Path.GetFileName(file)));
}
foreach (var dir in Directory.GetDirectories(sourceDir))
{
CopyDirectory(dir, Path.Combine(destDir, Path.GetFileName(dir)));
}
}
// True when the path lives outside the current project (e.g. dragged in from Windows Explorer).
// Such sources must be copied, never moved, so we don't delete files from their original location.
public static bool IsExternalSource(string path)
{
try
{
var root = Project.Current?.GetRootPath();
if (string.IsNullOrEmpty(root))
return false;
var full = Path.GetFullPath(path);
var rootFull = Path.GetFullPath(root);
return !full.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
// Create a prefab from GameObject(s) dragged in from the scene hierarchy.
// A single object becomes the prefab root directly; multiple objects are
// wrapped under a new root, mirroring the engine's "Convert to Prefab".
public static void CreatePrefabFromGameObjects(GameObject[] gameObjects, string targetFolder, Action onComplete = null)
{
if (gameObjects == null || string.IsNullOrEmpty(targetFolder)) return;
var selection = gameObjects.Where(g => g != null).ToArray();
var first = selection.FirstOrDefault();
if (first == null) return;
try
{
var session = SceneEditorSession.Resolve(first);
if (session == null) return;
var baseName = string.IsNullOrWhiteSpace(first.Name) ? "Prefab" : first.Name;
var location = GetUniquePrefabPath(targetFolder, baseName);
using var scene = session.Scene.Push();
using (session.UndoScope("Create Prefab from Hierarchy").WithGameObjectChanges(selection, GameObjectUndoFlags.All).WithGameObjectCreations().Push())
{
GameObject prefabRoot;
if (selection.Length == 1)
{
prefabRoot = first;
}
else
{
prefabRoot = new GameObject();
prefabRoot.WorldTransform = first.WorldTransform;
foreach (var go in selection)
go.SetParent(prefabRoot, true);
}
prefabRoot.Name = Path.GetFileNameWithoutExtension(location);
EditorUtility.Prefabs.ConvertGameObjectToPrefab(prefabRoot, location);
EditorUtility.InspectorObject = prefabRoot;
}
}
catch (Exception ex)
{
Log.Error($"Failed to create prefab: {ex.Message}");
}
onComplete?.Invoke();
}
// Build a non-colliding "name.prefab" path inside the target folder.
private static string GetUniquePrefabPath(string folder, string baseName)
{
var path = Path.Combine(folder, $"{baseName}.prefab");
int n = 1;
while (File.Exists(path) || AssetSystem.FindByPath(path) != null)
{
path = Path.Combine(folder, $"{baseName} ({n}).prefab");
n++;
}
return path;
}
// ===== Backward compatibility: move an asset and optionally fix references to its old path =====
private static readonly HashSet<string> NonTextExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".tga", ".psd", ".bmp", ".gif", ".dds", ".hdr",
".fbx", ".obj", ".gltf", ".glb", ".dmx", ".blend",
".wav", ".mp3", ".ogg", ".flac",
".vtex", ".vsnd", ".vmdl", ".dll", ".pdb", ".exe", ".zip", ".bin"
};
// True if this is a single registered asset being moved inside the project - the case the
// backward-compatibility reference check applies to.
public static bool IsBackwardCompatMove(IReadOnlyList<string> files, bool isCopy)
{
if (!BrowserSettings.BackwardCompatibility) return false;
if (isCopy) return false;
if (files == null || files.Count != 1) return false;
var file = files[0];
if (string.IsNullOrEmpty(file) || IsExternalSource(file) || Directory.Exists(file)) return false;
if (!File.Exists(file)) return false;
var asset = AssetSystem.FindByPath(file);
return asset != null && !asset.IsDeleted;
}
// Moves an asset, first asking (via a modal) whether to update references to its old path.
public static void MoveAssetWithReferenceCheck(string sourceFile, string targetFolder, Action onComplete)
{
var asset = AssetSystem.FindByPath(sourceFile);
if (asset == null || asset.IsDeleted)
return;
// Don't bother if it's already in the target folder
if (string.Equals(Path.GetFullPath(Path.GetDirectoryName(sourceFile)), Path.GetFullPath(targetFolder), StringComparison.OrdinalIgnoreCase))
return;
var assetsRoot = Project.Current?.GetAssetsPath();
var oldRef = asset.RelativePath?.Replace('\\', '/');
var newAbs = Path.Combine(targetFolder, Path.GetFileName(sourceFile));
var newRef = string.IsNullOrEmpty(assetsRoot)
? null
: Path.GetRelativePath(assetsRoot, newAbs).Replace('\\', '/');
void JustMove()
{
EditorUtility.MoveAssetToDirectory(asset, targetFolder);
onComplete?.Invoke();
}
// Can't compute a clean reference (asset outside the assets mount) - just move normally
if (string.IsNullOrEmpty(oldRef) || string.IsNullOrEmpty(newRef) || newRef.StartsWith(".."))
{
JustMove();
return;
}
var affected = FindFilesReferencing(oldRef);
affected.RemoveAll(f => string.Equals(Path.GetFullPath(f), Path.GetFullPath(asset.AbsolutePath), StringComparison.OrdinalIgnoreCase));
var dialog = new MoveReferencesDialog(oldRef, newRef, affected,
onJustMove: JustMove,
onMoveAndUpdate: () =>
{
EditorUtility.MoveAssetToDirectory(asset, targetFolder);
RewriteReferences(affected, oldRef, newRef);
onComplete?.Invoke();
});
dialog.Show();
}
// Matches the reference only at a path boundary, so "models/foo.vmdl" doesn't match inside
// "othermodels/foo.vmdl" or "sub/models/foo.vmdl". A trailing "_c" (compiled form) is still matched.
private static Regex BuildReferenceRegex(string reference)
{
return new Regex(@"(?<![\w./\\-])" + Regex.Escape(reference), RegexOptions.IgnoreCase);
}
// Finds every text-based project file that mentions the given reference path.
public static List<string> FindFilesReferencing(string reference)
{
var results = new List<string>();
if (string.IsNullOrEmpty(reference))
return results;
var root = Project.Current?.GetAssetsPath();
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
return results;
var regex = BuildReferenceRegex(reference);
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
{
if (!IsScannableTextFile(file))
continue;
try
{
var info = new FileInfo(file);
if (info.Length == 0 || info.Length > 16_000_000)
continue;
var text = File.ReadAllText(file);
if (regex.IsMatch(text))
results.Add(file);
}
catch
{
// Ignore unreadable files
}
}
return results;
}
// Rewrites the old reference to the new one in each file (covers compiled "_c" forms too, since
// they share the prefix). Re-registers the file afterwards.
public static void RewriteReferences(IEnumerable<string> files, string oldRef, string newRef)
{
var regex = BuildReferenceRegex(oldRef);
foreach (var file in files)
{
try
{
var text = File.ReadAllText(file);
var updated = regex.Replace(text, _ => newRef);
if (updated != text)
{
File.WriteAllText(file, updated);
AssetSystem.RegisterFile(file);
}
}
catch (Exception ex)
{
Log.Error($"Failed to update references in '{file}': {ex.Message}");
}
}
}
private static bool IsScannableTextFile(string file)
{
if (!ShouldRegister(file))
return false;
var ext = Path.GetExtension(file);
if (NonTextExtensions.Contains(ext))
return false;
return true;
}
private static bool ShouldRegister(string file)
{
var name = Path.GetFileName(file);
if (name.StartsWith(".")) return false;
if (name.EndsWith("_c", StringComparison.OrdinalIgnoreCase)) return false;
if (name.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) return false;
if (name.Contains(".generated", StringComparison.OrdinalIgnoreCase)) return false;
return true;
}
// Deletes a file, also removing its compiled "_c" sibling. Uses Asset.Delete when registered.
public static void DeleteFileWithCompiled(string fullPath, Asset asset)
{
if (asset != null)
{
asset.Delete();
return;
}
File.Delete(fullPath);
var compiledPath = fullPath + "_c";
if (File.Exists(compiledPath))
{
File.Delete(compiledPath);
}
}
private static readonly HashSet<string> MeshExtensions =
new(StringComparer.OrdinalIgnoreCase) { ".fbx", ".obj", ".dmx", ".gltf", ".glb" };
// Builds the right-click menu shown when several files are selected at once.
// Shared by the tree view and the icon grid so both behave identically.
public static void BuildMultiFileMenu(Menu menu, List<(string Path, Asset Asset)> items, Action onChanged)
{
if (items == null || items.Count == 0) return;
int count = items.Count;
var assets = items.Where(i => i.Asset != null).Select(i => i.Asset).ToList();
// Asset-type batch options (Create Material (N), Create Texture (N), etc.)
bool addedTypeOptions = AddMultiAssetTypeOptions(menu, assets);
if (addedTypeOptions)
menu.AddSeparator();
menu.AddOption($"Duplicate ({count})", "file_copy", () =>
{
foreach (var it in items)
DuplicateFile(it.Path);
onChanged?.Invoke();
});
menu.AddOption($"Delete ({count})", "delete", () =>
{
var confirm = new PopupWindow(
"Delete Files",
$"Are you sure you want to delete {count} item(s)?",
"Cancel",
new Dictionary<string, Action>()
{
{ "Delete", () =>
{
foreach (var it in items)
{
try
{
DeleteFileWithCompiled(it.Path, it.Asset);
}
catch (Exception ex)
{
Log.Error($"Failed to delete '{it.Path}': {ex.Message}");
}
}
onChanged?.Invoke();
}
}
}
);
confirm.Show();
});
}
// Adds asset-type specific batch options with a count suffix, e.g. "Create Material (3)".
// Each option auto-creates the result next to every source asset (no save dialog).
// Returns true if any option was added.
public static bool AddMultiAssetTypeOptions(Menu menu, List<Asset> assets)
{
if (assets == null || assets.Count == 0) return false;
var images = assets.Where(a => a.AssetType == AssetType.ImageFile).ToList();
var shaders = assets.Where(a => a.AssetType == AssetType.Shader).ToList();
var meshes = assets.Where(a => MeshExtensions.Contains(Path.GetExtension(a.AbsolutePath))).ToList();
bool added = false;
if (images.Count > 0)
{
menu.AddOption($"Create Material ({images.Count})", "image", () =>
{
foreach (var a in images) CreateMaterialFromImageAuto(a);
Log.Info($"Created {images.Count} material(s)");
});
menu.AddOption($"Create Texture ({images.Count})", "texture", () =>
{
foreach (var a in images) CreateTextureFromImageAuto(a);
});
menu.AddOption($"Create Sprite ({images.Count})", "emoji_emotions", () =>
{
foreach (var a in images) CreateSpriteFromImageAuto(a);
});
added = true;
}
if (shaders.Count > 0)
{
if (added) menu.AddSeparator();
menu.AddOption($"Create Material ({shaders.Count})", "image", () =>
{
foreach (var a in shaders) CreateMaterialFromShaderAuto(a);
});
added = true;
}
if (meshes.Count > 0)
{
if (added) menu.AddSeparator();
menu.AddOption($"Create Model ({meshes.Count})", "view_in_ar", () =>
{
foreach (var a in meshes) CreateModelFromMeshAuto(a);
});
added = true;
}
var sounds = assets.Where(a => a.AssetType == AssetType.SoundFile).ToList();
if (sounds.Count > 0)
{
if (added) menu.AddSeparator();
// One sound event per file
menu.AddOption($"Create Sound Event ({sounds.Count})", "graphic_eq", () =>
{
foreach (var a in sounds) CreateSoundEventFromAudioAuto(a);
Log.Info($"Created {sounds.Count} sound event(s)");
});
// A single sound event that randomly picks between all the selected sounds
menu.AddOption($"Create Random Sound Event ({sounds.Count})", "shuffle", () =>
{
CreateRandomSoundEventFromAudios(sounds);
});
added = true;
}
return added;
}
/// <summary>
/// Add asset-type specific options like Create Material, Create Texture, etc.
/// </summary>
public static void AddAssetTypeOptions(Menu menu, Asset asset)
{
if (asset == null) return;
var assetType = asset.AssetType;
if (assetType == null) return;
// Image files - can create Material, Texture, Sprite
if (assetType == AssetType.ImageFile)
{
menu.AddSeparator();
menu.AddOption("Create Material", "image", () => CreateMaterialFromImage(asset));
menu.AddOption("Create Texture", "texture", () => CreateTextureFromImage(asset));
menu.AddOption("Create Sprite", "emoji_emotions", () => CreateSpriteFromImage(asset));
}
// Shader files - can create Material
if (assetType == AssetType.Shader)
{
menu.AddSeparator();
menu.AddOption("Create Material", "image", () => CreateMaterialFromShader(asset));
}
// Mesh files (FBX, OBJ) - can create Model
if (MeshExtensions.Contains(Path.GetExtension(asset.AbsolutePath)))
{
menu.AddSeparator();
menu.AddOption("Create Model", "view_in_ar", () => CreateModelFromMesh(asset));
}
// Sound files - can create a Sound Event
if (assetType == AssetType.SoundFile)
{
menu.AddSeparator();
menu.AddOption("Create Sound Event", "graphic_eq", () => CreateSoundEventFromAudio(asset));
}
}
private static void CreateTextureFromImage(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Texture from Image..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".vtex";
fd.SelectFile($"{asset.Name}.vtex");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Texture File (*.vtex)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildVtexContent(asset));
AssetSystem.RegisterFile(fd.SelectedFile);
}
private static void CreateMaterialFromImage(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Material from Image..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".vmat";
fd.SelectFile($"{GetMaterialBaseName(asset)}.vmat");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Material File (*.vmat)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildImageMaterialContent(asset));
AssetSystem.RegisterFile(fd.SelectedFile);
}
private static async void CreateSpriteFromImage(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Sprite from Image..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".sprite";
fd.SelectFile($"{asset.Name}.sprite");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Sprite File (*.sprite)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildSpriteContent(asset));
var resultAsset = AssetSystem.RegisterFile(fd.SelectedFile);
while (!resultAsset.IsCompiledAndUpToDate)
{
await Task.Delay(10);
}
}
private static void CreateMaterialFromShader(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Material from Shader..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".vmat";
fd.SelectFile($"{asset.Name}.vmat");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Material File (*.vmat)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildShaderMaterialContent(asset));
AssetSystem.RegisterFile(fd.SelectedFile);
}
private static void CreateModelFromMesh(Asset asset)
{
var targetPath = EditorUtility.SaveFileDialog("Create Model..", "vmdl", Path.ChangeExtension(asset.AbsolutePath, "vmdl"));
if (targetPath == null)
return;
EditorUtility.CreateModelFromMeshFile(asset, targetPath);
}
private static void CreateSoundEventFromAudio(Asset asset)
{
var fd = new FileDialog(null);
fd.Title = "Create Sound Event..";
fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
fd.DefaultSuffix = ".sound";
fd.SelectFile($"{asset.Name}.sound");
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter("Sound Event (*.sound)");
if (!fd.Execute())
return;
File.WriteAllText(fd.SelectedFile, BuildSoundEventContent(new[] { GetVsndReference(asset) }));
AssetSystem.RegisterFile(fd.SelectedFile);
}
// ===== Batch creators: auto-name next to each source asset, skip if it already exists =====
private static void CreateMaterialFromImageAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{GetMaterialBaseName(asset)}.vmat");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildImageMaterialContent(asset));
AssetSystem.RegisterFile(destPath);
}
private static void CreateTextureFromImageAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{asset.Name}.vtex");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildVtexContent(asset));
AssetSystem.RegisterFile(destPath);
}
private static async void CreateSpriteFromImageAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{asset.Name}.sprite");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildSpriteContent(asset));
var resultAsset = AssetSystem.RegisterFile(destPath);
while (!resultAsset.IsCompiledAndUpToDate)
{
await Task.Delay(10);
}
}
private static void CreateMaterialFromShaderAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{asset.Name}.vmat");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildShaderMaterialContent(asset));
AssetSystem.RegisterFile(destPath);
}
private static void CreateModelFromMeshAuto(Asset asset)
{
var destPath = Path.ChangeExtension(asset.AbsolutePath, "vmdl");
if (File.Exists(destPath))
return;
EditorUtility.CreateModelFromMeshFile(asset, destPath);
}
private static void CreateSoundEventFromAudioAuto(Asset asset)
{
var directory = Path.GetDirectoryName(asset.AbsolutePath);
var destPath = Path.Combine(directory, $"{asset.Name}.sound");
if (File.Exists(destPath))
return;
File.WriteAllText(destPath, BuildSoundEventContent(new[] { GetVsndReference(asset) }));
AssetSystem.RegisterFile(destPath);
}
// Creates a single sound event that randomly picks between all the given audio files.
private static void CreateRandomSoundEventFromAudios(List<Asset> assets)
{
if (assets == null || assets.Count == 0)
return;
var first = assets[0];
var directory = Path.GetDirectoryName(first.AbsolutePath);
var destPath = FindFreePath(directory, GetSoundBaseName(first), ".sound");
var references = assets.Select(GetVsndReference).ToList();
File.WriteAllText(destPath, BuildSoundEventContent(references));
AssetSystem.RegisterFile(destPath);
}
// ===== Content builders shared by single and batch creators =====
// Strips trailing texture-role suffixes (_color, _normal, ...) to get the material base name.
private static string GetMaterialBaseName(Asset asset)
{
string[] suffixes = { "color", "ao", "normal", "metallic", "rough", "diff", "diffuse", "nrm", "spec", "selfillum", "mask" };
var assetName = asset.Name;
foreach (var t in suffixes)
{
if (assetName.EndsWith($"_{t}"))
assetName = assetName.Substring(0, assetName.Length - (t.Length + 1));
}
return assetName;
}
// Builds a complex.shader material, auto-wiring sibling textures (normal/ao/rough/...) by name.
private static string BuildImageMaterialContent(Asset asset)
{
var assetName = GetMaterialBaseName(asset);
var assetPath = Path.GetDirectoryName(asset.AbsolutePath).NormalizeFilename(false);
var assetPeers = AssetSystem.All
.Where(x => x.AssetType == AssetType.ImageFile)
.Where(x => x.AbsolutePath.StartsWith(assetPath))
.ToArray();
var assetPeersWithSameBaseName = assetPeers
.Where(x => x.Name == assetName || x.Name.StartsWith(assetName + "_"))
.ToArray();
if (assetPeersWithSameBaseName.Length > 0)
{
assetPeers = assetPeersWithSameBaseName;
}
string texColor = assetPeers.Where(x => x.Name.Contains("_color") || x.Name.Contains("_diff")).Select(x => x.RelativePath).FirstOrDefault();
texColor ??= asset.RelativePath;
string texNormal = assetPeers.Where(x => x.Name.Contains("_nrm") || x.Name.Contains("_normal") || x.Name.Contains("_amb")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_normal.tga";
string texAo = assetPeers.Where(x => x.Name.Contains("_ao") || x.Name.Contains("_occ") || x.Name.Contains("_amb")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_ao.tga";
string texRough = assetPeers.Where(x => x.Name.Contains("_rough")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_rough.tga";
string texMetallic = assetPeers.Where(x => x.Name.Contains("_metallic")).Select(x => x.RelativePath).FirstOrDefault();
if (texMetallic != null)
{
texMetallic = $"\n\tF_METALNESS_TEXTURE 1\n\tF_SPECULAR 1\n\tTextureMetalness \"{texMetallic}\"";
}
string texSelfIllum = assetPeers.Where(x => x.Name.Contains("_selfillum")).Select(x => x.RelativePath).FirstOrDefault();
if (texSelfIllum != null)
{
texSelfIllum = $"\n\tF_SELF_ILLUM 1\n\tTextureSelfIllumMask \"{texSelfIllum}\"";
}
string tintMask = assetPeers.Where(x => x.Name.Contains("_mask")).Select(x => x.RelativePath).FirstOrDefault();
if (tintMask != null)
{
tintMask = $"\n\tF_TINT_MASK 1\n\tTextureTintMask \"{tintMask}\"";
}
return $@"
Layer0
{{
shader ""shaders/complex.shader_c""
TextureColor ""{texColor}""
TextureAmbientOcclusion ""{texAo}""
TextureNormal ""{texNormal}""
TextureRoughness ""{texRough}""{texMetallic}{texSelfIllum}{tintMask}
}}
";
}
private static string BuildShaderMaterialContent(Asset asset)
{
var shaderPath = asset.GetCompiledFile();
return $@"
Layer0
{{
shader ""{shaderPath}""
}}
";
}
private static string BuildVtexContent(Asset asset)
{
var vtexContent = new Dictionary<string, object>
{
{ "Sequences", new object[]
{
new Dictionary<string, object>
{
{ "Source", asset.RelativePath },
{ "IsLooping", true }
}
}
}
};
return Json.Serialize(vtexContent);
}
private static string BuildSpriteContent(Asset asset)
{
var path = Path.ChangeExtension(asset.Path, Path.GetExtension(asset.AbsolutePath));
var sprite = Sprite.FromTexture(Texture.Load(path));
return sprite.Serialize().ToJsonString();
}
// Builds a .sound (SoundEvent) resource that plays a random sound from the given references.
private static string BuildSoundEventContent(IEnumerable<string> vsndReferences)
{
var content = new Dictionary<string, object>
{
{ "Volume", "1" },
{ "Pitch", "1" },
{ "SelectionMode", "Random" },
{ "Sounds", vsndReferences.ToArray() },
{ "__version", 1 }
};
return Json.Serialize(content);
}
// Sound events reference the compiled .vsnd, regardless of the source file extension.
private static string GetVsndReference(Asset asset)
{
return Path.ChangeExtension(asset.RelativePath, "vsnd").Replace('\\', '/');
}
// Strips a trailing numeric index so "footstep_01" -> "footstep" for naming a combined event.
private static string GetSoundBaseName(Asset asset)
{
var name = asset.Name;
var underscore = name.LastIndexOf('_');
if (underscore > 0 && int.TryParse(name.Substring(underscore + 1), out _))
name = name.Substring(0, underscore);
return name;
}
private static string FindFreePath(string directory, string baseName, string extension)
{
var path = Path.Combine(directory, $"{baseName}{extension}");
int counter = 1;
while (File.Exists(path))
path = Path.Combine(directory, $"{baseName}_{counter++}{extension}");
return path;
}
}