Files
chameleon/Assets/Scripts/Editor/PolyHaven/PolyHavenApi.cs
T
adminandClaude Fable 5 fb4098b9f8 Add ambientCG importer and extract shared web-asset import framework
New Tools/ambientCG Importer window (catalog browser, ZIP download with
selective map extraction, URP/Lit material build). Generic pieces moved
from the PolyHaven tool into Editor/WebAssets (EditorWebRequest,
PbrMapType, PbrMaterialBuilder, ImportUtil, AssetBrowserGrid); PolyHaven
API/window now delegate to the shared code, behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:27:02 +02:00

272 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using Chameleon.Editor.WebAssets;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace Chameleon.Editor.PolyHaven
{
/// <summary>One downloadable file variant (a specific map/resolution/format).</summary>
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);
}
/// <summary>Asset metadata from /info/{slug}.</summary>
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<string>();
}
/// <summary>Parsed /files/{slug}: MapType -> resolution("2k") -> best file.</summary>
public sealed class PolyHavenFiles
{
public readonly Dictionary<PbrMapType, Dictionary<string, PolyHavenFile>> Maps =
new Dictionary<PbrMapType, Dictionary<string, PolyHavenFile>>();
// Every resolution string seen across all maps (e.g. "1k","2k","4k"), sorted low->high.
public List<string> Resolutions = new List<string>();
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);
}
/// <summary>Best available file at res, else next-lower resolution of the same map.</summary>
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;
}
}
/// <summary>
/// Async client for the public Poly Haven API (same endpoints the Blender/UE5
/// plugins use). Transport is the shared non-blocking EditorWebRequest.
/// </summary>
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<PolyHavenInfo> onDone, Action<string> 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<string>();
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);
}
/// <summary>Loads the full texture catalog as browser-grid entries.</summary>
public static void FetchAssets(string type, Action<List<AssetBrowserGrid.Entry>> onDone, Action<string> onError)
{
EditorWebRequest.GetText($"{ApiBase}/assets?t={type}", text =>
{
try
{
var root = JObject.Parse(text);
var list = new List<AssetBrowserGrid.Entry>(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<PolyHavenFiles> onDone, Action<string> 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<string>(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<string>();
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<string, PolyHavenFile>();
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<string>(resSet);
resList.Sort((x, y) => ImportUtil.ResolutionRank(x).CompareTo(ImportUtil.ResolutionRank(y)));
result.Resolutions = resList;
return result;
}
}
}