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 { /// /// 개별 프리펩 표시용 데이터 /// public class PrefabDisplayItem { public string prefabName; public string prefabPath; public string folderName; public PropInfo parentProp; public string thumbnailPath; public string DisplayName => prefabName; } /// /// 프랍 브라우저 에디터 윈도우 (UI Toolkit) /// 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 _filteredProps; private List _displayItems; private Dictionary _thumbnailCache = new Dictionary(); 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("프랍 브라우저"); window.minSize = new Vector2(400, 500); window.Show(); } public void CreateGUI() { var root = rootVisualElement; var uss = AssetDatabase.LoadAssetAtPath( "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(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 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(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(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(evt => { if (evt.clickCount == 2) { SelectPrefabInProject(item); evt.StopPropagation(); } }); // 드래그 앤 드롭 container.RegisterCallback(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(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(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(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(item.prefabPath); if (prefab != null) { Selection.activeObject = prefab; EditorGUIUtility.PingObject(prefab); return; } } if (item.parentProp != null) { var folder = AssetDatabase.LoadAssetAtPath(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(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(item.thumbnailPath); if (texture != null) _thumbnailCache[item.thumbnailPath] = texture; return texture; } private void ClearThumbnailCache() => _thumbnailCache.Clear(); // ─── 데이터베이스 ─── private void LoadOrCreateDatabase() { _database = AssetDatabase.LoadAssetAtPath(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(); 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(); _displayItems = new List(); return; } if (string.IsNullOrEmpty(_searchFilter)) _filteredProps = new List(_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(); 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(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(); 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(); 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 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(); 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 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(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.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(); 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(); 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(); 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(true); var meshFilters = obj.GetComponentsInChildren(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("프랍 업로드"); exporter.UploadToWebsite(); } } }