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>
346 lines
13 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|