user 4a49ecd772 Refactor: 배경/프랍 브라우저 IMGUI→UI Toolkit 전환 + USS 리디자인
- BackgroundSceneLoaderWindow: OnGUI → CreateGUI (Toolbar + ToolbarSearchField)
- PropBrowserWindow: OnGUI → CreateGUI (Toolbar + ToolbarSearchField)
- StreamingleCommon.uss: 브라우저 공통 스타일 추가 (그리드/리스트/뷰토글/액션바/상태바)
- excludeFromWeb 상태 새로고침 시 보존 수정
- 삭제된 배경 리소스 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 01:55:48 +09:00

1078 lines
42 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace Streamingle.Prop.Editor
{
/// <summary>
/// 개별 프리펩 표시용 데이터
/// </summary>
public class PrefabDisplayItem
{
public string prefabName;
public string prefabPath;
public string folderName;
public PropInfo parentProp;
public string thumbnailPath;
public string DisplayName => prefabName;
}
/// <summary>
/// 프랍 브라우저 에디터 윈도우 (UI Toolkit)
/// </summary>
public class PropBrowserWindow : EditorWindow
{
private const string PROP_PATH = "Assets/ResourcesData/Prop";
private const string DATABASE_PATH = "Assets/Resources/Settings/PropDatabase.asset";
private const string THUMBNAIL_FOLDER = "Thumbnail";
private const int THUMBNAIL_SIZE = 128;
private const int THUMBNAIL_CAPTURE_SIZE = 512;
private PropDatabase _database;
private List<PropInfo> _filteredProps;
private List<PrefabDisplayItem> _displayItems;
private Dictionary<string, Texture2D> _thumbnailCache = new Dictionary<string, Texture2D>();
private string _searchFilter = "";
private int _viewMode = 0; // 0: 그리드, 1: 리스트
// UI 참조
private ToolbarSearchField _searchField;
private Button _gridBtn, _listBtn;
private Label _countLabel;
private ScrollView _scrollView;
[MenuItem("Streamingle/Prop Browser %#p")]
public static void ShowWindow()
{
var window = GetWindow<PropBrowserWindow>("프랍 브라우저");
window.minSize = new Vector2(400, 500);
window.Show();
}
public void CreateGUI()
{
var root = rootVisualElement;
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(
"Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss");
if (uss != null) root.styleSheets.Add(uss);
root.AddToClassList("tool-root");
root.style.paddingTop = 0;
root.style.paddingBottom = 0;
root.style.paddingLeft = 0;
root.style.paddingRight = 0;
LoadOrCreateDatabase();
BuildUI(root);
RebuildContent();
root.RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanel);
}
private static Button MakeBarButton(string text, System.Action onClick)
{
var btn = new Button(onClick) { text = text };
btn.style.height = 26;
btn.style.paddingLeft = 14;
btn.style.paddingRight = 14;
btn.style.fontSize = 12;
return btn;
}
private void BuildUI(VisualElement root)
{
// ── 검색 툴바 (Toolbar 사용 — ToolbarSearchField 정상 렌더링) ──
var toolbar = new Toolbar();
toolbar.style.borderBottomWidth = 1;
toolbar.style.borderBottomColor = new Color(1f, 1f, 1f, 0.06f);
_searchField = new ToolbarSearchField();
_searchField.style.flexGrow = 1;
_searchField.RegisterValueChangedCallback(OnSearchChanged);
toolbar.Add(_searchField);
var refreshBtn = new ToolbarButton(OnRefreshClicked) { text = "새로고침" };
toolbar.Add(refreshBtn);
root.Add(toolbar);
// ── 컨트롤 바 ──
var controlsRow = new VisualElement();
controlsRow.AddToClassList("browser-controls-row");
controlsRow.style.minHeight = 38;
_countLabel = new Label("총 0개 프리펩");
_countLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
_countLabel.style.fontSize = 12;
controlsRow.Add(_countLabel);
controlsRow.Add(new VisualElement { style = { flexGrow = 1 } });
var viewGroup = new VisualElement();
viewGroup.AddToClassList("view-toggle-group");
viewGroup.style.height = 28;
_gridBtn = new Button(() => SetViewMode(0)) { text = "그리드" };
_gridBtn.AddToClassList("view-btn");
_gridBtn.style.height = 24;
viewGroup.Add(_gridBtn);
_listBtn = new Button(() => SetViewMode(1)) { text = "리스트" };
_listBtn.AddToClassList("view-btn");
_listBtn.style.height = 24;
viewGroup.Add(_listBtn);
controlsRow.Add(viewGroup);
root.Add(controlsRow);
// ── 스크롤 콘텐츠 ──
_scrollView = new ScrollView(ScrollViewMode.Vertical);
_scrollView.AddToClassList("browser-scroll");
root.Add(_scrollView);
// ── 하단 액션 바 ──
var actionBar = new VisualElement();
actionBar.AddToClassList("browser-action-bar");
actionBar.style.minHeight = 40;
actionBar.Add(new VisualElement { style = { flexGrow = 1 } });
var thumbBtn = MakeBarButton("썸네일 생성", ShowThumbnailMenu);
actionBar.Add(thumbBtn);
var webBtn = MakeBarButton("웹 업로드", ShowWebUploadMenu);
actionBar.Add(webBtn);
var folderBtn = MakeBarButton("폴더", () => EditorUtility.RevealInFinder(PROP_PATH));
actionBar.Add(folderBtn);
root.Add(actionBar);
UpdateViewModeButtons();
}
private void OnDetachFromPanel(DetachFromPanelEvent evt)
{
ClearThumbnailCache();
}
private void OnRefreshClicked()
{
RefreshPropList();
RebuildContent();
}
private void OnSearchChanged(ChangeEvent<string> evt)
{
_searchFilter = evt.newValue;
ApplyFilter();
RebuildContent();
}
private void SetViewMode(int mode)
{
_viewMode = mode;
UpdateViewModeButtons();
RebuildContent();
}
private void UpdateViewModeButtons()
{
_gridBtn.EnableInClassList("view-btn--active", _viewMode == 0);
_listBtn.EnableInClassList("view-btn--active", _viewMode == 1);
}
private void UpdateCountLabel()
{
int totalPrefabCount = _database?.props.Sum(p => Math.Max(1, p.prefabPaths.Count)) ?? 0;
int filteredCount = _displayItems?.Count ?? 0;
_countLabel.text = string.IsNullOrEmpty(_searchFilter)
? $"총 {totalPrefabCount}개 프리펩"
: $"{filteredCount}/{totalPrefabCount}개 프리펩";
}
// ─── 콘텐츠 빌드 ───
private void RebuildContent()
{
if (_scrollView == null) return;
_scrollView.Clear();
UpdateCountLabel();
if (_database == null || _displayItems == null)
{
var emptyLabel = new Label("데이터베이스를 로드할 수 없습니다. 새로고침을 눌러주세요.");
emptyLabel.AddToClassList("browser-empty");
_scrollView.Add(emptyLabel);
return;
}
if (_displayItems.Count == 0)
{
var emptyLabel = new Label("프리펩이 없습니다.");
emptyLabel.AddToClassList("browser-empty");
_scrollView.Add(emptyLabel);
return;
}
if (_viewMode == 0)
BuildGridView();
else
BuildListView();
}
private void BuildGridView()
{
var gridContainer = new VisualElement();
gridContainer.AddToClassList("grid-container");
foreach (var item in _displayItems)
{
var gridItem = CreateGridItem(item);
gridContainer.Add(gridItem);
}
_scrollView.Add(gridContainer);
}
private VisualElement CreateGridItem(PrefabDisplayItem item)
{
var container = new VisualElement();
container.AddToClassList("grid-item");
// 썸네일
var thumbContainer = new VisualElement();
thumbContainer.AddToClassList("grid-thumbnail");
var thumbnail = GetThumbnailForItem(item);
if (thumbnail != null)
{
thumbContainer.style.backgroundImage = new StyleBackground(thumbnail);
}
else
{
// AssetPreview 폴백
if (!string.IsNullOrEmpty(item.prefabPath))
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(item.prefabPath);
if (prefab != null)
{
var preview = AssetPreview.GetAssetPreview(prefab);
if (preview != null)
{
thumbContainer.style.backgroundImage = new StyleBackground(preview);
}
else
{
AssetPreview.SetPreviewTextureCacheSize(256);
var loadingLabel = new Label("Loading...");
loadingLabel.AddToClassList("no-thumbnail");
thumbContainer.Add(loadingLabel);
// 비동기 프리뷰 로딩 재시도
thumbContainer.schedule.Execute(() =>
{
if (thumbContainer.panel == null) return;
var p = AssetPreview.GetAssetPreview(prefab);
if (p != null)
{
thumbContainer.Clear();
thumbContainer.style.backgroundImage = new StyleBackground(p);
}
}).Every(500).Until(() =>
{
if (thumbContainer.panel == null) return true;
var p = AssetPreview.GetAssetPreview(
AssetDatabase.LoadAssetAtPath<GameObject>(item.prefabPath));
return p != null;
});
}
}
}
else
{
var noThumb = new Label("No\nPrefab");
noThumb.AddToClassList("no-thumbnail");
thumbContainer.Add(noThumb);
}
}
// 폴더 이름 배지
if (!string.IsNullOrEmpty(item.folderName) && item.folderName != item.prefabName)
{
var badge = new Label(item.folderName);
badge.AddToClassList("folder-badge");
thumbContainer.Add(badge);
}
container.Add(thumbContainer);
// 프리펩 이름
var label = new Label(item.prefabName);
label.AddToClassList("grid-label");
container.Add(label);
// 더블클릭 → 프리펩 선택
container.RegisterCallback<ClickEvent>(evt =>
{
if (evt.clickCount == 2)
{
SelectPrefabInProject(item);
evt.StopPropagation();
}
});
// 드래그 앤 드롭
container.RegisterCallback<PointerDownEvent>(evt =>
{
if (evt.button == 0 && evt.clickCount == 1)
HandleDragStartForItem(item);
});
// 우클릭 컨텍스트 메뉴
container.AddManipulator(new ContextualMenuManipulator(menuEvt =>
{
PopulateContextMenu(menuEvt, item);
}));
return container;
}
private void BuildListView()
{
foreach (var item in _displayItems)
{
var row = CreateListRow(item);
_scrollView.Add(row);
}
}
private VisualElement CreateListRow(PrefabDisplayItem item)
{
var row = new VisualElement();
row.AddToClassList("list-row");
// 썸네일
var thumb = new VisualElement();
thumb.AddToClassList("list-thumbnail");
var thumbnail = GetThumbnailForItem(item);
if (thumbnail != null)
{
thumb.style.backgroundImage = new StyleBackground(thumbnail);
}
else if (!string.IsNullOrEmpty(item.prefabPath))
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(item.prefabPath);
if (prefab != null)
{
var preview = AssetPreview.GetAssetPreview(prefab);
if (preview != null)
thumb.style.backgroundImage = new StyleBackground(preview);
}
}
row.Add(thumb);
// 정보
var info = new VisualElement();
info.AddToClassList("list-row-info");
var nameLabel = new Label(item.prefabName);
nameLabel.AddToClassList("list-row-name");
info.Add(nameLabel);
if (!string.IsNullOrEmpty(item.folderName) && item.folderName != item.prefabName)
{
var detailLabel = new Label($"폴더: {item.folderName}");
detailLabel.AddToClassList("list-row-detail");
info.Add(detailLabel);
}
row.Add(info);
// 버튼
var buttons = new VisualElement();
buttons.AddToClassList("list-row-buttons");
var selectBtn = new Button(() => SelectPrefabInProject(item)) { text = "선택" };
buttons.Add(selectBtn);
var menuBtn = new Button() { text = "..." };
menuBtn.AddManipulator(new ContextualMenuManipulator(menuEvt =>
{
PopulateContextMenu(menuEvt, item);
}));
buttons.Add(menuBtn);
row.Add(buttons);
// 드래그
row.RegisterCallback<PointerDownEvent>(evt =>
{
if (evt.button == 0)
HandleDragStartForItem(item);
});
return row;
}
private void PopulateContextMenu(ContextualMenuPopulateEvent menuEvt, PrefabDisplayItem item)
{
menuEvt.menu.AppendAction("프로젝트에서 선택", _ => SelectPrefabInProject(item));
if (item.parentProp != null)
menuEvt.menu.AppendAction("폴더 열기", _ => RevealInFinder(item.parentProp));
menuEvt.menu.AppendSeparator();
menuEvt.menu.AppendAction("씬에 배치", _ => InstantiatePrefabFromItem(item));
menuEvt.menu.AppendSeparator();
menuEvt.menu.AppendAction("이 프리펩 썸네일 생성", _ =>
{
GenerateThumbnailForItem(item);
ClearThumbnailCache();
RebuildContent();
});
}
// ─── 드래그 앤 드롭 ───
private void HandleDragStartForItem(PrefabDisplayItem item)
{
if (string.IsNullOrEmpty(item.prefabPath)) return;
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(item.prefabPath);
if (prefab == null) return;
DragAndDrop.PrepareStartDrag();
DragAndDrop.objectReferences = new UnityEngine.Object[] { prefab };
DragAndDrop.paths = new string[] { item.prefabPath };
DragAndDrop.StartDrag(item.prefabName);
}
// ─── 프리펩 작업 ───
private void SelectPrefabInProject(PrefabDisplayItem item)
{
if (!string.IsNullOrEmpty(item.prefabPath))
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(item.prefabPath);
if (prefab != null)
{
Selection.activeObject = prefab;
EditorGUIUtility.PingObject(prefab);
return;
}
}
if (item.parentProp != null)
{
var folder = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(item.parentProp.folderPath);
if (folder != null)
{
Selection.activeObject = folder;
EditorGUIUtility.PingObject(folder);
}
}
}
private void RevealInFinder(PropInfo propInfo) => EditorUtility.RevealInFinder(propInfo.folderPath);
private void InstantiatePrefabFromItem(PrefabDisplayItem item)
{
if (string.IsNullOrEmpty(item.prefabPath))
{
EditorUtility.DisplayDialog("오류", "프리펩이 없습니다.", "확인");
return;
}
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(item.prefabPath);
if (prefab == null)
{
EditorUtility.DisplayDialog("오류", "프리펩을 로드할 수 없습니다.", "확인");
return;
}
var instance = PrefabUtility.InstantiatePrefab(prefab) as GameObject;
if (instance != null)
{
if (SceneView.lastActiveSceneView != null)
instance.transform.position = SceneView.lastActiveSceneView.pivot;
Selection.activeGameObject = instance;
Undo.RegisterCreatedObjectUndo(instance, "Instantiate Prop");
}
}
// ─── 썸네일 ───
private Texture2D GetThumbnailForItem(PrefabDisplayItem item)
{
if (string.IsNullOrEmpty(item.thumbnailPath)) return null;
if (_thumbnailCache.TryGetValue(item.thumbnailPath, out var cached)) return cached;
var texture = AssetDatabase.LoadAssetAtPath<Texture2D>(item.thumbnailPath);
if (texture != null) _thumbnailCache[item.thumbnailPath] = texture;
return texture;
}
private void ClearThumbnailCache() => _thumbnailCache.Clear();
// ─── 데이터베이스 ───
private void LoadOrCreateDatabase()
{
_database = AssetDatabase.LoadAssetAtPath<PropDatabase>(DATABASE_PATH);
if (_database == null)
{
if (!AssetDatabase.IsValidFolder("Assets/Resources"))
AssetDatabase.CreateFolder("Assets", "Resources");
if (!AssetDatabase.IsValidFolder("Assets/Resources/Settings"))
AssetDatabase.CreateFolder("Assets/Resources", "Settings");
_database = CreateInstance<PropDatabase>();
AssetDatabase.CreateAsset(_database, DATABASE_PATH);
AssetDatabase.SaveAssets();
}
RefreshPropList();
}
private void RefreshPropList()
{
if (_database == null) return;
_database.props.Clear();
if (!Directory.Exists(PROP_PATH))
{
Debug.LogWarning($"Prop 폴더가 없습니다: {PROP_PATH}");
ApplyFilter();
return;
}
var propFolders = Directory.GetDirectories(PROP_PATH)
.Where(f => !f.EndsWith("Glb") && !f.EndsWith("Prop Prefab"))
.ToList();
foreach (var folderPath in propFolders)
{
string propName = Path.GetFileName(folderPath);
string assetPath = folderPath.Replace("\\", "/");
int assetsIndex = assetPath.IndexOf("Assets/");
if (assetsIndex >= 0) assetPath = assetPath.Substring(assetsIndex);
var propInfo = new PropInfo { propName = propName, folderPath = assetPath };
ScanPropFiles(propInfo, folderPath);
_database.props.Add(propInfo);
}
_database.props = _database.props.OrderBy(p => p.propName).ToList();
EditorUtility.SetDirty(_database);
AssetDatabase.SaveAssets();
ApplyFilter();
Debug.Log($"[PropBrowser] {_database.props.Count}개 프랍 스캔 완료");
}
private void ScanPropFiles(PropInfo propInfo, string folderPath)
{
var allFiles = Directory.GetFiles(folderPath, "*.*", SearchOption.AllDirectories)
.Where(f => !f.EndsWith(".meta")).ToList();
foreach (var file in allFiles)
{
string ext = Path.GetExtension(file).ToLower();
string assetPath = file.Replace("\\", "/");
int assetsIndex = assetPath.IndexOf("Assets/");
if (assetsIndex >= 0) assetPath = assetPath.Substring(assetsIndex);
if (ext == ".prefab")
{
string parentFolder = Path.GetFileName(Path.GetDirectoryName(file));
if (parentFolder.Equals("Prefab", StringComparison.OrdinalIgnoreCase))
propInfo.prefabPaths.Add(assetPath);
}
else if (ext == ".glb" || ext == ".fbx" || ext == ".obj")
{
propInfo.modelPaths.Add(assetPath);
}
else if (ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".tga" || ext == ".psd")
{
propInfo.textureCount++;
string fileName = Path.GetFileNameWithoutExtension(file).ToLower();
if (fileName.Contains("thumbnail") || fileName.Contains("_thumb"))
propInfo.thumbnailPath = assetPath;
}
else if (ext == ".mat")
{
propInfo.materialCount++;
}
}
propInfo.prefabPaths = propInfo.prefabPaths.OrderBy(p => Path.GetFileName(p)).ToList();
}
private void ApplyFilter()
{
if (_database == null)
{
_filteredProps = new List<PropInfo>();
_displayItems = new List<PrefabDisplayItem>();
return;
}
if (string.IsNullOrEmpty(_searchFilter))
_filteredProps = new List<PropInfo>(_database.props);
else
{
string filter = _searchFilter.ToLower();
_filteredProps = _database.props
.Where(p => p.propName.ToLower().Contains(filter) ||
p.prefabPaths.Any(pp => Path.GetFileNameWithoutExtension(pp).ToLower().Contains(filter)))
.ToList();
}
_displayItems = new List<PrefabDisplayItem>();
foreach (var prop in _filteredProps)
{
if (prop.prefabPaths.Count > 0)
{
foreach (var prefabPath in prop.prefabPaths)
{
string prefabName = Path.GetFileNameWithoutExtension(prefabPath);
if (!string.IsNullOrEmpty(_searchFilter))
{
string filter = _searchFilter.ToLower();
if (!prefabName.ToLower().Contains(filter) && !prop.propName.ToLower().Contains(filter))
continue;
}
string prefabThumbnailPath = $"{prop.folderPath}/{THUMBNAIL_FOLDER}/{prefabName}_thumbnail.png";
string prefabThumbnailFullPath = prefabThumbnailPath.Replace("Assets/", Application.dataPath + "/");
string thumbnailToUse = File.Exists(prefabThumbnailFullPath) ? prefabThumbnailPath : prop.thumbnailPath;
_displayItems.Add(new PrefabDisplayItem
{
prefabName = prefabName,
prefabPath = prefabPath,
folderName = prop.propName,
parentProp = prop,
thumbnailPath = thumbnailToUse
});
}
}
else
{
_displayItems.Add(new PrefabDisplayItem
{
prefabName = prop.propName,
prefabPath = null,
folderName = prop.propName,
parentProp = prop,
thumbnailPath = prop.thumbnailPath
});
}
}
_displayItems = _displayItems.OrderBy(d => d.prefabName).ToList();
}
// ─── 썸네일 생성 ───
private void ShowThumbnailMenu()
{
var menu = new GenericMenu();
menu.AddItem(new GUIContent("모든 프리펩 썸네일 생성 (개별)"), false, GenerateAllPrefabThumbnails);
menu.AddItem(new GUIContent("썸네일 없는 프리펩만 생성"), false, GenerateMissingPrefabThumbnails);
menu.AddSeparator("");
menu.AddItem(new GUIContent("폴더 단위로 생성 (기존 방식)/모든 폴더"), false, GenerateAllThumbnails);
menu.AddItem(new GUIContent("폴더 단위로 생성 (기존 방식)/선택한 폴더"), false, GenerateThumbnailsForSelected);
menu.ShowAsContext();
}
private bool GenerateThumbnailForItem(PrefabDisplayItem item)
{
if (string.IsNullOrEmpty(item.prefabPath) || item.parentProp == null)
{
Debug.LogWarning($"[PropBrowser] {item.prefabName}: 프리펩 경로가 없습니다.");
return false;
}
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(item.prefabPath);
if (prefab == null)
{
Debug.LogWarning($"[PropBrowser] {item.prefabName}: 프리펩을 로드할 수 없습니다.");
return false;
}
string thumbnailFolderPath = $"{item.parentProp.folderPath}/{THUMBNAIL_FOLDER}";
string thumbnailFileName = $"{item.prefabName}_thumbnail.png";
string thumbnailPath = $"{thumbnailFolderPath}/{thumbnailFileName}";
if (!AssetDatabase.IsValidFolder(thumbnailFolderPath))
AssetDatabase.CreateFolder(item.parentProp.folderPath, THUMBNAIL_FOLDER);
var previewTexture = GeneratePreviewTexture(prefab);
if (previewTexture == null)
{
Debug.LogWarning($"[PropBrowser] {item.prefabName}: 미리보기를 생성할 수 없습니다.");
return false;
}
try
{
byte[] pngData = previewTexture.EncodeToPNG();
string fullPath = thumbnailPath.Replace("Assets/", Application.dataPath + "/");
string directory = Path.GetDirectoryName(fullPath);
if (!Directory.Exists(directory)) Directory.CreateDirectory(directory);
File.WriteAllBytes(fullPath, pngData);
DestroyImmediate(previewTexture);
AssetDatabase.ImportAsset(thumbnailPath);
item.thumbnailPath = thumbnailPath;
Debug.Log($"[PropBrowser] {item.prefabName}: 썸네일 생성 완료");
return true;
}
catch (Exception ex)
{
Debug.LogError($"[PropBrowser] {item.prefabName}: 썸네일 저장 실패 - {ex.Message}");
return false;
}
}
private void GenerateAllPrefabThumbnails()
{
var allItems = new List<PrefabDisplayItem>();
foreach (var prop in _database.props)
foreach (var prefabPath in prop.prefabPaths)
allItems.Add(new PrefabDisplayItem
{
prefabName = Path.GetFileNameWithoutExtension(prefabPath),
prefabPath = prefabPath,
folderName = prop.propName,
parentProp = prop
});
if (allItems.Count == 0)
{
EditorUtility.DisplayDialog("알림", "생성할 프리펩이 없습니다.", "확인");
return;
}
if (!EditorUtility.DisplayDialog("확인",
$"모든 프리펩 ({allItems.Count}개)의 썸네일을 개별 생성합니다.\n계속하시겠습니까?", "생성", "취소")) return;
GeneratePrefabThumbnails(allItems, "모든 프리펩");
}
private void GenerateMissingPrefabThumbnails()
{
var itemsWithoutThumbnail = new List<PrefabDisplayItem>();
foreach (var prop in _database.props)
{
foreach (var prefabPath in prop.prefabPaths)
{
string prefabName = Path.GetFileNameWithoutExtension(prefabPath);
string expectedPath = $"{prop.folderPath}/{THUMBNAIL_FOLDER}/{prefabName}_thumbnail.png";
string fullPath = expectedPath.Replace("Assets/", Application.dataPath + "/");
if (!File.Exists(fullPath))
{
itemsWithoutThumbnail.Add(new PrefabDisplayItem
{
prefabName = prefabName,
prefabPath = prefabPath,
folderName = prop.propName,
parentProp = prop
});
}
}
}
if (itemsWithoutThumbnail.Count == 0)
{
EditorUtility.DisplayDialog("알림", "모든 프리펩에 썸네일이 있습니다.", "확인");
return;
}
GeneratePrefabThumbnails(itemsWithoutThumbnail, $"썸네일 없는 {itemsWithoutThumbnail.Count}개 프리펩");
}
private void GeneratePrefabThumbnails(List<PrefabDisplayItem> items, string description)
{
int successCount = 0, failCount = 0;
try
{
for (int i = 0; i < items.Count; i++)
{
EditorUtility.DisplayProgressBar("썸네일 생성 중",
$"{items[i].prefabName} ({i + 1}/{items.Count})", (float)i / items.Count);
if (GenerateThumbnailForItem(items[i])) successCount++;
else failCount++;
}
}
finally { EditorUtility.ClearProgressBar(); }
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
ClearThumbnailCache();
ApplyFilter();
RebuildContent();
EditorUtility.DisplayDialog("완료",
$"{description} 썸네일 생성 완료\n성공: {successCount}개\n실패: {failCount}개", "확인");
}
private void GenerateThumbnailsForSelected()
{
var selectedObjects = Selection.objects;
var propsToGenerate = new List<PropInfo>();
foreach (var obj in selectedObjects)
{
string path = AssetDatabase.GetAssetPath(obj);
if (string.IsNullOrEmpty(path)) continue;
var prop = _database.props.Find(p => path.StartsWith(p.folderPath));
if (prop != null && !propsToGenerate.Contains(prop))
propsToGenerate.Add(prop);
}
if (propsToGenerate.Count == 0)
{
EditorUtility.DisplayDialog("알림", "선택된 프랍이 없습니다.\n프로젝트 창에서 프랍 폴더나 프리펩을 선택해주세요.", "확인");
return;
}
GenerateThumbnails(propsToGenerate, $"선택한 {propsToGenerate.Count}개 프랍");
}
private void GenerateAllThumbnails()
{
if (!EditorUtility.DisplayDialog("확인",
$"모든 프랍 ({_database.props.Count}개)의 썸네일을 생성합니다.\n계속하시겠습니까?", "생성", "취소")) return;
GenerateThumbnails(_database.props, "모든 프랍");
}
private void GenerateThumbnails(List<PropInfo> props, string description)
{
int successCount = 0, failCount = 0;
try
{
for (int i = 0; i < props.Count; i++)
{
EditorUtility.DisplayProgressBar("썸네일 생성 중",
$"{props[i].propName} ({i + 1}/{props.Count})", (float)i / props.Count);
if (GenerateThumbnail(props[i])) successCount++;
else failCount++;
}
}
finally { EditorUtility.ClearProgressBar(); }
EditorUtility.SetDirty(_database);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
ClearThumbnailCache();
RebuildContent();
EditorUtility.DisplayDialog("완료",
$"{description} 썸네일 생성 완료\n성공: {successCount}개\n실패: {failCount}개", "확인");
}
private bool GenerateThumbnail(PropInfo propInfo)
{
if (propInfo.prefabPaths.Count == 0)
{
Debug.LogWarning($"[PropBrowser] {propInfo.propName}: 프리펩이 없어 썸네일을 생성할 수 없습니다.");
return false;
}
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(propInfo.MainPrefabPath);
if (prefab == null)
{
Debug.LogWarning($"[PropBrowser] {propInfo.propName}: 프리펩을 로드할 수 없습니다.");
return false;
}
string thumbnailFolderPath = $"{propInfo.folderPath}/{THUMBNAIL_FOLDER}";
string thumbnailFileName = $"{propInfo.propName}_thumbnail.png";
string thumbnailPath = $"{thumbnailFolderPath}/{thumbnailFileName}";
if (!AssetDatabase.IsValidFolder(thumbnailFolderPath))
AssetDatabase.CreateFolder(propInfo.folderPath, THUMBNAIL_FOLDER);
var previewTexture = GeneratePreviewTexture(prefab);
if (previewTexture == null)
{
Debug.LogWarning($"[PropBrowser] {propInfo.propName}: 미리보기를 생성할 수 없습니다.");
return false;
}
try
{
byte[] pngData = previewTexture.EncodeToPNG();
string fullPath = thumbnailPath.Replace("Assets/", Application.dataPath + "/");
string directory = Path.GetDirectoryName(fullPath);
if (!Directory.Exists(directory)) Directory.CreateDirectory(directory);
File.WriteAllBytes(fullPath, pngData);
DestroyImmediate(previewTexture);
AssetDatabase.ImportAsset(thumbnailPath);
propInfo.thumbnailPath = thumbnailPath;
Debug.Log($"[PropBrowser] {propInfo.propName}: 썸네일 생성 완료");
return true;
}
catch (Exception ex)
{
Debug.LogError($"[PropBrowser] {propInfo.propName}: 썸네일 저장 실패 - {ex.Message}");
return false;
}
}
private Texture2D GeneratePreviewTexture(GameObject prefab)
{
var tempInstance = PrefabUtility.InstantiatePrefab(prefab) as GameObject;
if (tempInstance == null) tempInstance = Instantiate(prefab);
Vector3 capturePosition = new Vector3(10000f, 10000f, 10000f);
tempInstance.transform.position = capturePosition;
tempInstance.name = "[ThumbnailCapture_Temp]";
try
{
Bounds bounds = CalculateTotalBounds(tempInstance);
if (bounds.size == Vector3.zero)
{
DestroyImmediate(tempInstance);
return null;
}
var cameraGo = new GameObject("[ThumbnailCamera_Temp]");
var camera = cameraGo.AddComponent<Camera>();
camera.backgroundColor = new Color(0, 0, 0, 0);
camera.clearFlags = CameraClearFlags.SolidColor;
camera.orthographic = false;
camera.fieldOfView = 35f;
camera.nearClipPlane = 0.01f;
camera.farClipPlane = 10000f;
PositionCameraToFitBounds(camera, bounds);
var rt = new RenderTexture(THUMBNAIL_CAPTURE_SIZE, THUMBNAIL_CAPTURE_SIZE, 24, RenderTextureFormat.ARGB32);
rt.antiAliasing = 4;
camera.targetTexture = rt;
var lightContainer = new GameObject("[ThumbnailLights_Temp]");
var keyLightGo = new GameObject("[KeyLight]");
keyLightGo.transform.SetParent(lightContainer.transform);
var keyLight = keyLightGo.AddComponent<Light>();
keyLight.type = LightType.Directional;
keyLight.transform.rotation = Quaternion.Euler(35, -25, 0);
keyLight.intensity = 2.0f;
keyLight.color = new Color(1f, 0.98f, 0.96f);
var fillLightGo = new GameObject("[FillLight]");
fillLightGo.transform.SetParent(lightContainer.transform);
var fillLight = fillLightGo.AddComponent<Light>();
fillLight.type = LightType.Directional;
fillLight.transform.rotation = Quaternion.Euler(20, 160, 0);
fillLight.intensity = 1.0f;
fillLight.color = new Color(0.9f, 0.92f, 1f);
var rimLightGo = new GameObject("[RimLight]");
rimLightGo.transform.SetParent(lightContainer.transform);
var rimLight = rimLightGo.AddComponent<Light>();
rimLight.type = LightType.Directional;
rimLight.transform.rotation = Quaternion.Euler(10, 180, 0);
rimLight.intensity = 0.8f;
rimLight.color = Color.white;
camera.Render();
var texture = new Texture2D(THUMBNAIL_CAPTURE_SIZE, THUMBNAIL_CAPTURE_SIZE, TextureFormat.RGBA32, false);
RenderTexture.active = rt;
texture.ReadPixels(new Rect(0, 0, THUMBNAIL_CAPTURE_SIZE, THUMBNAIL_CAPTURE_SIZE), 0, 0);
texture.Apply();
RenderTexture.active = null;
DestroyImmediate(lightContainer);
DestroyImmediate(cameraGo);
DestroyImmediate(rt);
DestroyImmediate(tempInstance);
return texture;
}
catch (Exception ex)
{
Debug.LogError($"[PropBrowser] GeneratePreviewTexture 오류: {ex.Message}");
DestroyImmediate(tempInstance);
return null;
}
}
private Bounds CalculateTotalBounds(GameObject obj)
{
var renderers = obj.GetComponentsInChildren<Renderer>(true);
var meshFilters = obj.GetComponentsInChildren<MeshFilter>(true);
Bounds bounds = new Bounds(obj.transform.position, Vector3.zero);
bool hasBounds = false;
foreach (var renderer in renderers)
{
if (renderer is ParticleSystemRenderer) continue;
if (!hasBounds)
{
bounds = renderer.bounds;
hasBounds = true;
}
else bounds.Encapsulate(renderer.bounds);
}
if (!hasBounds)
{
foreach (var mf in meshFilters)
{
if (mf.sharedMesh == null) continue;
var meshBounds = mf.sharedMesh.bounds;
var worldBounds = new Bounds(
mf.transform.TransformPoint(meshBounds.center),
Vector3.Scale(meshBounds.size, mf.transform.lossyScale));
if (!hasBounds)
{
bounds = worldBounds;
hasBounds = true;
}
else bounds.Encapsulate(worldBounds);
}
}
return bounds;
}
private void PositionCameraToFitBounds(Camera camera, Bounds bounds)
{
Vector3 viewDirection = new Vector3(0.3f, 0.4f, 1f).normalized;
float boundsSphereRadius = bounds.extents.magnitude;
float fov = camera.fieldOfView * Mathf.Deg2Rad;
float distance = boundsSphereRadius / Mathf.Sin(fov / 2f);
distance *= 1.3f;
camera.transform.position = bounds.center + viewDirection * distance;
camera.transform.LookAt(bounds.center);
camera.nearClipPlane = Mathf.Max(0.01f, distance - boundsSphereRadius * 2f);
camera.farClipPlane = distance + boundsSphereRadius * 2f;
}
// ─── 웹 업로드 ───
private void ShowWebUploadMenu()
{
var menu = new GenericMenu();
menu.AddItem(new GUIContent("웹사이트에 업로드"), false, UploadToWebsite);
menu.AddItem(new GUIContent("업로드 설정 열기"), false, () => WebsitePropExporter.ShowWindow());
menu.ShowAsContext();
}
private void UploadToWebsite()
{
if (_database == null || _database.props.Count == 0)
{
EditorUtility.DisplayDialog("오류", "업로드할 프랍이 없습니다.", "확인");
return;
}
var exporter = GetWindow<WebsitePropExporter>("프랍 업로드");
exporter.UploadToWebsite();
}
}
}