- BackgroundSceneLoaderWindow: OnGUI → CreateGUI (Toolbar + ToolbarSearchField) - PropBrowserWindow: OnGUI → CreateGUI (Toolbar + ToolbarSearchField) - StreamingleCommon.uss: 브라우저 공통 스타일 추가 (그리드/리스트/뷰토글/액션바/상태바) - excludeFromWeb 상태 새로고침 시 보존 수정 - 삭제된 배경 리소스 정리 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1078 lines
42 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|