// MIT License - Copyright (c) 2025 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 System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using CustomEditors;
using UnityEditor;
using UnityEngine;
using Utils;
using WallstopStudios.UnityHelpers.Core.Extension;
using WallstopStudios.UnityHelpers.Utils;
using Object = UnityEngine.Object;
///
/// Computes and applies a new sprite pivot based on an alpha-weighted center-of-mass
/// calculation, with optional regex filtering, fuzzy skip of unchanged results, and a force
/// reimport override.
///
///
///
/// Problems this solves: aligning sprites around a perceptual center (ignoring transparent
/// pixels below a cutoff) to simplify positioning and animation.
///
///
/// How it works: for each single-sprite texture in the selected folders (filtered by optional
/// regex), computes the pixel-weighted centroid using alpha >= cutoff and writes the
/// pivot into the importer settings.
///
///
/// Pros: predictable pivots for varied silhouettes; skip unchanged to speed runs.
/// Caveats: multi-sprite textures are not supported; importer may be dirtied frequently.
///
///
public class SpritePivotAdjuster : EditorWindow
{
internal static bool SuppressUserPrompts { get; set; }
private const float PivotEpsilon = 1e-3f;
private static readonly string[] ImageFileExtensions =
{
".png",
".jpg",
".jpeg",
".bmp",
".tga",
".psd",
".gif",
};
[SerializeField]
internal List _directoryPaths = new();
[SerializeField]
private string _spriteNameRegex = ".*";
[SerializeField]
internal float _alphaCutoff = 0.01f;
[SerializeField]
internal bool _skipUnchanged = true;
[SerializeField]
internal bool _forceReimport;
private SerializedObject _serializedObject;
private SerializedProperty _directoryPathsProperty;
private List _filesToProcess;
private Regex _regex;
private string _regexError;
private string _lastValidatedRegex;
[MenuItem("Tools/Wallstop Studios/Unity Helpers/Sprite Pivot Adjuster")]
public static void ShowWindow()
{
GetWindow("Sprite Pivot Adjuster");
}
static SpritePivotAdjuster()
{
// Auto-suppress UI prompts in batch mode and test runs
try
{
if (Application.isBatchMode || Utils.EditorUi.Suppress)
{
SuppressUserPrompts = true;
}
}
catch
{
// Ignore environment probing failures
}
}
private static bool IsInvokedByTestRunner()
{
// Heuristic: Unity test runs pass -runTests/-testResults on the command line
string[] args = Environment.GetCommandLineArgs();
for (int i = 0; i < args.Length; ++i)
{
string a = args[i];
if (
a.IndexOf("runTests", StringComparison.OrdinalIgnoreCase) >= 0
|| a.IndexOf("testResults", StringComparison.OrdinalIgnoreCase) >= 0
|| a.IndexOf("testPlatform", StringComparison.OrdinalIgnoreCase) >= 0
)
{
return true;
}
}
return false;
}
private void OnEnable()
{
_serializedObject = new SerializedObject(this);
_directoryPathsProperty = _serializedObject.FindProperty(nameof(_directoryPaths));
// _spriteNameRegexProperty was unused; using direct field to enable inline validation and tooltips.
}
private void OnGUI()
{
EditorGUILayout.LabelField(
new GUIContent(
"Input Directories",
"Folders to scan for textures. Only supported image extensions are considered."
),
EditorStyles.boldLabel
);
_serializedObject.Update();
PersistentDirectoryGUI.PathSelectorObjectArray(
_directoryPathsProperty,
nameof(SpriteCropper)
);
using (new GUILayout.HorizontalScope())
{
_spriteNameRegex = EditorGUILayout.TextField(
new GUIContent(
"Sprite Name Regex",
"Optional .NET regex applied to file names (no extension). Leave empty for all."
),
_spriteNameRegex
);
}
// Inline regex validation (validate only when the text changes to avoid per-frame cost)
if (!string.Equals(_spriteNameRegex, _lastValidatedRegex, StringComparison.Ordinal))
{
_lastValidatedRegex = _spriteNameRegex;
if (string.IsNullOrWhiteSpace(_spriteNameRegex))
{
_regexError = null;
}
else
{
try
{
_ = new Regex(_spriteNameRegex, RegexOptions.CultureInvariant);
_regexError = null;
}
catch (ArgumentException e)
{
_regexError = e.Message;
}
}
}
if (!string.IsNullOrEmpty(_regexError))
{
EditorGUILayout.HelpBox($"Invalid regex: {_regexError}", MessageType.Error);
}
EditorGUILayout.HelpBox(
"Single-sprite textures only. Alpha Cutoff ignores pixels at/under the threshold when computing center-of-mass pivot. 'Skip Unchanged' avoids reimport if change < "
+ PivotEpsilon
+ ". 'Force Reimport' overrides that.",
MessageType.Info
);
_alphaCutoff = EditorGUILayout.Slider(
new GUIContent(
"Alpha Cutoff",
"Pixels with alpha <= cutoff are ignored when computing the pivot."
),
_alphaCutoff,
0f,
1f
);
using (new GUILayout.HorizontalScope())
{
_skipUnchanged = EditorGUILayout.ToggleLeft(
new GUIContent(
"Skip Unchanged (fuzzy)",
"If pivot delta < " + PivotEpsilon + ", skip reimport to save time."
),
_skipUnchanged
);
}
using (new GUILayout.HorizontalScope())
{
_forceReimport = EditorGUILayout.ToggleLeft(
new GUIContent(
"Force Reimport",
"Reimport even if the computed pivot is unchanged. Overrides 'Skip Unchanged'."
),
_forceReimport
);
}
_serializedObject.ApplyModifiedProperties();
if (
GUILayout.Button(
new GUIContent(
"Find Sprites To Process",
"Scan selected folders and filter by regex."
)
)
)
{
_regex = null;
if (!string.IsNullOrEmpty(_regexError))
{
ShowNotification(new GUIContent("Invalid regex. Fix it before searching."));
return;
}
if (!string.IsNullOrWhiteSpace(_spriteNameRegex))
{
try
{
_regex = new Regex(
_spriteNameRegex,
RegexOptions.Compiled | RegexOptions.CultureInvariant
);
}
catch (ArgumentException e)
{
this.LogWarn($"Invalid regex '{_spriteNameRegex}'", e);
ShowNotification(new GUIContent("Invalid regex. Fix it before searching."));
return;
}
}
FindFilesToProcess();
}
if (_filesToProcess is { Count: > 0 })
{
GUILayout.Label(
$"Found {_filesToProcess.Count} sprites to process.",
EditorStyles.boldLabel
);
using (new GUILayout.HorizontalScope())
{
if (
GUILayout.Button(
new GUIContent(
"Dry Run",
"Simulate pivot changes without applying. Shows a brief summary."
)
)
)
{
AdjustPivotsInDirectory(dryRun: true);
}
if (
GUILayout.Button(
new GUIContent(
"Adjust Pivots in Directory",
"Compute and apply pivots (cancelable). Honors Alpha Cutoff, Skip Unchanged, and Force Reimport."
)
)
)
{
AdjustPivotsInDirectory(dryRun: false);
_filesToProcess = null;
}
}
}
else if (_filesToProcess != null)
{
GUILayout.Label(
"No sprites found to process in the selected directories.",
EditorStyles.label
);
}
}
internal void FindFilesToProcess()
{
_filesToProcess ??= new List();
_filesToProcess.Clear();
if (_directoryPaths is not { Count: > 0 })
{
this.LogWarn($"No input directories selected.");
return;
}
// Use AssetDatabase to find textures in selected folders to ensure asset-relative paths.
using PooledResource> seenRes = SetBuffers
.GetHashSetPool(StringComparer.OrdinalIgnoreCase)
.Get(out HashSet seen);
foreach (Object maybeDirectory in _directoryPaths.Where(d => d != null))
{
string assetPath = AssetDatabase.GetAssetPath(maybeDirectory);
if (!AssetDatabase.IsValidFolder(assetPath))
{
this.LogWarn($"Skipping invalid path: {assetPath}");
continue;
}
string[] guids = AssetDatabase.FindAssets("t:Texture2D", new[] { assetPath });
foreach (string guid in guids)
{
string file = AssetDatabase.GUIDToAssetPath(guid);
if (string.IsNullOrEmpty(file))
{
continue;
}
// Extension filter
if (
!Array.Exists(
ImageFileExtensions,
ext => file.EndsWith(ext, StringComparison.OrdinalIgnoreCase)
)
)
{
continue;
}
string fileName = Path.GetFileNameWithoutExtension(file);
if (_regex != null && !_regex.IsMatch(fileName))
{
continue;
}
if (seen.Add(file))
{
_filesToProcess.Add(file);
}
}
}
Repaint();
}
internal void AdjustPivotsInDirectory(bool dryRun)
{
if (_filesToProcess == null || _filesToProcess.Count == 0)
{
ShowNotification(new GUIContent("Nothing to process. Run 'Find' first."));
return;
}
using PooledResource> processedFilesRes = SetBuffers
.GetHashSetPool(StringComparer.OrdinalIgnoreCase)
.Get(out HashSet processedFiles);
using PooledResource> importersRes =
Buffers.List.Get(out List importers);
int totalCandidates = _filesToProcess?.Count ?? 0;
int processedSingles = 0;
int changed = 0;
int skippedUnchanged = 0;
int skippedNonReadable = 0;
int skippedNotSprite = 0;
int skippedNullSprite = 0;
int skippedNotSingle = 0;
bool canceled = false;
AssetDatabaseBatchScope? batchScope = dryRun
? null
: AssetDatabaseBatchHelper.BeginBatch(refreshOnDispose: false);
try
{
if (_filesToProcess == null)
{
return;
}
for (int i = 0; i < _filesToProcess.Count; i++)
{
string assetPath = _filesToProcess[i];
if (!processedFiles.Add(assetPath))
{
continue;
}
if (
AssetImporter.GetAtPath(assetPath)
is not TextureImporter { textureType: TextureImporterType.Sprite } importer
)
{
// Not a sprite texture; skip to next file
skippedNotSprite++;
continue;
}
Sprite sprite = AssetDatabase.LoadAssetAtPath(assetPath);
if (sprite == null)
{
skippedNullSprite++;
continue;
}
if (
ShowCancelableProgress(
"Processing sprites",
$"Processing {sprite.name}",
(float)i / _filesToProcess.Count
)
)
{
canceled = true;
break; // user canceled
}
if (importer.spriteImportMode == SpriteImportMode.Single)
{
processedSingles++;
if (!importer.isReadable)
{
skippedNonReadable++;
this.LogWarn($"Skipping non-readable texture: {assetPath}");
continue;
}
Vector2 newPivot = CalculateCenterOfMassPivot(sprite, _alphaCutoff);
Vector2 currentPivot = importer.spritePivot;
bool unchanged =
Mathf.Abs(currentPivot.x - newPivot.x) < PivotEpsilon
&& Mathf.Abs(currentPivot.y - newPivot.y) < PivotEpsilon;
if (_skipUnchanged && !_forceReimport && unchanged)
{
skippedUnchanged++;
continue; // no meaningful change and not forced
}
if (!dryRun)
{
Undo.RecordObject(importer, "Adjust Sprite Pivot");
TextureImporterSettings settings = new();
importer.ReadTextureSettings(settings);
settings.spritePivot = newPivot;
settings.spriteAlignment = (int)SpriteAlignment.Custom;
importer.SetTextureSettings(settings);
importer.spritePivot = newPivot;
importers.Add(importer);
}
changed++;
}
else
{
skippedNotSingle++;
}
}
}
finally
{
ClearProgress();
batchScope?.Dispose();
if (!dryRun)
{
foreach (TextureImporter importer in importers)
{
importer.SaveAndReimport();
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
using PooledResource sbRes = Buffers.StringBuilder.Get(
out StringBuilder sb
);
sb.AppendLine(
canceled ? "Canceled by user."
: dryRun ? "Dry run completed."
: "Completed."
);
sb.AppendLine($"Total candidates: {totalCandidates}");
sb.AppendLine($"Single sprites processed: {processedSingles}");
sb.AppendLine(
"Changed pivots" + (dryRun ? " (would change)" : "") + $": {changed}"
);
sb.AppendLine($"Skipped unchanged: {skippedUnchanged}");
sb.AppendLine($"Skipped non-readable: {skippedNonReadable}");
sb.AppendLine($"Skipped not sprite: {skippedNotSprite}");
sb.AppendLine($"Skipped missing sprite: {skippedNullSprite}");
sb.AppendLine($"Skipped multi-sprite textures: {skippedNotSingle}");
Info(
dryRun ? "Sprite Pivot Adjuster — Dry Run" : "Sprite Pivot Adjuster",
sb.ToString()
);
}
}
private static bool ShowCancelableProgress(string title, string info, float progress)
{
return Utils.EditorUi.CancelableProgress(title, info, progress);
}
private static void ClearProgress()
{
Utils.EditorUi.ClearProgress();
}
private static void Info(string title, string message)
{
Utils.EditorUi.Info(title, message);
}
private static Vector2 CalculateCenterOfMassPivot(Sprite sprite, float alphaCutoff)
{
Texture2D texture = sprite.texture;
Rect spriteRect = sprite.rect;
int startX = Mathf.FloorToInt(spriteRect.x);
int startY = Mathf.FloorToInt(spriteRect.y);
int width = Mathf.FloorToInt(spriteRect.width);
int height = Mathf.FloorToInt(spriteRect.height);
long totalX = 0;
long totalY = 0;
long pixelCount = 0;
// Fast path: sprite covers entire texture, use GetPixels32 for lower allocation and faster access
if (startX == 0 && startY == 0 && width == texture.width && height == texture.height)
{
Color32[] pixels32 = texture.GetPixels32();
byte alphaThreshold = (byte)Mathf.CeilToInt(alphaCutoff * 255f);
Parallel.For(
0,
height,
() => (sumX: 0L, sumY: 0L, count: 0L),
(y, _, local) =>
{
int rowOffset = y * width;
for (int x = 0; x < width; ++x)
{
if (pixels32[rowOffset + x].a > alphaThreshold)
{
local.sumX += x;
local.sumY += y;
local.count++;
}
}
return local;
},
local =>
{
Interlocked.Add(ref totalX, local.sumX);
Interlocked.Add(ref totalY, local.sumY);
Interlocked.Add(ref pixelCount, local.count);
}
);
}
else
{
Color[] pixels = texture.GetPixels(startX, startY, width, height);
Parallel.For(
0,
height,
() => (sumX: 0L, sumY: 0L, count: 0L),
(y, _, local) =>
{
int rowOffset = y * width;
for (int x = 0; x < width; ++x)
{
if (pixels[rowOffset + x].a > alphaCutoff)
{
local.sumX += x;
local.sumY += y;
local.count++;
}
}
return local;
},
local =>
{
Interlocked.Add(ref totalX, local.sumX);
Interlocked.Add(ref totalY, local.sumY);
Interlocked.Add(ref pixelCount, local.count);
}
);
}
if (pixelCount == 0L)
{
return new Vector2(0.5f, 0.5f);
}
double averageX = (double)totalX / pixelCount;
double averageY = (double)totalY / pixelCount;
double pivotX = averageX / width;
double pivotY = averageY / height;
return new Vector2(Mathf.Clamp01((float)pivotX), Mathf.Clamp01((float)pivotY));
}
}
#endif
}