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; } } }