Files
chameleon/Assets/Scripts/Editor/PolyHaven/PolyHavenApi.cs
T
adminandClaude Fable 5 897ba57599 Add Poly Haven importer editor tool
Tools/Poly Haven Importer: fetches asset metadata/files via the public
Poly Haven API, browsable texture catalog with searchable thumbnail
grid, per-map resolution selection, async non-blocking downloads, and
automatic URP/Lit material build (roughness->smoothness repacking into
_MetallicGlossMap, ARM unpacking as fallback for missing AO/Rough/Metal
maps, linear import settings for data textures). Thumbnails survive
play mode via HideAndDontSave + destroyed-cache recovery.

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

346 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Networking;
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>One catalog entry from /assets (lightweight, for the browser grid).</summary>
public sealed class PolyHavenAsset
{
public string Slug;
public string Name;
public string ThumbnailUrl;
public string[] Categories = Array.Empty<string>();
public string[] Tags = Array.Empty<string>();
public string Search; // pre-lowered "name slug categories tags" haystack
}
/// <summary>Parsed /files/{slug}: MapType -> resolution("2k") -> best file.</summary>
public sealed class PolyHavenFiles
{
public readonly Dictionary<PolyHavenMapType, Dictionary<string, PolyHavenFile>> Maps =
new Dictionary<PolyHavenMapType, 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(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);
}
/// <summary>Best available file at res, else next-lower resolution of the same map.</summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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<PolyHavenInfo> onDone, Action<string> 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<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 (keyed by slug) for the browser grid.</summary>
public static void FetchAssets(string type, Action<List<PolyHavenAsset>> onDone, Action<string> 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<PolyHavenAsset>(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<PolyHavenFiles> onDone, Action<string> 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<Texture2D> onDone, Action<string> 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);
}
/// <summary>Streams a file straight to disk (no RAM peak on big EXR/8K files).</summary>
public static void DownloadFile(PolyHavenFile file, string destPath, Action onDone,
Action<string> onError, Action<float> 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<string>();
var list = new List<string>(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<string>();
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<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[meta.Type] = perRes;
}
var resList = new List<string>(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<string> onError,
Action<float> 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;
}
}
}