// MIT License - Copyright (c) 2024 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
namespace WallstopStudios.UnityHelpers.Editor.Sprites
{
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
using CustomEditors;
using Utils;
using WallstopStudios.UnityHelpers.Core.Extension;
using WallstopStudios.UnityHelpers.Utils;
using Object = UnityEngine.Object;
///
/// Batch-applies configurable sprite importer settings to selected sprites and/or recursively
/// through selected directories using prioritized profiles with multiple match modes.
///
///
///
/// Problems this solves: keeping large sets of sprites consistent (PPU, pivot, mode, filter,
/// wrap, compression, etc.) without manual per-asset editing.
///
///
/// How it works: define one or more SpriteSettings profiles with match mode
/// (Any/NameContains/PathContains/Regex/Extension) and an optional priority. Calculate stats to
/// preview which assets will be affected, then apply settings in one pass.
///
///
/// Usage: add sprites and/or directories; configure profiles; click "Calculate Stats" to see
/// impact and preview up to 200 paths; then "Apply Settings" to write importer changes.
///
///
/// Caveats: importer changes trigger reimports; ensure regex patterns are correct; for very
/// large trees prefer running in batches.
///
///
public sealed class SpriteSettingsApplierWindow : EditorWindow
{
public List sprites = new();
public List spriteFileExtensions = new() { ".png" };
public List spriteSettings = new() { new SpriteSettings() };
public List directories = new();
private SerializedObject _serializedObject;
private SerializedProperty _spritesProp;
private SerializedProperty _spriteFileExtensionsProp;
private SerializedProperty _spriteSettingsProp;
private SerializedProperty _directoriesProp;
private Vector2 _scrollPosition;
private int _totalSpritesToProcess = -1;
private int _spritesThatWillChange = -1;
private bool _showPreviewOfChanges;
private readonly List _assetsThatWillChange = new();
private bool _applyCanceled;
private readonly TextureImporterSettings _settingsBuffer = new();
private readonly List<(string fullFilePath, string relativePath)> _targetSpriteBuffer =
new();
[MenuItem("Tools/Wallstop Studios/Unity Helpers/Sprite Settings Applier", priority = -2)]
public static void ShowWindow()
{
SpriteSettingsApplierWindow window = GetWindow(
"Sprite Settings Applier"
);
window.minSize = new Vector2(400, 300);
window.Show();
}
private void OnEnable()
{
_serializedObject = new SerializedObject(this);
_spritesProp = _serializedObject.FindProperty(nameof(sprites));
_spriteFileExtensionsProp = _serializedObject.FindProperty(
nameof(spriteFileExtensions)
);
_spriteSettingsProp = _serializedObject.FindProperty(nameof(spriteSettings));
_directoriesProp = _serializedObject.FindProperty(nameof(directories));
}
private void OnGUI()
{
_serializedObject.Update();
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition);
EditorGUILayout.LabelField("Sprite Sources", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_spritesProp, new GUIContent("Specific Sprites"), true);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Directory Sources", EditorStyles.boldLabel);
PersistentDirectoryGUI.PathSelectorObjectArray(
_directoriesProp,
nameof(SpriteSettingsApplierWindow)
);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Settings", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(
_spriteFileExtensionsProp,
new GUIContent("Sprite File Extensions"),
true
);
EditorGUILayout.PropertyField(
_spriteSettingsProp,
new GUIContent("Sprite Settings Profiles"),
true
);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Actions", EditorStyles.boldLabel);
if (GUILayout.Button("Calculate Stats"))
{
CalculateStats();
}
if (_totalSpritesToProcess >= 0 && _spritesThatWillChange >= 0)
{
EditorGUILayout.LabelField($"Sprites to process: {_totalSpritesToProcess}");
EditorGUILayout.LabelField($"Sprites that will change: {_spritesThatWillChange}");
_showPreviewOfChanges = EditorGUILayout.Foldout(
_showPreviewOfChanges,
$"Preview ({_assetsThatWillChange.Count})"
);
if (_showPreviewOfChanges)
{
int toShow = Mathf.Min(_assetsThatWillChange.Count, 200);
for (int i = 0; i < toShow; i++)
{
EditorGUILayout.LabelField(_assetsThatWillChange[i]);
}
if (_assetsThatWillChange.Count > 200)
{
EditorGUILayout.LabelField(
$"...and {_assetsThatWillChange.Count - 200} more"
);
}
if (GUILayout.Button("Copy List"))
{
EditorGUIUtility.systemCopyBuffer = string.Join(
"\n",
_assetsThatWillChange
);
}
}
}
else
{
EditorGUILayout.LabelField("Press 'Calculate Stats' to see processing details.");
}
EditorGUILayout.Space();
if (GUILayout.Button("Apply Settings to Sprites"))
{
ApplySettings();
}
EditorGUILayout.Space();
EditorGUILayout.LabelField("Profiles", EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Save Profiles Asset"))
{
SaveProfilesAsset();
}
if (GUILayout.Button("Load Profiles Asset"))
{
LoadProfilesAsset();
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndScrollView();
_serializedObject.ApplyModifiedProperties();
}
private List<(string fullFilePath, string relativePath)> GetTargetSpritePaths()
{
List<(string fullFilePath, string relativePath)> filePaths = _targetSpriteBuffer;
filePaths.Clear();
HashSet uniqueRelativePaths = new(StringComparer.OrdinalIgnoreCase);
using PooledResource> folderAssetPathsLease = Buffers.List.Get(
out List folderAssetPaths
);
// Collect folder asset paths from user selection
for (int i = 0; i < _directoriesProp.arraySize; i++)
{
Object dir = _directoriesProp.GetArrayElementAtIndex(i).objectReferenceValue;
if (dir == null)
{
continue;
}
string assetPath = AssetDatabase.GetAssetPath(dir);
if (string.IsNullOrWhiteSpace(assetPath))
{
continue;
}
if (AssetDatabase.IsValidFolder(assetPath))
{
folderAssetPaths.Add(assetPath);
}
else
{
this.LogWarn($"Item '{assetPath}' is not a valid directory. Skipping.");
}
}
// Build allowed extension set
HashSet allowedExtensions = new(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < _spriteFileExtensionsProp.arraySize; i++)
{
string ext = _spriteFileExtensionsProp.GetArrayElementAtIndex(i).stringValue;
if (string.IsNullOrWhiteSpace(ext))
{
continue;
}
if (!ext.StartsWith("."))
{
ext = "." + ext;
}
allowedExtensions.Add(ext);
}
// Search in folders via AssetDatabase
if (folderAssetPaths.Count > 0)
{
string[] guids = AssetDatabase.FindAssets(
"t:Texture2D",
folderAssetPaths.ToArray()
);
foreach (string guid in guids)
{
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
if (string.IsNullOrWhiteSpace(assetPath))
{
continue;
}
string ext = Path.GetExtension(assetPath);
if (allowedExtensions.Count > 0 && !allowedExtensions.Contains(ext))
{
continue;
}
if (uniqueRelativePaths.Add(assetPath))
{
filePaths.Add((string.Empty, assetPath));
}
}
}
// Add explicitly selected sprites
for (int i = 0; i < _spritesProp.arraySize; i++)
{
Sprite sprite =
_spritesProp.GetArrayElementAtIndex(i).objectReferenceValue as Sprite;
if (sprite == null)
{
continue;
}
string assetPath = AssetDatabase.GetAssetPath(sprite);
if (string.IsNullOrWhiteSpace(assetPath))
{
continue;
}
string ext = Path.GetExtension(assetPath);
if (allowedExtensions.Count > 0 && !allowedExtensions.Contains(ext))
{
continue;
}
if (uniqueRelativePaths.Add(assetPath))
{
filePaths.Add((string.Empty, assetPath));
}
}
return filePaths;
}
internal void CalculateStats()
{
_totalSpritesToProcess = 0;
_spritesThatWillChange = 0;
List<(string fullFilePath, string relativePath)> targetFiles = GetTargetSpritePaths();
_totalSpritesToProcess = targetFiles.Count;
List currentSettings;
if (_serializedObject.targetObject is SpriteSettingsApplierWindow windowInstance)
{
currentSettings = windowInstance.spriteSettings;
}
else
{
this.LogError(
$"Cannot access spriteSettings list from target object. Aborting stats."
);
return;
}
_assetsThatWillChange.Clear();
if (_assetsThatWillChange.Capacity < targetFiles.Count)
{
_assetsThatWillChange.Capacity = targetFiles.Count;
}
// Prepare matchers once using public API
List prepared =
SpriteSettingsApplierAPI.PrepareProfiles(currentSettings);
double lastUpdateTime = EditorApplication.timeSinceStartup;
for (int i = 0; i < targetFiles.Count; i++)
{
(string _, string relativePath) = targetFiles[i];
// Throttle progress bar updates to reduce overhead
double now = EditorApplication.timeSinceStartup;
if (
i == 0
|| i == targetFiles.Count - 1
|| i % 50 == 0
|| now - lastUpdateTime > 0.2
)
{
Utils.EditorUi.ShowProgress(
"Calculating Stats",
$"Checking '{Path.GetFileName(relativePath)}' ({i + 1}/{_totalSpritesToProcess})",
(float)(i + 1) / _totalSpritesToProcess
);
lastUpdateTime = now;
}
if (
SpriteSettingsApplierAPI.WillTextureSettingsChange(
relativePath,
prepared,
_settingsBuffer
)
)
{
_spritesThatWillChange++;
_assetsThatWillChange.Add(relativePath);
}
}
Utils.EditorUi.ClearProgress();
this.Log(
$"Calculation complete. Sprites to process: {_totalSpritesToProcess}, Sprites that will change: {_spritesThatWillChange}"
);
}
private void ApplySettings()
{
List<(string fullFilePath, string relativePath)> targetFiles = GetTargetSpritePaths();
int spriteCount = 0;
List updatedImporters = new(targetFiles.Count);
_applyCanceled = false;
List currentSettings;
if (_serializedObject.targetObject is SpriteSettingsApplierWindow windowInstance)
{
currentSettings = windowInstance.spriteSettings;
}
else
{
this.LogError(
$"Cannot access spriteSettings list from target object. Aborting apply."
);
return;
}
if (targetFiles.Count == 0)
{
this.LogWarn($"No sprites found to process based on current configuration.");
return;
}
using (AssetDatabaseBatchHelper.BeginBatch(refreshOnDispose: false))
{
// Prepare profile matchers once via API for unification
List prepared =
SpriteSettingsApplierAPI.PrepareProfiles(currentSettings);
double lastUpdateTime = EditorApplication.timeSinceStartup;
for (int i = 0; i < targetFiles.Count; i++)
{
string filePath = targetFiles[i].relativePath;
double now = EditorApplication.timeSinceStartup;
bool shouldUpdate =
i == 0
|| i == targetFiles.Count - 1
|| i % 50 == 0
|| now - lastUpdateTime > 0.2;
if (
shouldUpdate
&& Utils.EditorUi.CancelableProgress(
"Applying Sprite Settings",
$"Processing '{Path.GetFileName(filePath)}' ({i + 1}/{targetFiles.Count})",
(float)(i + 1) / targetFiles.Count
)
)
{
_applyCanceled = true;
break;
}
if (shouldUpdate)
{
lastUpdateTime = now;
}
if (
SpriteSettingsApplierAPI.TryUpdateTextureSettings(
filePath,
prepared,
out TextureImporter textureImporter,
_settingsBuffer
)
)
{
if (textureImporter != null)
{
updatedImporters.Add(textureImporter);
++spriteCount;
}
}
}
}
Utils.EditorUi.ClearProgress();
foreach (TextureImporter importer in updatedImporters)
{
importer.SaveAndReimport();
}
if (_applyCanceled)
{
this.Log($"Canceled. Processed {spriteCount} sprites before cancel.");
}
else
{
this.Log($"Processed {spriteCount} sprites.");
}
if (0 < spriteCount)
{
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
this.Log($"Asset database saved and refreshed.");
}
else
{
this.Log($"No sprites required changes.");
}
_totalSpritesToProcess = -1;
_spritesThatWillChange = -1;
}
// Matching and application logic lives in SpriteSettingsApplierAPI.
// Profiles persistence helpers
private void SaveProfilesAsset()
{
string path = EditorUtility.SaveFilePanelInProject(
"Save Sprite Settings Profiles",
"SpriteSettingsProfiles",
"asset",
"Choose location to save the profiles asset"
);
if (string.IsNullOrEmpty(path))
{
return;
}
SpriteSettingsProfileCollection asset =
CreateInstance();
asset.profiles = new List(spriteSettings.Count);
for (int i = 0; i < spriteSettings.Count; i++)
{
string json = JsonUtility.ToJson(spriteSettings[i]);
asset.profiles.Add(JsonUtility.FromJson(json));
}
AssetDatabase.CreateAsset(asset, path);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
this.Log($"Saved profiles to {path}");
}
private void LoadProfilesAsset()
{
string path = Utils.EditorUi.OpenFilePanel(
"Load Sprite Settings Profiles",
"Assets",
"asset"
);
if (string.IsNullOrEmpty(path))
{
return;
}
string projectRelative = path;
if (path.StartsWith(Application.dataPath))
{
projectRelative = "Assets" + path.Substring(Application.dataPath.Length);
}
else if (path.Contains("/Assets/"))
{
int idx = path.IndexOf("/Assets/", StringComparison.OrdinalIgnoreCase);
projectRelative = path.Substring(idx + 1);
}
SpriteSettingsProfileCollection asset =
AssetDatabase.LoadAssetAtPath(projectRelative);
if (asset == null)
{
this.LogWarn($"Could not load profiles asset at: {projectRelative}");
return;
}
spriteSettings = new List(asset.profiles.Count);
for (int i = 0; i < asset.profiles.Count; i++)
{
string json = JsonUtility.ToJson(asset.profiles[i]);
spriteSettings.Add(JsonUtility.FromJson(json));
}
this.Log($"Loaded {spriteSettings.Count} profiles from {projectRelative}");
}
}
#endif
}