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" / "fbx" 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 texture set of an asset. Textures have a single unnamed set; models may ship /// several (e.g. "cart"/"props"/"mugs"), keyed by the prefix of the API map keys. /// public sealed class PolyHavenMapSet { public string Name = ""; // "" for the default/only set public readonly Dictionary> Maps = new Dictionary>(); public bool Has(PbrMapType type) => Maps.ContainsKey(type); /// 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; return Maps.TryGetValue(type, out var byRes) && PolyHavenFiles.PickWithFallback(byRes, res, out file, out usedRes); } } /// Parsed /files/{slug}: texture sets plus (for models) the FBX per resolution. public sealed class PolyHavenFiles { public readonly List Sets = new List(); /// Model geometry: resolution("2k") -> FBX file. Empty for texture assets. public readonly Dictionary FbxByRes = new Dictionary(); // Every resolution string seen across all maps (e.g. "1k","2k","4k"), sorted low->high. public List Resolutions = new List(); public bool HasFbx => FbxByRes.Count > 0; public int MapTypeCount { get { var types = new HashSet(); foreach (var set in Sets) foreach (var t in set.Maps.Keys) types.Add(t); return types.Count; } } /// True if any set carries this map type. public bool Has(PbrMapType type) { foreach (var set in Sets) if (set.Has(type)) return true; return false; } public bool TryGetFbxWithFallback(string res, out PolyHavenFile file, out string usedRes) => PickWithFallback(FbxByRes, res, out file, out usedRes); /// Exact res, else the highest one at-or-below it, else the lowest available. internal static bool PickWithFallback(Dictionary byRes, string res, out PolyHavenFile file, out string usedRes) { file = default; usedRes = null; if (byRes == null || byRes.Count == 0) 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"; // Map-key suffixes in the dynamic file tree. Textures use the bare names // ("Diffuse", "nor_gl"); multi-set models prefix them ("cart_diff", // "props_roughness"). Longer suffixes first so "roughness" wins over "rough". private static readonly (string suffix, PbrMapType type)[] SuffixMap = { ("displacement", PbrMapType.Displacement), ("roughness", PbrMapType.Rough), ("metallic", PbrMapType.Metal), ("diffuse", PbrMapType.Diffuse), ("height", PbrMapType.Displacement), ("nor_gl", PbrMapType.NormalGL), ("metal", PbrMapType.Metal), ("rough", PbrMapType.Rough), ("diff", PbrMapType.Diffuse), ("disp", PbrMapType.Displacement), ("arm", PbrMapType.Arm), ("ao", PbrMapType.AO), }; // 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 asset catalog ("textures"/"models") 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 bool MatchMapKey(string key, out string setName, out PbrMapType type) { setName = null; type = default; string k = key.ToLowerInvariant(); foreach (var (suffix, t) in SuffixMap) { if (k == suffix) { setName = ""; type = t; return true; } if (k.EndsWith("_" + suffix, StringComparison.Ordinal)) { setName = key.Substring(0, key.Length - suffix.Length - 1); type = t; return true; } } return false; } private static PolyHavenFiles ParseFiles(string json) { var root = JObject.Parse(json); var result = new PolyHavenFiles(); var resSet = new HashSet(); var setsByName = new Dictionary(); foreach (var prop in root) { // Model geometry: fbx -> res -> "fbx" -> {url,size}. if (string.Equals(prop.Key, "fbx", StringComparison.OrdinalIgnoreCase)) { if (prop.Value is not JObject fbxByRes) continue; foreach (var resProp in fbxByRes) { if (resProp.Value is JObject inner && inner["fbx"] is JObject fo && fo["url"] != null) { result.FbxByRes[resProp.Key] = new PolyHavenFile( (string)fo["url"], "fbx", fo["size"] != null ? (long)fo["size"] : 0); resSet.Add(resProp.Key); } } continue; } // Everything else: either a map key (possibly set-prefixed) or an // unsupported container (blend/gltf/usd/nor_dx/...) we skip. if (!MatchMapKey(prop.Key, out var setName, out var type)) continue; if (prop.Value 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) continue; if (!setsByName.TryGetValue(setName, out var set)) { set = new PolyHavenMapSet { Name = setName }; setsByName[setName] = set; result.Sets.Add(set); } set.Maps[type] = perRes; } // Deterministic set order: default set first, then alphabetical. result.Sets.Sort((a, b) => { if (a.Name.Length == 0 != (b.Name.Length == 0)) return a.Name.Length == 0 ? -1 : 1; return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase); }); var resList = new List(resSet); resList.Sort((x, y) => ImportUtil.ResolutionRank(x).CompareTo(ImportUtil.ResolutionRank(y))); result.Resolutions = resList; return result; } } }