// 혜안(HyeAn) — 기획서 문장으로 게임 에셋을 검색하는 Unity 에디터 확장 // 사용법: 이 파일을 Unity 프로젝트의 Assets/Editor/ 폴더에 복사 → 메뉴 Window > HyeAn > Asset Matcher // 백엔드: 혜안 클라우드 (기본 https://hyean.14dimension.com) — 자체 호스팅 시 서버 URL만 바꾸면 됨 using System; using System.Collections.Generic; using UnityEditor; using UnityEngine; using UnityEngine.Networking; using UnityEngine.UI; namespace HyeAn { public class HyeAnMatchWindow : EditorWindow { private string serverUrl = "https://hyean.14dimension.com"; private string apiKey = ""; // 인덱싱·매칭 모두 이 키가 "내 인덱스"의 식별자 private string query = ""; private List indexFolders = new List { "Assets" }; // 여러 폴더 지원 private List excludeFolders = new List(); // 제외 폴더 private bool showIndexing = false; private string indexStatus = ""; private MatchResponse result; private List previews = new List(); private bool searching; private string status = ""; private Vector2 scroll; private int selectedIndex = -1; // 유저의 현재 선택 (기본 = AI 추천) [MenuItem("Window/HyeAn/Asset Matcher")] public static void Open() { var w = GetWindow("혜안 Asset Matcher"); w.minSize = new Vector2(560, 420); } private void OnGUI() { EditorGUILayout.Space(6); serverUrl = EditorGUILayout.TextField("서버 URL", serverUrl); apiKey = EditorGUILayout.TextField("API 키", apiKey); // ---- 내 프로젝트 인덱싱 (원본은 전송되지 않음 — 256px 썸네일만) ---- showIndexing = EditorGUILayout.Foldout(showIndexing, "내 프로젝트 인덱싱", true); if (showIndexing) { EditorGUILayout.HelpBox("지정한 폴더들의 이미지를 하위 폴더까지 재귀적으로 등록합니다.\n" + "원본 파일은 서버로 가지 않습니다 — 축소 썸네일(장당 ~8KB)만 전송됩니다.", MessageType.Info); // 포함 폴더 목록 EditorGUILayout.LabelField("포함 폴더 (하위 폴더 자동 포함)", EditorStyles.boldLabel); DrawFolderList(indexFolders, "폴더 추가"); // 제외 폴더 목록 EditorGUILayout.LabelField("제외 폴더 (선택)", EditorStyles.boldLabel); DrawFolderList(excludeFolders, "제외 폴더 추가"); EditorGUILayout.BeginHorizontal(); using (new EditorGUI.DisabledScope(string.IsNullOrEmpty(apiKey) || indexFolders.Count == 0)) { if (GUILayout.Button("인덱싱 시작")) IndexFolders(); if (GUILayout.Button("상태 확인", GUILayout.Width(90))) CheckIndexStatus(); } EditorGUILayout.EndHorizontal(); if (string.IsNullOrEmpty(apiKey)) EditorGUILayout.LabelField("API 키를 먼저 입력하세요 (키 = 내 인덱스 식별자)", EditorStyles.miniLabel); if (!string.IsNullOrEmpty(indexStatus)) EditorGUILayout.LabelField(indexStatus, EditorStyles.wordWrappedMiniLabel); } EditorGUILayout.Space(4); EditorGUILayout.LabelField("GDD 텍스트 (한국어)", EditorStyles.boldLabel); query = EditorGUILayout.TextArea(query, GUILayout.MinHeight(52)); using (new EditorGUI.DisabledScope(searching || string.IsNullOrWhiteSpace(query))) { if (GUILayout.Button(searching ? "검색 중..." : "에셋 매칭", GUILayout.Height(30))) Search(); } if (!string.IsNullOrEmpty(status)) EditorGUILayout.HelpBox(status, MessageType.Info); if (result?.candidates == null) return; scroll = EditorGUILayout.BeginScrollView(scroll); EditorGUILayout.Space(4); EditorGUILayout.LabelField($"AI 추천(★): {SafeName(result.winner_index)} | 현재 선택: {SafeName(selectedIndex)}", EditorStyles.boldLabel); if (!string.IsNullOrEmpty(result.reason)) EditorGUILayout.LabelField(result.reason, EditorStyles.wordWrappedMiniLabel); EditorGUILayout.BeginHorizontal(); for (int i = 0; i < result.candidates.Length; i++) DrawCandidate(i); EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(6); bool overridden = selectedIndex != result.winner_index; if (overridden) EditorGUILayout.HelpBox("AI 추천과 다른 선택 — 승인 시 교정 데이터로 서버에 기록됩니다.", MessageType.Warning); if (GUILayout.Button("승인 및 적용", GUILayout.Height(32))) { var c = result.candidates[selectedIndex]; ApplyToSelection(c, selectedIndex < previews.Count ? previews[selectedIndex] : null); SendFeedback(overridden); } EditorGUILayout.EndScrollView(); } private string SafeName(int idx) { if (result?.candidates == null || idx < 0 || idx >= result.candidates.Length) return "-"; return result.candidates[idx].name_ko; } private void DrawCandidate(int i) { var c = result.candidates[i]; bool isWinner = (i == result.winner_index); bool isSelected = (i == selectedIndex); EditorGUILayout.BeginVertical(GUI.skin.box, GUILayout.Width(100)); var old = GUI.backgroundColor; if (isSelected) GUI.backgroundColor = new Color(0.35f, 0.7f, 1f); // 선택 = 파랑 else if (isWinner) GUI.backgroundColor = new Color(0.4f, 1f, 0.5f); // AI 추천 = 초록 var tex = (i < previews.Count) ? previews[i] : null; GUILayout.Label(tex, GUILayout.Width(88), GUILayout.Height(88)); GUI.backgroundColor = old; EditorGUILayout.LabelField(isWinner ? "★ " + c.name_ko : c.name_ko, EditorStyles.wordWrappedMiniLabel, GUILayout.Width(92)); EditorGUILayout.LabelField(c.layout_rule_preset, EditorStyles.centeredGreyMiniLabel, GUILayout.Width(92)); using (new EditorGUI.DisabledScope(isSelected)) { if (GUILayout.Button(isSelected ? "선택됨" : "선택", GUILayout.Width(92))) selectedIndex = i; } EditorGUILayout.EndVertical(); } private void SendFeedback(bool overridden) { var fb = new FeedbackRequest { query = query, candidates = new string[result.candidates.Length], ai_winner = result.candidates[result.winner_index].file_path, user_choice = result.candidates[selectedIndex].file_path, overridden = overridden, layout_rule_preset = result.candidates[selectedIndex].layout_rule_preset, client = "unity-editor", }; for (int i = 0; i < result.candidates.Length; i++) fb.candidates[i] = result.candidates[i].file_path; var req = new UnityWebRequest(serverUrl.TrimEnd('/') + "/feedback", "POST"); req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(JsonUtility.ToJson(fb))); req.downloadHandler = new DownloadHandlerBuffer(); req.SetRequestHeader("Content-Type", "application/json"); if (!string.IsNullOrEmpty(apiKey)) req.SetRequestHeader("X-API-Key", apiKey); req.timeout = 15; var op = req.SendWebRequest(); bool wasOverridden = overridden; op.completed += _ => { status = req.result == UnityWebRequest.Result.Success ? (wasOverridden ? "적용 완료 — 교정 데이터 전송됨 (학습 재료로 축적)" : "적용 완료 — 승인 기록됨") : "적용 완료 — 피드백 전송 실패: " + req.error; req.Dispose(); Repaint(); }; } // 선택된 GameObject의 Image에 스프라이트 할당 + 레이아웃 프리셋 적용 private void ApplyToSelection(Candidate c, Texture2D tex) { var go = Selection.activeGameObject; if (go == null) { status = "적용할 GameObject를 씬에서 선택하세요."; return; } var image = go.GetComponent(); var rt = go.GetComponent(); if (rt == null) { status = "선택 객체에 RectTransform이 없습니다 (UI 오브젝트 필요)."; return; } Undo.RecordObject(rt, "HyeAn Apply Preset"); switch (c.layout_rule_preset) { case "Center_Popup": rt.anchorMin = rt.anchorMax = rt.pivot = new Vector2(0.5f, 0.5f); rt.anchoredPosition = Vector2.zero; break; case "Top_Right_Corner": rt.anchorMin = rt.anchorMax = rt.pivot = new Vector2(1f, 1f); rt.anchoredPosition = new Vector2(-20f, -20f); break; case "Fill_Parent": rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.offsetMin = rt.offsetMax = Vector2.zero; break; // Grid_Slot: 부모 GridLayoutGroup이 크기를 지배하므로 앵커를 건드리지 않는다 } // 프로젝트 내 동일 파일명 에셋을 찾아 할당, 없으면 서버 프리뷰 텍스처로 임시 스프라이트 생성 if (image != null) { Undo.RecordObject(image, "HyeAn Assign Sprite"); var fileName = System.IO.Path.GetFileNameWithoutExtension(c.file_path); var guids = AssetDatabase.FindAssets(fileName + " t:Sprite"); Sprite sprite = null; if (guids.Length > 0) { var path = AssetDatabase.GUIDToAssetPath(guids[0]); sprite = AssetDatabase.LoadAssetAtPath(path); // Auto-Slicing: border가 정의된 스프라이트는 Sliced 강제 if (sprite != null && sprite.border != Vector4.zero) image.type = Image.Type.Sliced; } if (sprite == null && tex != null) sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f)); if (sprite != null) image.sprite = sprite; status = guids.Length > 0 ? "프로젝트 에셋 할당 + 프리셋 적용 완료" : "프로젝트에 동일 에셋 없음 — 프리뷰로 임시 할당 (원본: " + c.file_path + ")"; } else { status = "프리셋 적용 완료 (Image 컴포넌트 없음 — 스프라이트 할당 생략)"; } EditorUtility.SetDirty(go); } private void Search() { searching = true; status = ""; result = null; previews.Clear(); var body = JsonUtility.ToJson(new MatchRequest { query = query, top_k = 5 }); var req = new UnityWebRequest(serverUrl.TrimEnd('/') + "/match", "POST"); req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(body)); req.downloadHandler = new DownloadHandlerBuffer(); req.SetRequestHeader("Content-Type", "application/json"); if (!string.IsNullOrEmpty(apiKey)) req.SetRequestHeader("X-API-Key", apiKey); req.timeout = 60; var op = req.SendWebRequest(); op.completed += _ => { searching = false; try { if (req.result != UnityWebRequest.Result.Success) { status = "서버 오류: " + req.error + " — server/main.py가 실행 중인지 확인하세요."; return; } result = JsonUtility.FromJson(req.downloadHandler.text); selectedIndex = result.winner_index; // 기본 선택 = AI 추천 if (result.match_quality == "none") status = "⚠ 기획 의도와 어울리는 에셋이 없습니다 — 최유사 후보만 표시합니다."; foreach (var c in result.candidates) { Texture2D tex = null; if (!string.IsNullOrEmpty(c.preview_b64)) { tex = new Texture2D(2, 2); tex.LoadImage(Convert.FromBase64String(c.preview_b64)); } previews.Add(tex); } status = $"완료 — 후보 {result.candidates.Length}개 (재랭킹: {result.rerank_model})"; } catch (Exception e) { status = "응답 파싱 실패: " + e.Message; } finally { req.Dispose(); Repaint(); } }; } // ---- 폴더 목록 UI (추가/제거) ---- private void DrawFolderList(List list, string addLabel) { int removeAt = -1; for (int i = 0; i < list.Count; i++) { EditorGUILayout.BeginHorizontal(); list[i] = EditorGUILayout.TextField(list[i]); if (GUILayout.Button("찾기", GUILayout.Width(48))) { var abs = EditorUtility.OpenFolderPanel("폴더 선택", list[i], ""); if (!string.IsNullOrEmpty(abs)) list[i] = ToProjectRelative(abs); } if (GUILayout.Button("×", GUILayout.Width(24))) removeAt = i; EditorGUILayout.EndHorizontal(); } if (removeAt >= 0) list.RemoveAt(removeAt); if (GUILayout.Button(addLabel)) list.Add("Assets"); } // 절대 경로를 프로젝트 상대 경로로 (Assets/... 형태 유지, 아니면 절대경로 그대로) private string ToProjectRelative(string abs) { abs = abs.Replace("\\", "/"); var root = System.IO.Path.GetDirectoryName(Application.dataPath).Replace("\\", "/") + "/"; return abs.StartsWith(root) ? abs.Substring(root.Length) : abs; } // ---- 인덱싱: 여러 폴더 재귀 스캔 → 제외 폴더 필터 → 썸네일 → 배치 업로드 ---- private void IndexFolders() { var images = new List(); var seen = new HashSet(); var excludes = new List(); foreach (var ex in excludeFolders) if (!string.IsNullOrWhiteSpace(ex)) excludes.Add(ex.Replace("\\", "/").TrimEnd('/') + "/"); foreach (var folder in indexFolders) { if (string.IsNullOrWhiteSpace(folder) || !System.IO.Directory.Exists(folder)) continue; foreach (var f in System.IO.Directory.GetFiles(folder, "*.*", System.IO.SearchOption.AllDirectories)) { var ext = System.IO.Path.GetExtension(f).ToLowerInvariant(); if (ext != ".png" && ext != ".jpg" && ext != ".jpeg") continue; var norm = f.Replace("\\", "/"); if (excludes.Exists(e => norm.Contains("/" + e) || norm.StartsWith(e))) continue; if (seen.Add(norm)) images.Add(f); // 폴더 중첩 시 중복 제거 } } if (images.Count == 0) { indexStatus = "이미지 없음 (포함 폴더/제외 설정 확인)"; return; } // ── 증분 동기화 1단계: 서버와 diff — 새 파일만 올리고, 사라진 파일은 정리 ── var allPaths = new string[images.Count]; for (int i = 0; i < images.Count; i++) allPaths[i] = images[i].Replace("\\", "/"); var diffJson = JsonUtility.ToJson(new SyncPaths { paths = allPaths }); if (!PostBlockingGet("/index/diff", diffJson, out var diffBody, out var derr)) { indexStatus = "동기화 확인 실패: " + derr; return; } var diff = JsonUtility.FromJson(diffBody); var newPaths = diff.@new ?? new string[0]; int removed = 0; if (diff.removed != null && diff.removed.Length > 0) { if (PostBlockingGet("/index/prune", diffJson, out var pruneBody, out _)) removed = JsonUtility.FromJson(pruneBody).removed; } if (newPaths.Length == 0) { indexStatus = $"변경 없음 — 기존 {diff.unchanged}개 유지" + (removed > 0 ? $", 삭제 파일 {removed}개 정리" : ""); return; } // ── 2단계: 새 파일만 썸네일 전송 ── const int BATCH = 32; int sent = 0, failed = 0; try { for (int b = 0; b < newPaths.Length; b += BATCH) { int n = Math.Min(BATCH, newPaths.Length - b); EditorUtility.DisplayProgressBar("혜안 인덱싱", $"새 파일 {b + n}/{newPaths.Length} 전송 중 (원본은 전송되지 않습니다)", (float)(b + n) / newPaths.Length); var reqObj = new IndexAddRequest { items = new IndexItem[n] }; for (int i = 0; i < n; i++) { var path = newPaths[b + i]; reqObj.items[i] = new IndexItem { path = path, thumb_b64 = MakeThumbB64(path) }; } if (!PostBlocking("/index/add", JsonUtility.ToJson(reqObj), out var err)) { failed += n; indexStatus = "배치 실패: " + err; } else sent += n; } } finally { EditorUtility.ClearProgressBar(); } indexStatus = $"새 파일 {sent}건 전송 (기존 {diff.unchanged}개 유지" + (removed > 0 ? $", 삭제 {removed}개 정리" : "") + $"{(failed > 0 ? $", 실패 {failed}" : "")}) — 서버 처리 중, '상태 확인'으로 진행률."; } // POST 후 응답 본문까지 받는 버전 private bool PostBlockingGet(string route, string json, out string body, out string error) { var req = new UnityWebRequest(serverUrl.TrimEnd('/') + route, "POST"); req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(json)); req.downloadHandler = new DownloadHandlerBuffer(); req.SetRequestHeader("Content-Type", "application/json"); if (!string.IsNullOrEmpty(apiKey)) req.SetRequestHeader("X-API-Key", apiKey); req.timeout = 120; var op = req.SendWebRequest(); while (!op.isDone) System.Threading.Thread.Sleep(50); body = req.downloadHandler.text; error = req.result == UnityWebRequest.Result.Success ? null : req.error + " " + body; req.Dispose(); return error == null; } private string MakeThumbB64(string path) { var bytes = System.IO.File.ReadAllBytes(path); var tex = new Texture2D(2, 2); tex.LoadImage(bytes); // 임포트 설정과 무관하게 원본 바이트에서 로드 int max = Mathf.Max(tex.width, tex.height); int w = tex.width, h = tex.height; if (max > 256) { w = tex.width * 256 / max; h = tex.height * 256 / max; } var rt = RenderTexture.GetTemporary(w, h); Graphics.Blit(tex, rt); var prev = RenderTexture.active; RenderTexture.active = rt; var small = new Texture2D(w, h, TextureFormat.RGB24, false); small.ReadPixels(new Rect(0, 0, w, h), 0, 0); small.Apply(); RenderTexture.active = prev; RenderTexture.ReleaseTemporary(rt); var jpg = small.EncodeToJPG(85); UnityEngine.Object.DestroyImmediate(tex); UnityEngine.Object.DestroyImmediate(small); return Convert.ToBase64String(jpg); } private bool PostBlocking(string route, string json, out string error) { var req = new UnityWebRequest(serverUrl.TrimEnd('/') + route, "POST"); req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(json)); req.downloadHandler = new DownloadHandlerBuffer(); req.SetRequestHeader("Content-Type", "application/json"); if (!string.IsNullOrEmpty(apiKey)) req.SetRequestHeader("X-API-Key", apiKey); req.timeout = 120; var op = req.SendWebRequest(); while (!op.isDone) System.Threading.Thread.Sleep(50); error = req.result == UnityWebRequest.Result.Success ? null : req.error + " " + req.downloadHandler.text; req.Dispose(); return error == null; } private void CheckIndexStatus() { var req = UnityWebRequest.Get(serverUrl.TrimEnd('/') + "/index/status"); if (!string.IsNullOrEmpty(apiKey)) req.SetRequestHeader("X-API-Key", apiKey); req.timeout = 15; var op = req.SendWebRequest(); op.completed += _ => { indexStatus = req.result == UnityWebRequest.Result.Success ? "인덱스 상태: " + req.downloadHandler.text : "상태 확인 실패: " + req.error; req.Dispose(); Repaint(); }; } [Serializable] private class IndexItem { public string path; public string thumb_b64; } [Serializable] private class IndexAddRequest { public IndexItem[] items; } [Serializable] private class SyncPaths { public string[] paths; } [Serializable] private class DiffResponse { public string[] @new; public string[] removed; public int unchanged; } [Serializable] private class PruneResponse { public int removed; public int indexed; } [Serializable] private class MatchRequest { public string query; public int top_k; } [Serializable] private class FeedbackRequest { public string query; public string[] candidates; public string ai_winner; public string user_choice; public bool overridden; public string layout_rule_preset; public string client; } [Serializable] private class MatchResponse { public int winner_index; public string match_quality; public string reason; public string rerank_model; public Candidate[] candidates; } [Serializable] private class Candidate { public string file_path; public string name_ko; public float score; public string layout_rule_preset; public string preview_b64; } } }