// MIT License - Copyright (c) 2026 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.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using CustomEditors;
using UnityEditor;
using UnityEngine;
using WallstopStudios.UnityHelpers.Core.Extension;
using WallstopStudios.UnityHelpers.Core.Helper;
using WallstopStudios.UnityHelpers.Core.Serialization;
using WallstopStudios.UnityHelpers.Editor.Utils;
using WallstopStudios.UnityHelpers.Utils;
using Object = UnityEngine.Object;
///
/// Extracts individual sprites from sprite sheet textures (textures with SpriteImportMode.Multiple)
/// and saves them as separate PNG files. Provides preview GUI with reordering, bulk operations,
/// and optional reference replacement.
///
///
///
/// Problems this solves: splitting sprite sheets into individual assets for easier management,
/// creating separate sprites for animation systems that expect individual files, preparing
/// assets for different build targets.
///
///
/// How it works: scans input directories for textures with SpriteImportMode.Multiple, reads
/// sprite metadata (rects, names, pivots, borders), extracts pixel data for each sprite,
/// and writes individual PNG files. Optionally updates references in prefabs and scenes.
///
///
/// Usage:
///
///
/// - Open via menu: Tools/Wallstop Studios/Unity Helpers/Sprite Sheet Extractor.
/// - Select input folders and optional regex filter.
/// - Choose output directory and extraction options.
/// - Preview sprites and adjust selection/naming as needed.
/// - Click Extract to generate individual sprite files.
///
///
/// Pros: batch processing, preserves import settings, preview before extraction, undo support
/// for reference replacement. Caveats: extraction is one-way; reference replacement is
/// potentially destructive—use VCS.
///
///
public sealed class SpriteSheetExtractor : EditorWindow
{
private const string Name = "Sprite Sheet Extractor";
///
/// Controls whether diagnostic logging is enabled.
/// Set to true for debugging sprite regeneration and cache issues.
///
///
/// Using static readonly instead of const to avoid CS0162 unreachable code warnings
/// when the value is false, while still allowing JIT optimization.
///
private static readonly bool DiagnosticsEnabled = false;
///
/// Minimum score threshold for boundary transparency detection.
/// Lowered from 0.5 to 0.15 to handle sprite sheets with thin transparent gutters.
///
private const float MinimumBoundaryScore = 0.15f;
///
/// Maximum number of entries to keep fully cached with sprites.
/// Entries beyond this limit are evicted using LRU policy.
///
private const int MaxCachedEntries = 50;
private static readonly string[] ImageFileExtensions =
{
".png",
".jpg",
".jpeg",
".bmp",
".tga",
".psd",
".gif",
};
///
/// Common sprite cell sizes for grid detection candidate generation.
/// Avoids allocation during DetectOptimalGridFromTransparency calls.
///
private static readonly int[] CommonCellSizes =
{
8,
16,
24,
32,
48,
64,
96,
128,
256,
512,
};
private static readonly Vector2 CenterPivot = new(0.5f, 0.5f);
///
/// Color for sheet-level pivot markers (gold/yellow to differentiate from per-sprite markers).
///
private static readonly Color SheetPivotColor = new Color(1f, 0.84f, 0f, 0.8f);
///
/// EditorPrefs key for persisting splitter position.
///
private const string SplitterPositionPrefsKey =
"WallstopStudios.UnityHelpers.SpriteSheetExtractor.SplitterPosition";
///
/// Minimum height for the settings section (Input/Output/Discovery).
///
private const float MinSettingsHeight = 100f;
///
/// Minimum height for the preview section.
///
private const float MinPreviewHeight = 150f;
///
/// Height of the splitter bar in pixels.
///
private const float SplitterHeight = 5f;
///
/// Default splitter position as ratio of window height (0.4 = 40% settings, 60% preview).
///
private const float DefaultSplitterRatio = 0.4f;
///
/// Represents a discovered sprite sheet with its metadata.
///
public sealed class SpriteSheetEntry
{
internal string _assetPath;
internal Texture2D _texture;
internal TextureImporter _importer;
internal SpriteImportMode _importMode;
internal List _sprites;
internal bool _isExpanded;
internal bool _isSelected;
internal bool _useGlobalSettings = true;
internal bool _perSheetSettingsFoldout;
internal ExtractionMode? _extractionModeOverride;
internal GridSizeMode? _gridSizeModeOverride;
internal int? _gridColumnsOverride;
internal int? _gridRowsOverride;
internal int? _cellWidthOverride;
internal int? _cellHeightOverride;
internal int? _paddingLeftOverride;
internal int? _paddingRightOverride;
internal int? _paddingTopOverride;
internal int? _paddingBottomOverride;
internal float? _alphaThresholdOverride;
internal bool? _showOverlayOverride;
internal bool _sourcePreviewExpanded;
internal PivotMode? _pivotModeOverride;
internal Vector2? _customPivotOverride;
internal AutoDetectionAlgorithm? _autoDetectionAlgorithmOverride;
internal int? _expectedSpriteCountOverride;
///
/// Per-sheet override for snap to texture divisor. Only used when _useGlobalSettings is false.
///
internal bool? _snapToTextureDivisorOverride;
///
/// Whether to use a per-sheet pivot marker color override.
///
internal bool _usePivotMarkerColorOverride;
///
/// Per-sheet pivot marker color override.
/// UI-only preference; not saved to per-sheet config files.
///
internal Color _pivotMarkerColorOverride = Color.cyan;
///
/// When enabled, allows interactive pivot editing via click/drag in the source texture preview.
///
internal bool _editPivotsMode;
internal SpriteSheetConfig _loadedConfig;
internal bool _configLoaded;
internal bool _configStale;
internal SpriteSheetAlgorithms.AlgorithmResult? _cachedAlgorithmResult;
internal string _lastAlgorithmDisplayText;
///
/// The last computed cache key used to detect when sprite bounds need regeneration.
///
internal int _lastCacheKey;
///
/// Indicates whether the sprite bounds need regeneration due to settings changes.
///
internal bool _needsRegeneration;
///
/// The last access time (ticks) for LRU cache eviction.
///
internal long _lastAccessTime;
///
/// Computes a composite cache key based on all settings that affect sprite bounds calculation.
/// Used to detect when cached sprite data is stale and needs regeneration.
///
/// The SpriteSheetExtractor instance to read global settings from.
/// A hash code representing the current configuration state.
internal int GetBoundsCacheKey(SpriteSheetExtractor extractor)
{
if (extractor == null)
{
return 0;
}
ExtractionMode effectiveExtractionMode = extractor.GetEffectiveExtractionMode(this);
GridSizeMode effectiveGridSizeMode = extractor.GetEffectiveGridSizeMode(this);
int effectiveGridColumns = extractor.GetEffectiveGridColumns(this);
int effectiveGridRows = extractor.GetEffectiveGridRows(this);
int effectiveCellWidth = extractor.GetEffectiveCellWidth(this);
int effectiveCellHeight = extractor.GetEffectiveCellHeight(this);
int effectivePaddingLeft = extractor.GetEffectivePaddingLeft(this);
int effectivePaddingRight = extractor.GetEffectivePaddingRight(this);
int effectivePaddingTop = extractor.GetEffectivePaddingTop(this);
int effectivePaddingBottom = extractor.GetEffectivePaddingBottom(this);
float effectiveAlphaThreshold = extractor.GetEffectiveAlphaThreshold(this);
AutoDetectionAlgorithm effectiveAlgorithm =
extractor.GetEffectiveAutoDetectionAlgorithm(this);
int effectiveExpectedCount = extractor.GetEffectiveExpectedSpriteCount(this);
bool effectiveSnapToDivisor = extractor.GetEffectiveSnapToTextureDivisor(this);
int textureWidth = _texture != null ? _texture.width : 0;
int textureHeight = _texture != null ? _texture.height : 0;
return Objects.HashCode(
effectiveExtractionMode,
effectiveGridSizeMode,
effectiveGridColumns,
effectiveGridRows,
effectiveCellWidth,
effectiveCellHeight,
effectivePaddingLeft,
effectivePaddingRight,
effectivePaddingTop,
effectivePaddingBottom,
effectiveAlphaThreshold,
effectiveAlgorithm,
effectiveExpectedCount,
effectiveSnapToDivisor,
textureWidth,
textureHeight
);
}
}
///
/// Represents an individual sprite within a sprite sheet.
///
internal sealed class SpriteEntryData
{
internal string _originalName;
internal string _outputName;
internal Rect _rect;
internal Vector2 _pivot;
internal Vector4 _border;
internal int _sortIndex;
internal bool _isSelected;
internal Texture2D _previewTexture;
///
/// Whether to use a per-sprite pivot override.
///
internal bool _usePivotOverride;
///
/// Per-sprite pivot mode override. Only used when is true.
///
internal PivotMode _pivotModeOverride;
///
/// Per-sprite custom pivot override. Only used when is true
/// and is .
///
internal Vector2 _customPivotOverride;
///
/// Whether to use a per-sprite pivot marker color override.
///
internal bool _usePivotColorOverride;
///
/// Per-sprite pivot marker color override.
/// UI-only preference; not saved to per-sheet config files.
///
internal Color _pivotColorOverride;
}
///
/// Holds deferred import data for batch processing during sprite extraction.
/// This allows writing all PNG files first, then batching all import operations together.
///
internal readonly struct PendingImportSettings
{
///
/// The output path where the sprite was written.
///
internal readonly string OutputPath;
///
/// The source texture importer to copy settings from.
///
internal readonly TextureImporter SourceImporter;
///
/// The sprite entry data containing pivot, border, and other sprite-specific settings.
///
internal readonly SpriteEntryData Sprite;
///
/// The parent sheet entry for additional context.
///
internal readonly SpriteSheetEntry Entry;
internal PendingImportSettings(
string outputPath,
TextureImporter sourceImporter,
SpriteEntryData sprite,
SpriteSheetEntry entry
)
{
OutputPath = outputPath;
SourceImporter = sourceImporter;
Sprite = sprite;
Entry = entry;
}
}
public enum SortMode
{
[Obsolete("Use a specific SortMode value instead of None.")]
None = 0,
Original = 1,
ByName = 2,
ByPositionTopLeft = 3,
ByPositionBottomLeft = 4,
Reversed = 5,
}
///
/// Determines how sprites are discovered and extracted from sprite sheets.
///
public enum ExtractionMode
{
[Obsolete("Use a specific ExtractionMode value instead of None.")]
None = 0,
FromMetadata = 1,
GridBased = 2,
AlphaDetection = 3,
PaddedGrid = 4,
}
///
/// Determines whether grid dimensions are calculated automatically or manually specified.
///
public enum GridSizeMode
{
[Obsolete("Use a specific GridSizeMode value instead of None.")]
None = 0,
Auto = 1,
Manual = 2,
}
///
/// Determines the size of sprite preview thumbnails.
///
public enum PreviewSizeMode
{
[Obsolete("Use a specific PreviewSizeMode value instead of None.")]
None = 0,
Size24 = 1,
Size32 = 2,
Size64 = 3,
RealSize = 4,
}
///
/// Identifies whether a pivot drag operation targets a per-sprite or sheet-level pivot.
///
private enum PivotDragType
{
[Obsolete("Use a specific PivotDragType value instead of None.")]
None = 0,
Sprite = 1,
Sheet = 2,
}
[SerializeField]
internal List