using System; using System.Collections.Generic; using Chameleon.Editor.WebAssets; using Newtonsoft.Json.Linq; using UnityEngine; 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(); } /// 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(PbrMapType type) => Maps.ContainsKey(type); public bool TryGet(PbrMapType 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(PbrMapType 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 = ImportUtil.ResolutionRank(res); int bestRank = -1; foreach (var kv in byRes) { int rank = ImportUtil.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 = ImportUtil.ResolutionRank(kv.Key); if (bestRank == -1 || rank < bestRank) { bestRank = rank; file = kv.Value; usedRes = kv.Key; } } } return usedRes != null; } } /// /// Async client for the public Poly Haven API (same endpoints the Blender/UE5 /// plugins use). Transport is the shared non-blocking EditorWebRequest. /// public static class PolyHavenApi { private const string ApiBase = "https://api.polyhaven.com"; // Poly Haven's dynamic file-tree keys per map type. private static readonly (PbrMapType type, string apiKey)[] ApiKeys = { (PbrMapType.Diffuse, "Diffuse"), (PbrMapType.NormalGL, "nor_gl"), (PbrMapType.Rough, "Rough"), (PbrMapType.AO, "AO"), (PbrMapType.Metal, "Metal"), (PbrMapType.Displacement, "Displacement"), (PbrMapType.Arm, "arm"), }; // 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) { EditorWebRequest.GetText($"{ApiBase}/info/{slug}", text => { try { var o = JObject.Parse(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 as browser-grid entries. public static void FetchAssets(string type, Action> onDone, Action onError) { EditorWebRequest.GetText($"{ApiBase}/assets?t={type}", text => { try { var root = JObject.Parse(text); var list = new List(root.Count); foreach (var prop in root) { if (prop.Value is not JObject o) continue; string name = (string)o["name"] ?? prop.Key; var e = new AssetBrowserGrid.Entry { Key = prop.Key, Name = name, ThumbnailUrl = (string)o["thumbnail_url"] ?? ThumbnailUrl(prop.Key), }; e.Search = (name + " " + prop.Key + " " + JoinStrings(o["categories"]) + " " + JoinStrings(o["tags"])).ToLowerInvariant(); list.Add(e); } 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) { EditorWebRequest.GetText($"{ApiBase}/files/{slug}", text => { try { onDone?.Invoke(ParseFiles(text)); } catch (Exception e) { onError?.Invoke($"Files parse failed: {e.Message}"); } }, onError); } // ---- Parsing ----------------------------------------------------------- private static string JoinStrings(JToken token) { if (token is not JArray arr) return ""; var list = new List(arr.Count); foreach (var t in arr) { var s = (string)t; if (!string.IsNullOrEmpty(s)) list.Add(s); } return string.Join(" ", list); } private static PolyHavenFiles ParseFiles(string json) { var root = JObject.Parse(json); var result = new PolyHavenFiles(); var resSet = new HashSet(); foreach (var (type, apiKey) in ApiKeys) { if (root[apiKey] is not JObject byRes) continue; var meta = PbrMapInfo.Get(type); var pref = (meta.IsNormal || type == PbrMapType.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[type] = perRes; } var resList = new List(resSet); resList.Sort((x, y) => ImportUtil.ResolutionRank(x).CompareTo(ImportUtil.ResolutionRank(y))); result.Resolutions = resList; return result; } } }