using System; using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEngine; namespace Chameleon.Editor.PolyHaven { /// /// Editor window: paste a Poly Haven asset URL, fetch its maps, pick resolution + /// maps, then download and auto-build a URP/Lit material under Assets/Poly Haven/{Name}/. /// No browser — asset selection stays on polyhaven.com. /// public sealed class PolyHavenImporterWindow : EditorWindow { private const string RootFolder = "Assets/Poly Haven"; private const string DefaultRes = "2k"; private string _urlInput = "https://polyhaven.com/a/"; private PolyHavenInfo _info; private PolyHavenFiles _files; private Texture2D _thumbnail; private string _selectedRes = DefaultRes; private readonly Dictionary _mapEnabled = new Dictionary(); private bool _busy; private string _status = ""; private Vector2 _scroll; // ---- Browser ---------------------------------------------------------- private const int MaxResults = 150; private const int MaxThumbInFlight = 6; // Catalog cached for the whole session (survives window close, cleared on domain reload). private static List s_catalog; private bool _browseFoldout = true; private bool _autoLoadTried; private bool _catalogLoading; private string _searchQuery = ""; private readonly List _filtered = new List(); private Vector2 _gridScroll; private readonly Dictionary _thumbs = new Dictionary(); private readonly HashSet _thumbRequested = new HashSet(); private int _thumbsInFlight; [MenuItem("Tools/Poly Haven Importer")] public static void Open() { var w = GetWindow("Poly Haven Importer"); w.minSize = new Vector2(440, 480); } private void OnEnable() { // Catalog may already be cached from a previous window instance this session. if (s_catalog != null) { _autoLoadTried = true; ApplyFilter(); } } private void OnGUI() { _scroll = EditorGUILayout.BeginScrollView(_scroll); EditorGUILayout.LabelField("Poly Haven Importer", EditorStyles.boldLabel); EditorGUILayout.HelpBox( "Pick a texture on polyhaven.com, paste its URL here (e.g. .../a/broken_brick_wall), " + "Fetch, choose resolution + maps, then Download. A URP/Lit material is built automatically.", MessageType.Info); EditorGUILayout.Space(); DrawBrowser(); EditorGUILayout.Space(); using (new EditorGUI.DisabledScope(_busy)) { EditorGUILayout.LabelField("Asset URL or slug", EditorStyles.miniBoldLabel); using (new EditorGUILayout.HorizontalScope()) { _urlInput = EditorGUILayout.TextField(_urlInput); if (GUILayout.Button("Fetch", GUILayout.Width(70))) Fetch(); } } if (_info != null) DrawAssetSection(); EditorGUILayout.Space(); if (!string.IsNullOrEmpty(_status)) EditorGUILayout.HelpBox(_status, _busy ? MessageType.None : MessageType.Info); EditorGUILayout.EndScrollView(); } private void DrawAssetSection() { EditorGUILayout.Space(); using (new EditorGUILayout.HorizontalScope()) { if (_thumbnail) { var r = GUILayoutUtility.GetRect(96, 96, GUILayout.Width(96), GUILayout.Height(96)); GUI.DrawTexture(r, _thumbnail, ScaleMode.ScaleToFit); } using (new EditorGUILayout.VerticalScope()) { EditorGUILayout.LabelField(_info.Name, EditorStyles.boldLabel); EditorGUILayout.LabelField($"Slug: {_info.Slug}", EditorStyles.miniLabel); if (_info.Categories != null && _info.Categories.Length > 0) EditorGUILayout.LabelField($"Categories: {string.Join(", ", _info.Categories)}", EditorStyles.miniLabel); if (_info.Type != 1) EditorGUILayout.HelpBox("This asset is not a texture — only textures are supported.", MessageType.Warning); } } if (_files == null || _files.Resolutions.Count == 0) return; EditorGUILayout.Space(); // Resolution popup. var resArray = _files.Resolutions.ToArray(); int resIdx = Mathf.Max(0, System.Array.IndexOf(resArray, _selectedRes)); resIdx = EditorGUILayout.Popup("Resolution", resIdx, resArray.Select(r => r.ToUpperInvariant()).ToArray()); _selectedRes = resArray[resIdx]; EditorGUILayout.Space(); EditorGUILayout.LabelField("Maps", EditorStyles.miniBoldLabel); foreach (var meta in PolyHavenMapInfo.All) { if (!_files.Has(meta.Type)) continue; bool cur = _mapEnabled.TryGetValue(meta.Type, out var v) ? v : meta.DefaultEnabled; _mapEnabled[meta.Type] = EditorGUILayout.ToggleLeft(meta.Label, cur); } EditorGUILayout.Space(); bool canDownload = !_busy && _info.Type == 1 && AnyMapSelected(); using (new EditorGUI.DisabledScope(!canDownload)) { if (GUILayout.Button("Download / Import", GUILayout.Height(30))) Download(); } } private bool AnyMapSelected() { foreach (var meta in PolyHavenMapInfo.All) if (_files.Has(meta.Type) && _mapEnabled.TryGetValue(meta.Type, out var v) && v) return true; // Fall back to defaults if the user hasn't toggled anything yet. foreach (var meta in PolyHavenMapInfo.All) if (_files.Has(meta.Type) && meta.DefaultEnabled && !_mapEnabled.ContainsKey(meta.Type)) return true; return false; } // ---- Browser ----------------------------------------------------------- private void DrawBrowser() { _browseFoldout = EditorGUILayout.Foldout(_browseFoldout, "Browse catalog", true, EditorStyles.foldoutHeader); if (!_browseFoldout) return; using (new EditorGUI.DisabledScope(_busy)) { if (s_catalog == null) { // Auto-load once when the browser is first opened; still offer a manual retry. if (!_catalogLoading && !_autoLoadTried) { _autoLoadTried = true; LoadCatalog(); } using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField(_catalogLoading ? "Loading texture catalog…" : "Catalog not loaded."); using (new EditorGUI.DisabledScope(_catalogLoading)) if (GUILayout.Button("Load catalog", GUILayout.Width(110))) LoadCatalog(); } return; } using (new EditorGUILayout.HorizontalScope()) { EditorGUI.BeginChangeCheck(); _searchQuery = EditorGUILayout.TextField("Search", _searchQuery); if (EditorGUI.EndChangeCheck()) ApplyFilter(); if (GUILayout.Button("↻", GUILayout.Width(26))) LoadCatalog(); } int shown = Mathf.Min(_filtered.Count, MaxResults); string cap = _filtered.Count > MaxResults ? $", showing first {MaxResults}" : ""; EditorGUILayout.LabelField( $"{_filtered.Count} match(es){cap} of {s_catalog.Count} textures.", EditorStyles.miniLabel); DrawGrid(shown); } } private void DrawGrid(int count) { const float cell = 104f; const float pad = 6f; float viewW = EditorGUIUtility.currentViewWidth - 24f; int cols = Mathf.Max(1, Mathf.FloorToInt((viewW + pad) / (cell + pad))); _gridScroll = EditorGUILayout.BeginScrollView(_gridScroll, GUILayout.Height(320)); int rows = Mathf.CeilToInt(count / (float)cols); for (int row = 0; row < rows; row++) { using (new EditorGUILayout.HorizontalScope()) { for (int c = 0; c < cols; c++) { int i = row * cols + c; if (i >= count) { GUILayout.FlexibleSpace(); break; } DrawCell(_filtered[i], cell); } } } EditorGUILayout.EndScrollView(); } private void DrawCell(PolyHavenAsset a, float size) { using (new EditorGUILayout.VerticalScope(GUILayout.Width(size))) { var thumbRect = GUILayoutUtility.GetRect(size, size - 22f, GUILayout.Width(size), GUILayout.Height(size - 22f)); var tex = GetThumb(a); if (tex) GUI.DrawTexture(thumbRect, tex, ScaleMode.ScaleToFit); else EditorGUI.DrawRect(thumbRect, new Color(0f, 0f, 0f, 0.15f)); // Transparent hit target over the thumbnail. if (GUI.Button(thumbRect, new GUIContent("", a.Name), GUIStyle.none)) SelectAsset(a); EditorGUILayout.LabelField(new GUIContent(a.Name, a.Name), EditorStyles.miniLabel, GUILayout.Width(size)); } } /// Lazy, throttled thumbnail loader — only visible cells request, capped in-flight. private Texture2D GetThumb(PolyHavenAsset a) { if (_thumbs.TryGetValue(a.Slug, out var tex)) { if (tex) return tex; // Cached texture was destroyed (e.g. play-mode cleanup) — purge and re-request. _thumbs.Remove(a.Slug); _thumbRequested.Remove(a.Slug); } // Don't add to _thumbRequested until a slot is free, so it retries next repaint. if (!_thumbRequested.Contains(a.Slug) && _thumbsInFlight < MaxThumbInFlight) { _thumbRequested.Add(a.Slug); _thumbsInFlight++; PolyHavenApi.FetchThumbnail(a.ThumbnailUrl, t => { _thumbs[a.Slug] = t; _thumbsInFlight--; Repaint(); }, _ => { _thumbsInFlight--; Repaint(); }); // keep as requested: no retry-spam on error } return null; } private void SelectAsset(PolyHavenAsset a) { _urlInput = "https://polyhaven.com/a/" + a.Slug; GUI.FocusControl(null); Fetch(); // url filled -> fetch, then everything as before } private void LoadCatalog() { _catalogLoading = true; _status = "Loading Poly Haven texture catalog…"; Repaint(); PolyHavenApi.FetchAssets("textures", list => { s_catalog = list; _catalogLoading = false; ApplyFilter(); _status = $"Catalog loaded: {list.Count} textures."; Repaint(); }, err => { _catalogLoading = false; _status = "Catalog error: " + err; Debug.LogError("[PolyHaven] " + err); Repaint(); }); } private void ApplyFilter() { _filtered.Clear(); if (s_catalog == null) return; var q = _searchQuery?.Trim().ToLowerInvariant(); if (string.IsNullOrEmpty(q)) { _filtered.AddRange(s_catalog); return; } var terms = q.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); foreach (var a in s_catalog) { bool all = true; foreach (var t in terms) if (a.Search.IndexOf(t, StringComparison.Ordinal) < 0) { all = false; break; } if (all) _filtered.Add(a); } } // ---- Fetch ------------------------------------------------------------- private void Fetch() { string slug = PolyHavenApi.ExtractSlug(_urlInput); if (string.IsNullOrEmpty(slug)) { EditorUtility.DisplayDialog("Poly Haven", "Could not read a slug from the URL.", "OK"); return; } _busy = true; _status = $"Fetching '{slug}'…"; _info = null; _files = null; _thumbnail = null; _mapEnabled.Clear(); Repaint(); PolyHavenApi.FetchInfo(slug, info => { _info = info; Repaint(); PolyHavenApi.FetchThumbnail(info.ThumbnailUrl, tex => { _thumbnail = tex; Repaint(); }, _ => { }); PolyHavenApi.FetchFiles(slug, files => { _files = files; if (files.Resolutions.Contains(DefaultRes)) _selectedRes = DefaultRes; else if (files.Resolutions.Count > 0) _selectedRes = files.Resolutions[Mathf.Min(1, files.Resolutions.Count - 1)]; _busy = false; _status = info.Type == 1 ? $"Ready: {files.Maps.Count} map type(s), {files.Resolutions.Count} resolution(s)." : "Fetched, but this asset is not a texture."; Repaint(); }, err => Fail(err)); }, err => Fail(err)); } // ---- Download ---------------------------------------------------------- private void Download() { // Resolve which maps to fetch. var wanted = new List(); foreach (var meta in PolyHavenMapInfo.All) { if (!_files.Has(meta.Type)) continue; bool on = _mapEnabled.TryGetValue(meta.Type, out var v) ? v : meta.DefaultEnabled; if (on) wanted.Add(meta.Type); } // ARM fallback: if the asset packs AO/Rough/Metal only into ARM (no dedicated // map for a channel), pull ARM so the builder can unpack the missing channels. if (_files.Has(PolyHavenMapType.Arm) && !wanted.Contains(PolyHavenMapType.Arm)) { bool channelMissing = !_files.Has(PolyHavenMapType.Metal) || !_files.Has(PolyHavenMapType.Rough) || !_files.Has(PolyHavenMapType.AO); if (channelMissing) wanted.Add(PolyHavenMapType.Arm); } if (wanted.Count == 0) return; string displayName = _info.Name; string folder = $"{RootFolder}/{PolyHavenMaterialBuilder.SanitizeFileName(displayName)}"; // Folder-collision handling (non-destructive default). if (AssetDatabase.IsValidFolder(folder)) { int choice = EditorUtility.DisplayDialogComplex("Poly Haven", $"Folder already exists:\n{folder}", "New folder", "Cancel", "Overwrite"); if (choice == 1) return; // Cancel if (choice == 0) // New folder folder = AssetDatabase.GenerateUniqueAssetPath(folder); // choice == 2 => Overwrite: reuse folder as-is. } EnsureFolder(folder); int maxSize = ResolutionToMaxSize(_selectedRes); _busy = true; _status = $"Downloading {wanted.Count} map(s) at {_selectedRes.ToUpperInvariant()}…"; Repaint(); var results = new List(); var failures = new List(); int remaining = wanted.Count; AssetDatabase.StartAssetEditing(); void FinishOne() { remaining--; if (remaining > 0) return; AssetDatabase.StopAssetEditing(); AssetDatabase.Refresh(); if (results.Count == 0) { _busy = false; _status = "Download failed — no maps saved."; if (failures.Count > 0) Debug.LogError("[PolyHaven] " + string.Join("; ", failures)); Repaint(); return; } PolyHavenMaterialBuilder.Build(folder, displayName, _info.Slug, maxSize, results); _busy = false; _status = $"Done: {results.Count} map(s) imported, material built in {folder}."; if (failures.Count > 0) _status += $" ({failures.Count} map(s) failed — see console.)"; Repaint(); } foreach (var type in wanted) { if (!_files.TryGetWithFallback(type, _selectedRes, out var file, out var usedRes)) { failures.Add($"{type}: no file"); FinishOne(); continue; } if (usedRes != _selectedRes) Debug.LogWarning($"[PolyHaven] {type}: {_selectedRes} unavailable, using {usedRes}."); var meta = PolyHavenMapInfo.Get(type); string fileName = $"{_info.Slug}_{meta.Suffix}.{file.Format}"; string destPath = $"{folder}/{fileName}"; PolyHavenApi.DownloadFile(file, Path.GetFullPath(destPath), onDone: () => { results.Add(new PolyHavenMaterialBuilder.Downloaded { Type = type, AssetPath = destPath }); FinishOne(); }, onError: err => { failures.Add($"{type}: {err}"); FinishOne(); }); } } // ---- Helpers ----------------------------------------------------------- private void Fail(string message) { _busy = false; _status = "Error: " + message; Debug.LogError("[PolyHaven] " + message); Repaint(); } private static int ResolutionToMaxSize(string res) { int k = PolyHavenFiles.ResolutionRank(res); int px = k * 1024; // Clamp to a valid Unity max texture size (power of two up to 16384). int[] valid = { 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384 }; foreach (var v in valid) if (px <= v) return v; return 16384; } private static void EnsureFolder(string folder) { if (AssetDatabase.IsValidFolder(folder)) return; var parts = folder.Split('/'); string cur = parts[0]; // "Assets" for (int i = 1; i < parts.Length; i++) { string next = $"{cur}/{parts[i]}"; if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(cur, parts[i]); cur = next; } } } }