// Copyright (c) 2025 EFramework Innovation. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
using System.IO;
using System.Linq;
using System.Globalization;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using UnityEditor.U2D.Sprites;
using EFramework.Unity.Utility;
using EFramework.Unity.Editor;
namespace EFramework.Unity.UIPlugin.Editor
{
///
/// UIAtlasEditor 是 UIAtlas 组件的编辑器工具,用于管理和处理 Unity 精灵图集。
///
///
///
/// 功能特性
/// - 在项目窗口中为 UIAtlas 预制体显示自定义图标
/// - 提供通过 TexturePacker 创建和导入精灵图集的功能
/// - 支持精灵图集的自动分割和重组
/// - 管理纹理导入设置和精灵元数据
/// - 支持透明度处理和边界修剪
///
/// 更多信息请参考模块文档。
///
public class UIAtlasEditor : XEditor.Event.Internal.OnEditorLoad
{
///
/// icon 是 UIAtlas 组件的自定义图标,用于优化资源的显示。
///
internal static Texture2D icon;
int XEditor.Event.Callback.Priority { get; }
bool XEditor.Event.Callback.Singleton { get => true; }
///
/// OnEditorLoad 事件回调处理了文件视图的绘制监听。
///
void XEditor.Event.Internal.OnEditorLoad.Process(params object[] _)
{
// 加载图标
var pkg = XEditor.Utility.FindPackage();
icon = AssetDatabase.LoadAssetAtPath($"Packages/{pkg.name}/Editor/Resources/Icon/Atlas.png");
// 监听绘制
if (icon) EditorApplication.projectWindowItemOnGUI += OnProjectWindowItemOnGUI;
}
///
/// OnProjectWindowItemOnGUI 是项目窗口绘制回调,为 UIAtlas 预制体添加自定义图标。
///
/// 资源的 GUID
/// 项目窗口中的绘制区域
internal static void OnProjectWindowItemOnGUI(string guid, Rect selectionRect)
{
var path = AssetDatabase.GUIDToAssetPath(guid);
if (path.EndsWith("prefab") && AssetDatabase.LoadAssetAtPath(path))
{
Rect iconRect = new Rect(selectionRect.x + selectionRect.width - 20, selectionRect.y, 16, 16);
GUI.DrawTexture(iconRect, icon);
}
}
///
/// Create 创建图集的菜单项处理方法。
///
[MenuItem("Assets/Create/2D/Sheet Atlas")]
internal static void Create()
{
if (Selection.assetGUIDs.Length == 0) XLog.Error("UIAtlasEditor.Create: non selection.");
else
{
var path = AssetDatabase.GUIDToAssetPath(Selection.assetGUIDs[0]);
if (string.IsNullOrEmpty(path)) XLog.Error("UIAtlasEditor.Create: selection path is empty.");
else
{
var rawPath = EditorUtility.SaveFolderPanel("Select Raw Path", XEnv.ProjectPath, null);
if (string.IsNullOrEmpty(rawPath)) XLog.Warn("UIAtlasEditor.Create: raw path is empty.");
else
{
rawPath = XFile.NormalizePath(Path.GetRelativePath(XEnv.ProjectPath, rawPath));
if (!XFile.HasDirectory(rawPath)) XLog.Error("UIAtlasEditor.Create: raw path doesn't exist: {0}", rawPath);
else
{
var atlasRoot = XFile.PathJoin(XFile.HasFile(path) ? Path.GetDirectoryName(path) : path, Path.GetFileName(rawPath));
if (!XFile.HasDirectory(atlasRoot)) XFile.CreateDirectory(atlasRoot);
var asset = Create(XFile.PathJoin(atlasRoot, Path.GetFileName(rawPath) + ".prefab"), rawPath);
if (asset) Selection.activeObject = asset;
}
}
}
}
}
///
/// Create 创建 UIAtlas 预制体资源。
///
/// UIAtlas 预制体的保存路径
/// 包含精灵图片的素材路径
/// 创建的 UIAtlas 资源对象
public static Object Create(string atlasPath, string rawPath)
{
var go = new GameObject();
go.AddComponent().RawPath = rawPath;
var asset = PrefabUtility.SaveAsPrefabAsset(go, atlasPath);
Object.DestroyImmediate(go);
AssetDatabase.Refresh();
return asset;
}
///
/// PostProcessor 是资源后处理器,处理 UIAtlas 资源的导入和移动事件。
///
internal class PostProcessor : AssetPostprocessor
{
///
/// 处理所有资源的后处理事件,导入 UIAtlas 资源。
///
/// 导入的资源路径
/// 删除的资源路径
/// 移动的资源路径
/// 资源移动前的路径
static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths)
{
foreach (var path in importedAssets)
{
if (path.EndsWith(".prefab") && AssetDatabase.LoadAssetAtPath(path))
{
Import(path);
}
}
foreach (var path in movedAssets)
{
if (path.EndsWith(".prefab") && AssetDatabase.LoadAssetAtPath(path))
{
Import(path);
}
}
}
}
///
/// Import 导入 UIAtlas 资源,使用 TexturePacker 处理图集。
///
/// UIAtlas 资源路径
/// 导入是否成功
public static bool Import(string path)
{
var atlas = AssetDatabase.LoadAssetAtPath(path);
if (atlas == null)
{
XLog.Error("UIAtlasEditor.Import: null atlas at: {0}", path);
return false;
}
if (!XFile.HasDirectory(atlas.RawPath))
{
XLog.Error("UIAtlasEditor.Import: raw path doesn't exist: {0}", atlas.RawPath);
return false;
}
var atlasName = Path.GetFileNameWithoutExtension(path);
var rawAtlasTex = XFile.PathJoin(Path.GetDirectoryName(atlas.RawPath), atlasName + ".png");
var rawAtlasSheet = XFile.PathJoin(Path.GetDirectoryName(atlas.RawPath), atlasName + ".tpsheet");
var argFile = XFile.PathJoin(Path.GetDirectoryName(atlas.RawPath), atlasName + ".arg");
var arg = "--format unity-texture2d --force-publish --trim --disable-auto-alias --disable-rotation --force-squared --extrude 1";
if (XFile.HasFile(argFile)) arg = XFile.OpenText(argFile);
var success = false;
try
{
var task = XEditor.Command.Run(
bin: XEditor.Command.Find("TexturePacker", "C:/Program Files/CodeAndWeb/TexturePacker/bin", "/Applications/TexturePacker.app/Contents/MacOS"),
args: new string[] { arg, $"--sheet \"{rawAtlasTex}\" --data \"{rawAtlasSheet}\" \"{atlas.RawPath}\"" });
task.Wait();
if (task.Result.Code == 0 && XFile.HasFile(rawAtlasTex) && XFile.HasFile(rawAtlasSheet))
{
var sheetDesc = Parse(rawAtlasSheet);
if (sheetDesc == null) XLog.Error("UIAtlasEditor.Import: parse sheet info failed: {0}", rawAtlasSheet);
else
{
Draw(sheetDesc, atlas.RawPath, rawAtlasTex, arg.Contains("--trim") && !arg.Contains("--trim-mode None"));
var atlasDst = XFile.NormalizePath(Path.GetFullPath(Path.GetDirectoryName(path)));
var atlasTex = XFile.PathJoin(atlasDst, atlasName + ".png");
var atlasPrefab = XFile.PathJoin(atlasDst, atlasName + ".prefab");
if (!XFile.HasDirectory(atlasDst)) XFile.CreateDirectory(atlasDst);
File.Copy(rawAtlasTex, atlasTex, true);
AssetDatabase.Refresh();
atlasTex = atlasTex[(Application.dataPath.Length + 1)..];
atlasTex = "Assets/" + atlasTex;
atlasPrefab = atlasPrefab[(Application.dataPath.Length + 1)..];
atlasPrefab = "Assets/" + atlasPrefab;
var atlasPngImporter = AssetImporter.GetAtPath(atlasTex) as TextureImporter;
atlasPngImporter.textureType = TextureImporterType.Sprite;
if (atlasPngImporter.mipmapEnabled)
{
atlasPngImporter.mipmapEnabled = false;
AssetDatabase.ImportAsset(atlasTex);
}
AssetDatabase.Refresh();
if (Split(sheetDesc, atlasTex))
{
AssetDatabase.Refresh();
var sps = AssetDatabase.LoadAllAssetsAtPath(atlasTex).OfType().ToArray();
var go = AssetDatabase.LoadAssetAtPath(atlasPrefab);
atlas.Sprites = sps.OrderBy(ele => ele.name, System.StringComparer.OrdinalIgnoreCase).ToArray();
go = PrefabUtility.SavePrefabAsset(go);
if (icon) EditorGUIUtility.SetIconForObject(go, icon);
}
success = true;
}
}
}
catch (System.Exception e) { XLog.Panic(e); }
if (XFile.HasFile(rawAtlasTex)) XFile.DeleteFile(rawAtlasTex);
if (XFile.HasFile(rawAtlasSheet)) XFile.DeleteFile(rawAtlasSheet);
if (success) XLog.Debug("UIAtlasEditor.Import: import {1} from {3} succeeded.", Path.GetFullPath(path), path, Path.GetFullPath(atlas.RawPath), atlas.RawPath);
else XLog.Error("UIAtlasEditor.Import: import {1} from {3} failed.", Path.GetFullPath(path), path, Path.GetFullPath(atlas.RawPath), atlas.RawPath);
return success;
}
///
/// SheetDesc 是图集的描述类,用于存储图集的元数据信息。
///
internal class SheetDesc
{
///
/// MetaData 是精灵元数据的数组。
///
public SpriteMetaData[] MetaData = null;
///
/// Width 是图集的宽度。
///
public int Width = -1;
///
/// Height 是图集的高度。
///
public int Height = -1;
///
/// PivotPointsEnabled 表示是否启用轴心点。
///
public bool PivotPointsEnabled = true;
///
/// BordersEnabled 表示是否启用边界。
///
public bool BordersEnabled = false;
///
/// PolygonsEnabled 表示是否启用多边形。
///
public bool PolygonsEnabled = false;
///
/// AlphaIsTransparency 表示 Alpha 是否为透明。
///
public bool AlphaIsTransparency = true;
}
///
/// Parse 解析 TexturePacker 生成的图集描述文件。
///
/// 图集描述文件路径
/// 解析后的图集描述对象,解析失败返回 null
internal static SheetDesc Parse(string sheetFile)
{
if (!XFile.HasFile(sheetFile)) return null;
var lines = File.ReadAllLines(sheetFile);
var nlines = new List();
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];
if (!line.StartsWith("#")) nlines.Add(line); // elimate SmartUpdate
}
if (lines.Length != nlines.Count) File.WriteAllLines(sheetFile, nlines.ToArray());
var sheetDesc = new SheetDesc();
var spriteMetaDataDict = new SortedDictionary();
foreach (string line in nlines)
{
if (line.StartsWith(":format="))
{
int formatVersion = int.Parse(line[8..]);
if (formatVersion > 40300)
{
EditorUtility.DisplayDialog("Please update TexturePacker Importer", "Your TexturePacker Importer is too old to import '" + sheetFile + "', please load a new version from the asset store!\n\nEnsure that you have selected 'Unity - Texture2D sprite sheet' as data format in your TexturePacker project!", "Ok");
return null;
}
}
if (line.StartsWith(":size="))
{
var sizeParts = line[6..].Split('x');
sheetDesc.Width = int.Parse(sizeParts[0]);
sheetDesc.Height = int.Parse(sizeParts[1]);
}
if (line.StartsWith(":pivotpoints="))
{
string pivotPoints = line[13..].ToLower().Trim();
sheetDesc.PivotPointsEnabled = pivotPoints == "enabled";
}
if (line.StartsWith(":borders="))
{
string borders = line[9..].ToLower().Trim();
sheetDesc.BordersEnabled = borders == "enabled";
}
if (line.StartsWith(":alphahandling=KeepTransparentPixels") || line.StartsWith(":alphahandling=PremultiplyAlpha"))
{
sheetDesc.AlphaIsTransparency = false;
}
if (string.IsNullOrEmpty(line) || line.StartsWith("#") || line.StartsWith(":"))
{
continue;
}
var parts = line.Split(';');
if (parts.Length < 7)
{
EditorUtility.DisplayDialog("File format error", "Failed to import '" + sheetFile + "'", "Ok");
return null;
}
var smd = new SpriteMetaData
{
name = unescapeName(parts[0]),
rect = new Rect(
float.Parse(parts[1], CultureInfo.InvariantCulture),
float.Parse(parts[2], CultureInfo.InvariantCulture),
float.Parse(parts[3], CultureInfo.InvariantCulture),
float.Parse(parts[4], CultureInfo.InvariantCulture)
)
};
var x = parts[5];
var y = parts[6];
sheetDesc.PivotPointsEnabled = sheetDesc.PivotPointsEnabled && parsePivot(x, y, ref smd);
if (parts.Length >= 11)
{
smd.border = new Vector4(
float.Parse(parts[7], CultureInfo.InvariantCulture),
float.Parse(parts[10], CultureInfo.InvariantCulture),
float.Parse(parts[8], CultureInfo.InvariantCulture),
float.Parse(parts[9], CultureInfo.InvariantCulture)
);
sheetDesc.PolygonsEnabled = true;
}
else
{
smd.border = Vector4.zero;
}
spriteMetaDataDict.Add(smd.name, smd);
}
sheetDesc.MetaData = new SpriteMetaData[spriteMetaDataDict.Count];
spriteMetaDataDict.Values.CopyTo(sheetDesc.MetaData, 0);
if (!sheetDesc.PivotPointsEnabled)
{
for (int i = 0; i < sheetDesc.MetaData.Length; i++)
{
sheetDesc.MetaData[i].pivot = new Vector2(0.5f, 0.5f);
sheetDesc.MetaData[i].alignment = 0;
}
}
return sheetDesc;
string unescapeName(string name) { return name.Replace("\\/", "/"); }
bool parsePivot(string x, string y, ref SpriteMetaData smd)
{
if (float.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out float pivotX) &&
float.TryParse(y, NumberStyles.Float, CultureInfo.InvariantCulture, out float pivotY))
{
smd.pivot = new Vector2(pivotX, pivotY);
smd.alignment = 0; // Assuming alignment is not specified, set to 0
return true;
}
return false;
}
}
///
/// Draw 绘制图集纹理,将各个精灵拼接到一个图集中。
///
/// 图集描述对象
/// 精灵图片所在的素材路径
/// 目标图集纹理文件路径
/// 是否裁剪透明边缘
internal static void Draw(SheetDesc sheetDesc, string rawPath, string textureFile, bool isTrim = true)
{
static int GetEdge(Texture2D tex, bool isStart, bool isHorizontal, bool isTrim)
{
if (!isTrim) return isStart ? 0 : (isHorizontal ? tex.width - 1 : tex.height - 1);
var startEdge = isStart ? 0 : (isHorizontal ? tex.width - 1 : tex.height - 1);
var endEdge = isStart ? (isHorizontal ? tex.width : tex.height) : -1;
var step = isStart ? 1 : -1;
for (var i = startEdge; i != endEdge; i += step)
{
var limit = isHorizontal ? tex.height : tex.width;
for (var j = 0; j < limit; j++)
{
var x = isHorizontal ? i : j;
var y = isHorizontal ? (tex.height - 1 - j) : i; // Y 坐标翻转,只在水平扫描时
if (tex.GetPixel(x, y).a > 0.01f) return i; // 找到非透明边缘
}
}
return 0;
}
var texWidth = sheetDesc.Width;
var texHeight = sheetDesc.Height;
var atlas = new Texture2D(texWidth, texHeight, TextureFormat.RGBA32, false);
// 初始化为透明
var empty = new Color32[texWidth * texHeight];
for (var i = 0; i < empty.Length; i++) empty[i] = new Color32(0, 0, 0, 0);
atlas.SetPixels32(empty);
foreach (var smd in sheetDesc.MetaData)
{
var spritePath = Path.Combine(rawPath, smd.name + ".png");
if (!File.Exists(spritePath)) spritePath = Path.ChangeExtension(spritePath, ".jpg");
if (!File.Exists(spritePath)) continue;
var imgBytes = File.ReadAllBytes(spritePath);
Texture2D spriteTex = new Texture2D(2, 2, TextureFormat.RGBA32, false);
spriteTex.LoadImage(imgBytes);
var startX = GetEdge(spriteTex, true, true, isTrim);
var endX = GetEdge(spriteTex, false, true, isTrim);
var startY = GetEdge(spriteTex, true, false, isTrim);
var endY = GetEdge(spriteTex, false, false, isTrim);
for (var i = startX; i <= endX; i++)
{
for (var j = startY; j <= endY; j++)
{
Color color = spriteTex.GetPixel(i, j);
var dstX = i - startX + (int)smd.rect.x;
var dstY = (int)(smd.rect.y + (j - startY));
if (dstX >= 0 && dstX < texWidth && dstY >= 0 && dstY < texHeight)
{
atlas.SetPixel(dstX, dstY, color);
}
}
}
}
atlas.Apply();
// 保存为 PNG
var pngBytes = atlas.EncodeToPNG();
File.WriteAllBytes(textureFile, pngBytes);
}
///
/// Split 拆分图集纹理为多个精灵。
///
/// 图集描述对象
/// 图集纹理文件路径
/// 是否成功拆分图集
internal static bool Split(SheetDesc sheetDesc, string textureFile)
{
var importer = AssetImporter.GetAtPath(textureFile) as TextureImporter;
if (importer == null)
{
XLog.Error("UIAtlasEditor.Split: not importer found for {0}", textureFile);
return false;
}
copyMeta(importer.spritesheet, sheetDesc.MetaData, !sheetDesc.PivotPointsEnabled, !sheetDesc.BordersEnabled);
if (!metaEqual(importer.spritesheet, sheetDesc.MetaData))
{
var settings = new TextureImporterSettings();
importer.ReadTextureSettings(settings);
settings.spriteMeshType = sheetDesc.PolygonsEnabled ? SpriteMeshType.Tight : SpriteMeshType.FullRect;
settings.textureType = TextureImporterType.Sprite;
settings.alphaIsTransparency = sheetDesc.AlphaIsTransparency;
settings.spriteMode = (int)SpriteImportMode.Multiple;
importer.SetTextureSettings(settings);
var dataProvider = getDataProvider(importer);
var spriteNameFileIdDataProvider = dataProvider.GetDataProvider();
var oldIds = spriteNameFileIdDataProvider.GetNameFileIdPairs();
var rects = getSpriteRects(sheetDesc);
var ids = genSpriteIDs(oldIds, rects);
dataProvider.SetSpriteRects(rects);
spriteNameFileIdDataProvider.SetNameFileIdPairs(ids);
dataProvider.Apply();
importer.SaveAndReimport();
return true;
}
return false;
static bool metaEqual(SpriteMetaData[] meta1, SpriteMetaData[] meta2)
{
var flag = meta1.Length == meta2.Length;
var num = 0;
while (flag && num < meta1.Length)
{
var m1 = meta1[num++];
var m2 = meta2.First(ele => ele.name == m1.name);
flag = m1.name == m2.name && m1.rect == m2.rect && m1.border == m2.border && m1.pivot == m2.pivot && m1.alignment == m2.alignment;
}
return flag;
}
static void copyMeta(SpriteMetaData[] oldMeta, SpriteMetaData[] newMeta, bool copyPivotPoints, bool copyBorders)
{
for (int i = 0; i < newMeta.Length; i++)
{
for (int j = 0; j < oldMeta.Length; j++)
{
var val = oldMeta[j];
if (val.name == newMeta[i].name)
{
if (copyPivotPoints)
{
newMeta[i].pivot = val.pivot;
newMeta[i].alignment = val.alignment;
}
if (copyBorders)
{
newMeta[i].border = val.border;
}
break;
}
}
}
}
static ISpriteEditorDataProvider getDataProvider(TextureImporter importer)
{
var dataProviderFactories = new SpriteDataProviderFactories();
dataProviderFactories.Init();
var dataProvider = dataProviderFactories.GetSpriteEditorDataProviderFromObject(importer);
dataProvider.InitSpriteEditorDataProvider();
return dataProvider;
}
static SpriteRect[] getSpriteRects(SheetDesc desc)
{
var spriteCount = desc.MetaData.Length;
SpriteRect[] rects = new SpriteRect[spriteCount];
for (int i = 0; i < spriteCount; i++)
{
SpriteRect sr = rects[i] = new SpriteRect();
SpriteMetaData smd = desc.MetaData[i];
sr.name = smd.name;
sr.rect = smd.rect;
sr.pivot = smd.pivot;
sr.border = smd.border;
sr.alignment = (SpriteAlignment)smd.alignment;
}
return rects;
}
static SpriteNameFileIdPair[] genSpriteIDs(IEnumerable oldIds,
SpriteRect[] sprites)
{
var newIds = new SpriteNameFileIdPair[sprites.Length];
for (var i = 0; i < sprites.Length; i++)
{
sprites[i].spriteID = idForName(oldIds, sprites[i].name);
newIds[i] = new SpriteNameFileIdPair(sprites[i].name, sprites[i].spriteID);
}
return newIds;
}
static GUID idForName(IEnumerable oldIds, string name)
{
foreach (var old in oldIds)
{
if (old.name == name)
{
return old.GetFileGUID();
}
}
return GUID.Generate();
}
}
}
}