diff --git a/Assets/Scripts/Editor/PolyHaven.meta b/Assets/Scripts/Editor/PolyHaven.meta
new file mode 100644
index 0000000..be711df
--- /dev/null
+++ b/Assets/Scripts/Editor/PolyHaven.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: e7186f7ca5e56f14382071f77388acaa
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenApi.cs b/Assets/Scripts/Editor/PolyHaven/PolyHavenApi.cs
new file mode 100644
index 0000000..a17a840
--- /dev/null
+++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenApi.cs
@@ -0,0 +1,345 @@
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json.Linq;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace Chameleon.Editor.PolyHaven
+{
+ /// One downloadable file variant (a specific map/resolution/format).
+ public readonly struct PolyHavenFile
+ {
+ public readonly string Url;
+ public readonly string Format; // "jpg" / "png" / "exr"
+ public readonly long Size;
+
+ public PolyHavenFile(string url, string format, long size)
+ {
+ Url = url;
+ Format = format;
+ Size = size;
+ }
+
+ public bool IsValid => !string.IsNullOrEmpty(Url);
+ }
+
+ /// Asset metadata from /info/{slug}.
+ public sealed class PolyHavenInfo
+ {
+ public string Slug;
+ public string Name;
+ public int Type; // 0=HDRI, 1=Texture, 2=Model
+ public string ThumbnailUrl;
+ public string[] Categories = Array.Empty();
+ }
+
+ /// One catalog entry from /assets (lightweight, for the browser grid).
+ public sealed class PolyHavenAsset
+ {
+ public string Slug;
+ public string Name;
+ public string ThumbnailUrl;
+ public string[] Categories = Array.Empty();
+ public string[] Tags = Array.Empty();
+ public string Search; // pre-lowered "name slug categories tags" haystack
+ }
+
+ /// Parsed /files/{slug}: MapType -> resolution("2k") -> best file.
+ public sealed class PolyHavenFiles
+ {
+ public readonly Dictionary> Maps =
+ new Dictionary>();
+
+ // Every resolution string seen across all maps (e.g. "1k","2k","4k"), sorted low->high.
+ public List Resolutions = new List();
+
+ public bool Has(PolyHavenMapType type) => Maps.ContainsKey(type);
+
+ public bool TryGet(PolyHavenMapType type, string res, out PolyHavenFile file)
+ {
+ file = default;
+ return Maps.TryGetValue(type, out var byRes) && byRes.TryGetValue(res, out file);
+ }
+
+ /// Best available file at res, else next-lower resolution of the same map.
+ public bool TryGetWithFallback(PolyHavenMapType type, string res, out PolyHavenFile file, out string usedRes)
+ {
+ file = default;
+ usedRes = null;
+ if (!Maps.TryGetValue(type, out var byRes))
+ return false;
+ if (byRes.TryGetValue(res, out file)) { usedRes = res; return true; }
+
+ int target = ResolutionRank(res);
+ int bestRank = -1;
+ foreach (var kv in byRes)
+ {
+ int rank = ResolutionRank(kv.Key);
+ if (rank <= target && rank > bestRank)
+ {
+ bestRank = rank;
+ file = kv.Value;
+ usedRes = kv.Key;
+ }
+ }
+ if (usedRes == null) // nothing at-or-below target: take the lowest available
+ {
+ foreach (var kv in byRes)
+ {
+ int rank = ResolutionRank(kv.Key);
+ if (bestRank == -1 || rank < bestRank)
+ {
+ bestRank = rank;
+ file = kv.Value;
+ usedRes = kv.Key;
+ }
+ }
+ }
+ return usedRes != null;
+ }
+
+ public static int ResolutionRank(string res)
+ {
+ if (string.IsNullOrEmpty(res)) return int.MaxValue;
+ var digits = res.TrimEnd('k', 'K');
+ return int.TryParse(digits, out var n) ? n : int.MaxValue;
+ }
+ }
+
+ ///
+ /// Async client for the public Poly Haven API (same endpoints the Blender/UE5 plugins use).
+ /// All requests poll on EditorApplication.update — never blocks the editor.
+ ///
+ public static class PolyHavenApi
+ {
+ private const string ApiBase = "https://api.polyhaven.com";
+ private const string UserAgent = "Chameleon-Unity-PolyHavenImporter/1.0";
+
+ // Best format preference per map: lossless where it matters, jpg otherwise.
+ private static readonly string[] LosslessPref = { "png", "exr", "jpg" };
+ private static readonly string[] LossyPref = { "jpg", "png", "exr" };
+
+ public static string ExtractSlug(string urlOrSlug)
+ {
+ if (string.IsNullOrWhiteSpace(urlOrSlug)) return null;
+ var s = urlOrSlug.Trim();
+
+ // Full URL like https://polyhaven.com/a/broken_brick_wall
+ int a = s.IndexOf("/a/", StringComparison.OrdinalIgnoreCase);
+ if (a >= 0)
+ s = s.Substring(a + 3);
+
+ // Strip any trailing path/query/fragment.
+ foreach (var sep in new[] { '?', '#', '/' })
+ {
+ int i = s.IndexOf(sep);
+ if (i >= 0) s = s.Substring(0, i);
+ }
+ s = s.Trim();
+ return s.Length == 0 ? null : s;
+ }
+
+ public static string ThumbnailUrl(string slug) =>
+ $"https://cdn.polyhaven.com/asset_img/thumbs/{slug}.png?width=256&height=256";
+
+ // ---- Requests ----------------------------------------------------------
+
+ public static void FetchInfo(string slug, Action onDone, Action onError)
+ {
+ var req = UnityWebRequest.Get($"{ApiBase}/info/{slug}");
+ req.SetRequestHeader("User-Agent", UserAgent);
+ Send(req, () =>
+ {
+ try
+ {
+ var o = JObject.Parse(req.downloadHandler.text);
+ var info = new PolyHavenInfo
+ {
+ Slug = slug,
+ Name = (string)o["name"] ?? slug,
+ Type = o["type"] != null ? (int)o["type"] : -1,
+ ThumbnailUrl = (string)o["thumbnail_url"] ?? ThumbnailUrl(slug),
+ };
+ if (o["categories"] is JArray cats)
+ {
+ var list = new List();
+ foreach (var c in cats) list.Add((string)c);
+ info.Categories = list.ToArray();
+ }
+ onDone?.Invoke(info);
+ }
+ catch (Exception e) { onError?.Invoke($"Info parse failed: {e.Message}"); }
+ }, onError);
+ }
+
+ /// Loads the full texture catalog (keyed by slug) for the browser grid.
+ public static void FetchAssets(string type, Action> onDone, Action onError)
+ {
+ var req = UnityWebRequest.Get($"{ApiBase}/assets?t={type}");
+ req.SetRequestHeader("User-Agent", UserAgent);
+ Send(req, () =>
+ {
+ try
+ {
+ var root = JObject.Parse(req.downloadHandler.text);
+ var list = new List(root.Count);
+ foreach (var prop in root)
+ {
+ if (prop.Value is not JObject o) continue;
+ var a = new PolyHavenAsset
+ {
+ Slug = prop.Key,
+ Name = (string)o["name"] ?? prop.Key,
+ ThumbnailUrl = (string)o["thumbnail_url"] ?? ThumbnailUrl(prop.Key),
+ Categories = ToStringArray(o["categories"]),
+ Tags = ToStringArray(o["tags"]),
+ };
+ a.Search = (a.Name + " " + a.Slug + " " +
+ string.Join(" ", a.Categories) + " " +
+ string.Join(" ", a.Tags)).ToLowerInvariant();
+ list.Add(a);
+ }
+ list.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase));
+ onDone?.Invoke(list);
+ }
+ catch (Exception e) { onError?.Invoke($"Assets parse failed: {e.Message}"); }
+ }, onError);
+ }
+
+ public static void FetchFiles(string slug, Action onDone, Action onError)
+ {
+ var req = UnityWebRequest.Get($"{ApiBase}/files/{slug}");
+ req.SetRequestHeader("User-Agent", UserAgent);
+ Send(req, () =>
+ {
+ try { onDone?.Invoke(ParseFiles(req.downloadHandler.text)); }
+ catch (Exception e) { onError?.Invoke($"Files parse failed: {e.Message}"); }
+ }, onError);
+ }
+
+ public static void FetchThumbnail(string url, Action onDone, Action onError)
+ {
+ var req = UnityWebRequestTexture.GetTexture(url);
+ req.SetRequestHeader("User-Agent", UserAgent);
+ Send(req, () =>
+ {
+ var tex = DownloadHandlerTexture.GetContent(req);
+ // Survive play-mode enter/exit cleanup — otherwise Unity destroys these
+ // loose runtime textures and the browser previews go blank.
+ if (tex)
+ tex.hideFlags = HideFlags.HideAndDontSave;
+ onDone?.Invoke(tex);
+ }, onError);
+ }
+
+ /// Streams a file straight to disk (no RAM peak on big EXR/8K files).
+ public static void DownloadFile(PolyHavenFile file, string destPath, Action onDone,
+ Action onError, Action onProgress = null)
+ {
+ var req = new UnityWebRequest(file.Url, UnityWebRequest.kHttpVerbGET);
+ req.downloadHandler = new DownloadHandlerFile(destPath) { removeFileOnAbort = true };
+ req.SetRequestHeader("User-Agent", UserAgent);
+ Send(req, () => onDone?.Invoke(), onError, onProgress);
+ }
+
+ // ---- Parsing -----------------------------------------------------------
+
+ private static string[] ToStringArray(JToken token)
+ {
+ if (token is not JArray arr) return Array.Empty();
+ var list = new List(arr.Count);
+ foreach (var t in arr)
+ {
+ var s = (string)t;
+ if (!string.IsNullOrEmpty(s)) list.Add(s);
+ }
+ return list.ToArray();
+ }
+
+ private static PolyHavenFiles ParseFiles(string json)
+ {
+ var root = JObject.Parse(json);
+ var result = new PolyHavenFiles();
+ var resSet = new HashSet();
+
+ foreach (var meta in PolyHavenMapInfo.All)
+ {
+ if (root[meta.ApiKey] is not JObject byRes)
+ continue;
+
+ var pref = (meta.IsNormal || meta.Type == PolyHavenMapType.Displacement)
+ ? LosslessPref
+ : LossyPref;
+
+ var perRes = new Dictionary();
+ foreach (var resProp in byRes)
+ {
+ string res = resProp.Key;
+ if (resProp.Value is not JObject formats)
+ continue;
+
+ PolyHavenFile picked = default;
+ foreach (var fmt in pref)
+ {
+ if (formats[fmt] is JObject fo && fo["url"] != null)
+ {
+ picked = new PolyHavenFile(
+ (string)fo["url"], fmt,
+ fo["size"] != null ? (long)fo["size"] : 0);
+ break;
+ }
+ }
+ if (picked.IsValid)
+ {
+ perRes[res] = picked;
+ resSet.Add(res);
+ }
+ }
+
+ if (perRes.Count > 0)
+ result.Maps[meta.Type] = perRes;
+ }
+
+ var resList = new List(resSet);
+ resList.Sort((x, y) => PolyHavenFiles.ResolutionRank(x).CompareTo(PolyHavenFiles.ResolutionRank(y)));
+ result.Resolutions = resList;
+ return result;
+ }
+
+ // ---- Non-blocking transport -------------------------------------------
+
+ private static void Send(UnityWebRequest req, Action onSuccess, Action onError,
+ Action onProgress = null)
+ {
+ var op = req.SendWebRequest();
+ void Tick()
+ {
+ if (onProgress != null && !op.isDone)
+ onProgress(req.downloadProgress);
+
+ if (!op.isDone)
+ return;
+
+ EditorApplication.update -= Tick;
+ try
+ {
+ if (req.result != UnityWebRequest.Result.Success)
+ {
+ onError?.Invoke($"{req.error} ({req.url})");
+ }
+ else
+ {
+ onProgress?.Invoke(1f);
+ onSuccess?.Invoke();
+ }
+ }
+ finally
+ {
+ req.Dispose();
+ }
+ }
+ EditorApplication.update += Tick;
+ }
+ }
+}
diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenApi.cs.meta b/Assets/Scripts/Editor/PolyHaven/PolyHavenApi.cs.meta
new file mode 100644
index 0000000..b981c46
--- /dev/null
+++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenApi.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: dc6fa3c02522bc342877a7d50977f482
\ No newline at end of file
diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenImporterWindow.cs b/Assets/Scripts/Editor/PolyHaven/PolyHavenImporterWindow.cs
new file mode 100644
index 0000000..28b11d0
--- /dev/null
+++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenImporterWindow.cs
@@ -0,0 +1,534 @@
+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;
+ }
+ }
+ }
+}
diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenImporterWindow.cs.meta b/Assets/Scripts/Editor/PolyHaven/PolyHavenImporterWindow.cs.meta
new file mode 100644
index 0000000..c889cb0
--- /dev/null
+++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenImporterWindow.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 401846ce5f8a2eb4fbd4db733689b7b8
\ No newline at end of file
diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenMapType.cs b/Assets/Scripts/Editor/PolyHaven/PolyHavenMapType.cs
new file mode 100644
index 0000000..b6eebb4
--- /dev/null
+++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenMapType.cs
@@ -0,0 +1,71 @@
+using System.Collections.Generic;
+
+namespace Chameleon.Editor.PolyHaven
+{
+ ///
+ /// The PBR map types the importer understands. Values map onto Poly Haven's
+ /// dynamic file-tree keys (e.g. "nor_gl", "Rough"). "Arm" is the packed
+ /// AO/Rough/Metal fallback some assets ship instead of separate maps.
+ ///
+ public enum PolyHavenMapType
+ {
+ Diffuse,
+ NormalGL,
+ Rough,
+ AO,
+ Metal,
+ Displacement,
+ Arm,
+ }
+
+ /// Static per-map metadata: API key, file suffix, colour space, defaults.
+ public static class PolyHavenMapInfo
+ {
+ public readonly struct Meta
+ {
+ public readonly PolyHavenMapType Type;
+ public readonly string ApiKey; // exact key in the /files JSON
+ public readonly string Suffix; // file name suffix on disk
+ public readonly bool SRGB; // true => colour, false => linear data
+ public readonly bool IsNormal; // import as TextureImporterType.NormalMap
+ public readonly bool DefaultEnabled; // pre-checked in the UI when available
+ public readonly string Label; // UI label
+
+ public Meta(PolyHavenMapType type, string apiKey, string suffix, bool srgb,
+ bool isNormal, bool defaultEnabled, string label)
+ {
+ Type = type;
+ ApiKey = apiKey;
+ Suffix = suffix;
+ SRGB = srgb;
+ IsNormal = isNormal;
+ DefaultEnabled = defaultEnabled;
+ Label = label;
+ }
+ }
+
+ // Order here defines UI order.
+ public static readonly Meta[] All =
+ {
+ new Meta(PolyHavenMapType.Diffuse, "Diffuse", "diffuse", true, false, true, "Diffuse (Albedo)"),
+ new Meta(PolyHavenMapType.NormalGL, "nor_gl", "normal", false, true, true, "Normal (OpenGL)"),
+ new Meta(PolyHavenMapType.Rough, "Rough", "roughness", false, false, true, "Roughness"),
+ new Meta(PolyHavenMapType.AO, "AO", "ao", false, false, true, "Ambient Occlusion"),
+ new Meta(PolyHavenMapType.Metal, "Metal", "metallic", false, false, true, "Metallic"),
+ new Meta(PolyHavenMapType.Displacement, "Displacement", "height", false, false, false, "Displacement (Height)"),
+ new Meta(PolyHavenMapType.Arm, "arm", "arm", false, false, false, "ARM (packed AO/Rough/Metal)"),
+ };
+
+ private static readonly Dictionary ByType = BuildLookup();
+
+ private static Dictionary BuildLookup()
+ {
+ var d = new Dictionary(All.Length);
+ foreach (var m in All)
+ d[m.Type] = m;
+ return d;
+ }
+
+ public static Meta Get(PolyHavenMapType type) => ByType[type];
+ }
+}
diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenMapType.cs.meta b/Assets/Scripts/Editor/PolyHaven/PolyHavenMapType.cs.meta
new file mode 100644
index 0000000..1ead6ef
--- /dev/null
+++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenMapType.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d47448fcb2873654d8d7b4369c6c5da4
\ No newline at end of file
diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenMaterialBuilder.cs b/Assets/Scripts/Editor/PolyHaven/PolyHavenMaterialBuilder.cs
new file mode 100644
index 0000000..b3e1de9
--- /dev/null
+++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenMaterialBuilder.cs
@@ -0,0 +1,254 @@
+using System.Collections.Generic;
+using System.IO;
+using UnityEditor;
+using UnityEngine;
+
+namespace Chameleon.Editor.PolyHaven
+{
+ ///
+ /// Turns downloaded Poly Haven maps into a ready URP/Lit material:
+ /// correct import settings per map, Roughness→Smoothness repacking into a
+ /// MetallicGloss texture, and wired-up keywords.
+ ///
+ public static class PolyHavenMaterialBuilder
+ {
+ /// A map that was successfully downloaded, ready to import.
+ public struct Downloaded
+ {
+ public PolyHavenMapType Type;
+ public string AssetPath; // "Assets/Poly Haven/.../foo_diffuse.jpg"
+ }
+
+ public static void Build(string folder, string displayName, string slug, int maxSize,
+ List maps)
+ {
+ var byType = new Dictionary();
+ foreach (var m in maps)
+ byType[m.Type] = m.AssetPath;
+
+ // 1) Import settings for every raw map.
+ foreach (var m in maps)
+ ApplyImportSettings(m.AssetPath, m.Type, maxSize);
+
+ // 2) Build a MetallicSmoothness texture. Rough/Metal come from the dedicated
+ // maps when present, otherwise from the packed ARM map (G=rough, B=metal).
+ byType.TryGetValue(PolyHavenMapType.Arm, out var armPath);
+ byType.TryGetValue(PolyHavenMapType.Rough, out var roughPath);
+ byType.TryGetValue(PolyHavenMapType.Metal, out var metalPath);
+
+ string metallicGlossPath = null;
+ if (!string.IsNullOrEmpty(roughPath) || !string.IsNullOrEmpty(armPath))
+ metallicGlossPath = BuildMetallicSmoothness(folder, slug, roughPath, metalPath, armPath, maxSize);
+
+ // AO comes from the dedicated map, or the ARM red channel as a fallback.
+ string occlusionPath = null;
+ if (byType.TryGetValue(PolyHavenMapType.AO, out var aoSeparate))
+ occlusionPath = aoSeparate;
+ else if (!string.IsNullOrEmpty(armPath))
+ occlusionPath = BuildAoFromArm(folder, slug, armPath, maxSize);
+
+ // 3) Create the URP/Lit material.
+ var shader = Shader.Find("Universal Render Pipeline/Lit");
+ if (!shader)
+ {
+ Debug.LogError("[PolyHaven] URP/Lit shader not found — is URP installed?");
+ return;
+ }
+
+ var mat = new Material(shader) { name = displayName };
+ string matPath = Path.Combine(folder, SanitizeFileName(displayName) + ".mat").Replace('\\', '/');
+ matPath = AssetDatabase.GenerateUniqueAssetPath(matPath);
+ AssetDatabase.CreateAsset(mat, matPath);
+
+ if (byType.TryGetValue(PolyHavenMapType.Diffuse, out var diffPath))
+ mat.SetTexture("_BaseMap", Load(diffPath));
+
+ if (byType.TryGetValue(PolyHavenMapType.NormalGL, out var norPath))
+ {
+ mat.SetTexture("_BumpMap", Load(norPath));
+ mat.SetFloat("_BumpScale", 1f);
+ mat.EnableKeyword("_NORMALMAP");
+ }
+
+ if (!string.IsNullOrEmpty(metallicGlossPath))
+ {
+ mat.SetTexture("_MetallicGlossMap", Load(metallicGlossPath));
+ mat.SetFloat("_Smoothness", 1f); // acts as multiplier on the map's alpha
+ mat.EnableKeyword("_METALLICSPECGLOSSMAP");
+ }
+
+ if (!string.IsNullOrEmpty(occlusionPath))
+ {
+ mat.SetTexture("_OcclusionMap", Load(occlusionPath));
+ mat.SetFloat("_OcclusionStrength", 1f);
+ mat.EnableKeyword("_OCCLUSIONMAP");
+ }
+
+ if (byType.TryGetValue(PolyHavenMapType.Displacement, out var heightPath))
+ {
+ mat.SetTexture("_ParallaxMap", Load(heightPath));
+ mat.SetFloat("_Parallax", 0.03f);
+ mat.EnableKeyword("_PARALLAXMAP");
+ }
+
+ EditorUtility.SetDirty(mat);
+ AssetDatabase.SaveAssets();
+ EditorGUIUtility.PingObject(mat);
+ Selection.activeObject = mat;
+ Debug.Log($"[PolyHaven] Material created: {matPath}");
+ }
+
+ private static Texture2D Load(string path) => AssetDatabase.LoadAssetAtPath(path);
+
+ public static void ApplyImportSettings(string assetPath, PolyHavenMapType type, int maxSize)
+ {
+ var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
+ if (!importer)
+ return;
+
+ var meta = PolyHavenMapInfo.Get(type);
+ importer.textureType = meta.IsNormal ? TextureImporterType.NormalMap : TextureImporterType.Default;
+ if (!meta.IsNormal)
+ importer.sRGBTexture = meta.SRGB;
+ importer.maxTextureSize = maxSize;
+ importer.SaveAndReimport();
+ }
+
+ ///
+ /// Packs metallic into RGB and smoothness (1-roughness) into A, as URP/Lit expects
+ /// in _MetallicGlossMap. Roughness/Metal are read from the dedicated maps when given,
+ /// otherwise unpacked from the ARM map (G=rough, B=metal). Reads raw bytes off disk
+ /// into linear textures to avoid sRGB sampling on data maps.
+ ///
+ private static string BuildMetallicSmoothness(string folder, string slug, string roughPath,
+ string metalPath, string armPath, int maxSize)
+ {
+ Texture2D rough = string.IsNullOrEmpty(roughPath) ? null : LoadLinear(roughPath);
+ Texture2D metal = string.IsNullOrEmpty(metalPath) ? null : LoadLinear(metalPath);
+ Texture2D arm = string.IsNullOrEmpty(armPath) ? null : LoadLinear(armPath);
+
+ // Sizing grid: prefer the dedicated roughness map, else the ARM map.
+ Texture2D grid = rough ? rough : arm;
+ if (!grid)
+ {
+ if (rough) Object.DestroyImmediate(rough);
+ if (metal) Object.DestroyImmediate(metal);
+ if (arm) Object.DestroyImmediate(arm);
+ return null;
+ }
+
+ int w = grid.width, h = grid.height;
+ var outPixels = new Color[w * h];
+
+ for (int y = 0; y < h; y++)
+ {
+ for (int x = 0; x < w; x++)
+ {
+ int i = y * w + x;
+ float u = (x + 0.5f) / w, v = (y + 0.5f) / h;
+
+ // Roughness: dedicated map .r, else ARM .g. Smoothness = 1 - roughness.
+ float roughness = rough ? rough.GetPixelBilinear(u, v).r
+ : arm ? arm.GetPixelBilinear(u, v).g
+ : 1f;
+ float smoothness = 1f - roughness;
+
+ // Metallic: dedicated map .r, else ARM .b.
+ float m = metal ? metal.GetPixelBilinear(u, v).r
+ : arm ? arm.GetPixelBilinear(u, v).b
+ : 0f;
+
+ outPixels[i] = new Color(m, m, m, smoothness);
+ }
+ }
+
+ var packed = new Texture2D(w, h, TextureFormat.RGBA32, true, true);
+ packed.SetPixels(outPixels);
+ packed.Apply();
+
+ string outPath = Path.Combine(folder, slug + "_metallic_smoothness.png").Replace('\\', '/');
+ File.WriteAllBytes(outPath, packed.EncodeToPNG());
+
+ Object.DestroyImmediate(packed);
+ if (rough) Object.DestroyImmediate(rough);
+ if (metal) Object.DestroyImmediate(metal);
+ if (arm) Object.DestroyImmediate(arm);
+
+ ReimportLinear(outPath, maxSize, withAlpha: true);
+ return outPath;
+ }
+
+ ///
+ /// Extracts the AO (red) channel from a packed ARM map into a standalone grayscale
+ /// occlusion texture, used when the asset ships no dedicated AO map.
+ ///
+ private static string BuildAoFromArm(string folder, string slug, string armPath, int maxSize)
+ {
+ var arm = LoadLinear(armPath);
+ if (!arm)
+ return null;
+
+ int w = arm.width, h = arm.height;
+ var src = arm.GetPixels();
+ var outPixels = new Color[src.Length];
+ for (int i = 0; i < src.Length; i++)
+ {
+ float ao = src[i].r; // ARM red channel = ambient occlusion
+ outPixels[i] = new Color(ao, ao, ao, 1f);
+ }
+
+ var tex = new Texture2D(w, h, TextureFormat.RGBA32, true, true);
+ tex.SetPixels(outPixels);
+ tex.Apply();
+
+ string outPath = Path.Combine(folder, slug + "_ao.png").Replace('\\', '/');
+ File.WriteAllBytes(outPath, tex.EncodeToPNG());
+
+ Object.DestroyImmediate(tex);
+ Object.DestroyImmediate(arm);
+
+ ReimportLinear(outPath, maxSize, withAlpha: false);
+ return outPath;
+ }
+
+ /// Reimports a generated data texture as linear, at the target size.
+ private static void ReimportLinear(string outPath, int maxSize, bool withAlpha)
+ {
+ AssetDatabase.ImportAsset(outPath, ImportAssetOptions.ForceSynchronousImport);
+ var imp = AssetImporter.GetAtPath(outPath) as TextureImporter;
+ if (!imp)
+ return;
+ imp.sRGBTexture = false;
+ imp.maxTextureSize = maxSize;
+ if (withAlpha)
+ {
+ imp.alphaSource = TextureImporterAlphaSource.FromInput;
+ imp.alphaIsTransparency = false;
+ }
+ imp.SaveAndReimport();
+ }
+
+ /// Loads an image file straight off disk into a linear Texture2D (no import needed).
+ private static Texture2D LoadLinear(string assetPath)
+ {
+ string full = Path.GetFullPath(assetPath);
+ if (!File.Exists(full))
+ return null;
+ var bytes = File.ReadAllBytes(full);
+ var tex = new Texture2D(2, 2, TextureFormat.RGBA32, true, true);
+ if (!tex.LoadImage(bytes))
+ {
+ Object.DestroyImmediate(tex);
+ return null;
+ }
+ return tex;
+ }
+
+ public static string SanitizeFileName(string name)
+ {
+ foreach (var c in Path.GetInvalidFileNameChars())
+ name = name.Replace(c, ' ');
+ return name.Trim();
+ }
+ }
+}
diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenMaterialBuilder.cs.meta b/Assets/Scripts/Editor/PolyHaven/PolyHavenMaterialBuilder.cs.meta
new file mode 100644
index 0000000..ff5caf2
--- /dev/null
+++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenMaterialBuilder.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3c242963f05cc5545b0034908b5807f1
\ No newline at end of file