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>
This commit is contained in:
1 parent
9b35ee2a97
commit
fb4098b9f8
21 files changed
+1208
-420
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: fa6fff02eb207e748b98bea4ebc83bf0
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Chameleon.Editor.WebAssets;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace Chameleon.Editor.AmbientCg
|
||||||
|
{
|
||||||
|
/// <summary>One downloadable ZIP variant of an asset (e.g. attribute "2K-JPG").</summary>
|
||||||
|
public readonly struct AmbientCgZip
|
||||||
|
{
|
||||||
|
public readonly string Resolution; // "2K"
|
||||||
|
public readonly string Format; // "JPG" / "PNG"
|
||||||
|
public readonly string Url;
|
||||||
|
public readonly string FileName;
|
||||||
|
public readonly long Size;
|
||||||
|
|
||||||
|
public AmbientCgZip(string resolution, string format, string url, string fileName, long size)
|
||||||
|
{
|
||||||
|
Resolution = resolution;
|
||||||
|
Format = format;
|
||||||
|
Url = url;
|
||||||
|
FileName = fileName;
|
||||||
|
Size = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsValid => !string.IsNullOrEmpty(Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Details for one asset: display data, available maps and ZIP downloads.</summary>
|
||||||
|
public sealed class AmbientCgAsset
|
||||||
|
{
|
||||||
|
public string Id; // "Tiles141"
|
||||||
|
public string Name; // "Tiles 141"
|
||||||
|
public string Category;
|
||||||
|
public string ThumbnailUrl;
|
||||||
|
public string DataType; // "Material" expected
|
||||||
|
|
||||||
|
// PBR channels this asset ships, already mapped to the shared map types.
|
||||||
|
public readonly List<PbrMapType> Maps = new List<PbrMapType>();
|
||||||
|
|
||||||
|
public readonly List<AmbientCgZip> Zips = new List<AmbientCgZip>();
|
||||||
|
|
||||||
|
public List<string> Resolutions()
|
||||||
|
{
|
||||||
|
var set = new HashSet<string>();
|
||||||
|
foreach (var z in Zips) set.Add(z.Resolution);
|
||||||
|
var list = new List<string>(set);
|
||||||
|
list.Sort((x, y) => ImportUtil.ResolutionRank(x).CompareTo(ImportUtil.ResolutionRank(y)));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> Formats(string resolution)
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
foreach (var z in Zips)
|
||||||
|
if (z.Resolution == resolution && !list.Contains(z.Format))
|
||||||
|
list.Add(z.Format);
|
||||||
|
list.Sort(); // "JPG" before "PNG"
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetZip(string resolution, string format, out AmbientCgZip zip)
|
||||||
|
{
|
||||||
|
foreach (var z in Zips)
|
||||||
|
{
|
||||||
|
if (z.Resolution == resolution && z.Format == format)
|
||||||
|
{
|
||||||
|
zip = z;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zip = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Async client for the public ambientCG API (v2 full_json). Unlike Poly Haven,
|
||||||
|
/// ambientCG ships one ZIP per resolution+format containing all maps with
|
||||||
|
/// standardized file suffixes (Color, NormalGL, Roughness, AmbientOcclusion, ...).
|
||||||
|
/// </summary>
|
||||||
|
public static class AmbientCgApi
|
||||||
|
{
|
||||||
|
private const string ApiBase = "https://ambientcg.com/api/v2/full_json";
|
||||||
|
private const int PageSize = 250; // API maximum
|
||||||
|
|
||||||
|
// ambientCG "maps" names -> shared map type + file suffix inside the ZIP.
|
||||||
|
public static readonly (string apiName, PbrMapType type, string zipSuffix)[] MapNames =
|
||||||
|
{
|
||||||
|
("color", PbrMapType.Diffuse, "Color"),
|
||||||
|
("normal", PbrMapType.NormalGL, "NormalGL"),
|
||||||
|
("roughness", PbrMapType.Rough, "Roughness"),
|
||||||
|
("ambient-occlusion", PbrMapType.AO, "AmbientOcclusion"),
|
||||||
|
("metalness", PbrMapType.Metal, "Metalness"),
|
||||||
|
("displacement", PbrMapType.Displacement, "Displacement"),
|
||||||
|
// "opacity" is intentionally unsupported — URP/Lit opaque materials don't use it.
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string ZipSuffix(PbrMapType type)
|
||||||
|
{
|
||||||
|
foreach (var (_, t, suffix) in MapNames)
|
||||||
|
if (t == type)
|
||||||
|
return suffix;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ExtractId(string urlOrId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(urlOrId)) return null;
|
||||||
|
var s = urlOrId.Trim();
|
||||||
|
|
||||||
|
// https://ambientcg.com/a/Tiles141
|
||||||
|
int a = s.IndexOf("/a/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (a >= 0)
|
||||||
|
s = s.Substring(a + 3);
|
||||||
|
|
||||||
|
// https://ambientcg.com/view?id=Tiles141
|
||||||
|
int q = s.IndexOf("id=", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (q >= 0)
|
||||||
|
s = s.Substring(q + 3);
|
||||||
|
|
||||||
|
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 id) =>
|
||||||
|
$"https://acg-media.struffelproductions.com/file/ambientCG-Web/media/thumbnail/256-PNG/{id}.png";
|
||||||
|
|
||||||
|
// ---- Catalog (paginated) ------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the full Material catalog as browser-grid entries. The API caps pages
|
||||||
|
/// at 250 assets, so this walks all pages sequentially (~2000 assets => ~8 calls).
|
||||||
|
/// </summary>
|
||||||
|
public static void FetchCatalog(Action<List<AssetBrowserGrid.Entry>> onDone, Action<string> onError,
|
||||||
|
Action<int, int> onProgress = null)
|
||||||
|
{
|
||||||
|
var all = new List<AssetBrowserGrid.Entry>();
|
||||||
|
FetchCatalogPage(all, 0, onDone, onError, onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FetchCatalogPage(List<AssetBrowserGrid.Entry> all, int offset,
|
||||||
|
Action<List<AssetBrowserGrid.Entry>> onDone, Action<string> onError, Action<int, int> onProgress)
|
||||||
|
{
|
||||||
|
string url = $"{ApiBase}?type=Material&sort=Alphabet&limit={PageSize}&offset={offset}&include=imageData";
|
||||||
|
EditorWebRequest.GetText(url, text =>
|
||||||
|
{
|
||||||
|
int total;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var root = JObject.Parse(text);
|
||||||
|
total = root["numberOfResults"] != null ? (int)root["numberOfResults"] : 0;
|
||||||
|
|
||||||
|
if (root["foundAssets"] is JArray assets)
|
||||||
|
{
|
||||||
|
foreach (var t in assets)
|
||||||
|
{
|
||||||
|
if (t is not JObject o) continue;
|
||||||
|
string id = (string)o["assetId"];
|
||||||
|
if (string.IsNullOrEmpty(id)) continue;
|
||||||
|
|
||||||
|
string name = (string)o["displayName"] ?? id;
|
||||||
|
var e = new AssetBrowserGrid.Entry
|
||||||
|
{
|
||||||
|
Key = id,
|
||||||
|
Name = name,
|
||||||
|
ThumbnailUrl = (string)(o["previewImage"]?["256-PNG"]) ?? ThumbnailUrl(id),
|
||||||
|
};
|
||||||
|
e.Search = (name + " " + id + " " +
|
||||||
|
((string)o["displayCategory"] ?? "") + " " +
|
||||||
|
JoinStrings(o["tags"])).ToLowerInvariant();
|
||||||
|
all.Add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
onError?.Invoke($"Catalog parse failed: {e.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.Invoke(all.Count, total);
|
||||||
|
if (all.Count < total && offset + PageSize < total)
|
||||||
|
FetchCatalogPage(all, offset + PageSize, onDone, onError, onProgress);
|
||||||
|
else
|
||||||
|
onDone?.Invoke(all);
|
||||||
|
}, onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Asset details -------------------------------------------------------
|
||||||
|
|
||||||
|
public static void FetchAsset(string id, Action<AmbientCgAsset> onDone, Action<string> onError)
|
||||||
|
{
|
||||||
|
string url = $"{ApiBase}?id={Uri.EscapeDataString(id)}&include=downloadData,imageData,mapData";
|
||||||
|
EditorWebRequest.GetText(url, text =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var root = JObject.Parse(text);
|
||||||
|
if (root["foundAssets"] is not JArray assets || assets.Count == 0)
|
||||||
|
{
|
||||||
|
onError?.Invoke($"Asset '{id}' not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDone?.Invoke(ParseAsset((JObject)assets[0]));
|
||||||
|
}
|
||||||
|
catch (Exception e) { onError?.Invoke($"Asset parse failed: {e.Message}"); }
|
||||||
|
}, onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AmbientCgAsset ParseAsset(JObject o)
|
||||||
|
{
|
||||||
|
string id = (string)o["assetId"];
|
||||||
|
var asset = new AmbientCgAsset
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = (string)o["displayName"] ?? id,
|
||||||
|
Category = (string)o["displayCategory"] ?? "",
|
||||||
|
DataType = (string)o["dataType"] ?? "",
|
||||||
|
ThumbnailUrl = (string)(o["previewImage"]?["256-PNG"]) ?? ThumbnailUrl(id),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Which PBR channels this asset ships.
|
||||||
|
if (o["maps"] is JArray maps)
|
||||||
|
{
|
||||||
|
foreach (var m in maps)
|
||||||
|
{
|
||||||
|
var name = (string)m;
|
||||||
|
foreach (var (apiName, type, _) in MapNames)
|
||||||
|
if (string.Equals(name, apiName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
asset.Maps.Add(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZIP downloads: downloadFolders.default.downloadFiletypeCategories.zip.downloads[],
|
||||||
|
// each with attribute like "2K-JPG".
|
||||||
|
if (o.SelectToken("downloadFolders.default.downloadFiletypeCategories.zip.downloads") is JArray downloads)
|
||||||
|
{
|
||||||
|
foreach (var t in downloads)
|
||||||
|
{
|
||||||
|
if (t is not JObject d) continue;
|
||||||
|
string attr = (string)d["attribute"];
|
||||||
|
string link = (string)d["downloadLink"] ?? (string)d["fullDownloadPath"];
|
||||||
|
if (string.IsNullOrEmpty(attr) || string.IsNullOrEmpty(link))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int dash = attr.LastIndexOf('-');
|
||||||
|
if (dash <= 0)
|
||||||
|
continue; // not a "{RES}-{FMT}" texture zip
|
||||||
|
string res = attr.Substring(0, dash);
|
||||||
|
string fmt = attr.Substring(dash + 1);
|
||||||
|
|
||||||
|
asset.Zips.Add(new AmbientCgZip(res, fmt, link,
|
||||||
|
(string)d["fileName"] ?? $"{id}_{attr}.zip",
|
||||||
|
d["size"] != null ? (long)d["size"] : 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4cfc661c9314b9d4f9c5c491ae7df74f
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using Chameleon.Editor.WebAssets;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Chameleon.Editor.AmbientCg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Editor window: browse/paste an ambientCG material, pick resolution + format +
|
||||||
|
/// maps, then download the ZIP, extract the selected maps and auto-build a URP/Lit
|
||||||
|
/// material under Assets/ambientCG/{Name}/. Reuses the shared WebAssets pipeline
|
||||||
|
/// (transport, browser grid, PBR material builder).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AmbientCgImporterWindow : EditorWindow
|
||||||
|
{
|
||||||
|
private const string RootFolder = "Assets/ambientCG";
|
||||||
|
private const string DefaultRes = "2K";
|
||||||
|
private const string DefaultFmt = "JPG";
|
||||||
|
|
||||||
|
private string _urlInput = "https://ambientcg.com/a/";
|
||||||
|
private AmbientCgAsset _asset;
|
||||||
|
private Texture2D _thumbnail;
|
||||||
|
private string _selectedRes = DefaultRes;
|
||||||
|
private string _selectedFmt = DefaultFmt;
|
||||||
|
private readonly Dictionary<PbrMapType, bool> _mapEnabled = new Dictionary<PbrMapType, bool>();
|
||||||
|
|
||||||
|
private bool _busy;
|
||||||
|
private string _status = "";
|
||||||
|
private Vector2 _scroll;
|
||||||
|
|
||||||
|
// ---- Browser ----------------------------------------------------------
|
||||||
|
|
||||||
|
// Catalog cached for the whole session (survives window close, cleared on domain reload).
|
||||||
|
private static List<AssetBrowserGrid.Entry> s_catalog;
|
||||||
|
|
||||||
|
private AssetBrowserGrid _grid;
|
||||||
|
private bool _browseFoldout = true;
|
||||||
|
private bool _autoLoadTried;
|
||||||
|
private bool _catalogLoading;
|
||||||
|
|
||||||
|
[MenuItem("Tools/ambientCG Importer")]
|
||||||
|
public static void Open()
|
||||||
|
{
|
||||||
|
var w = GetWindow<AmbientCgImporterWindow>("ambientCG Importer");
|
||||||
|
w.minSize = new Vector2(440, 480);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
_grid = new AssetBrowserGrid(Repaint);
|
||||||
|
if (s_catalog != null)
|
||||||
|
{
|
||||||
|
_autoLoadTried = true;
|
||||||
|
_grid.SetCatalog(s_catalog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGUI()
|
||||||
|
{
|
||||||
|
_scroll = EditorGUILayout.BeginScrollView(_scroll);
|
||||||
|
|
||||||
|
EditorGUILayout.LabelField("ambientCG Importer", EditorStyles.boldLabel);
|
||||||
|
EditorGUILayout.HelpBox(
|
||||||
|
"Pick a material on ambientcg.com, paste its URL here (e.g. .../a/Tiles141), " +
|
||||||
|
"Fetch, choose resolution + format + maps, then Download. The maps ship as one ZIP; " +
|
||||||
|
"the selected maps are extracted and a URP/Lit material is built automatically.",
|
||||||
|
MessageType.Info);
|
||||||
|
|
||||||
|
EditorGUILayout.Space();
|
||||||
|
|
||||||
|
DrawBrowser();
|
||||||
|
|
||||||
|
EditorGUILayout.Space();
|
||||||
|
|
||||||
|
using (new EditorGUI.DisabledScope(_busy))
|
||||||
|
{
|
||||||
|
EditorGUILayout.LabelField("Asset URL or ID", EditorStyles.miniBoldLabel);
|
||||||
|
using (new EditorGUILayout.HorizontalScope())
|
||||||
|
{
|
||||||
|
_urlInput = EditorGUILayout.TextField(_urlInput);
|
||||||
|
if (GUILayout.Button("Fetch", GUILayout.Width(70)))
|
||||||
|
Fetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_asset != null)
|
||||||
|
DrawAssetSection();
|
||||||
|
|
||||||
|
EditorGUILayout.Space();
|
||||||
|
if (!string.IsNullOrEmpty(_status))
|
||||||
|
EditorGUILayout.HelpBox(_status, _busy ? MessageType.None : MessageType.Info);
|
||||||
|
|
||||||
|
EditorGUILayout.EndScrollView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawAssetSection()
|
||||||
|
{
|
||||||
|
EditorGUILayout.Space();
|
||||||
|
using (new EditorGUILayout.HorizontalScope())
|
||||||
|
{
|
||||||
|
if (_thumbnail)
|
||||||
|
{
|
||||||
|
var r = GUILayoutUtility.GetRect(96, 96, GUILayout.Width(96), GUILayout.Height(96));
|
||||||
|
GUI.DrawTexture(r, _thumbnail, ScaleMode.ScaleToFit);
|
||||||
|
}
|
||||||
|
using (new EditorGUILayout.VerticalScope())
|
||||||
|
{
|
||||||
|
EditorGUILayout.LabelField(_asset.Name, EditorStyles.boldLabel);
|
||||||
|
EditorGUILayout.LabelField($"ID: {_asset.Id}", EditorStyles.miniLabel);
|
||||||
|
if (!string.IsNullOrEmpty(_asset.Category))
|
||||||
|
EditorGUILayout.LabelField($"Category: {_asset.Category}", EditorStyles.miniLabel);
|
||||||
|
if (_asset.DataType != "Material")
|
||||||
|
EditorGUILayout.HelpBox("This asset is not a material — only materials are supported.", MessageType.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolutions = _asset.Resolutions();
|
||||||
|
if (resolutions.Count == 0)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox("No ZIP downloads found for this asset.", MessageType.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUILayout.Space();
|
||||||
|
|
||||||
|
// Resolution popup.
|
||||||
|
var resArray = resolutions.ToArray();
|
||||||
|
int resIdx = Mathf.Max(0, Array.IndexOf(resArray, _selectedRes));
|
||||||
|
resIdx = EditorGUILayout.Popup("Resolution", resIdx, resArray);
|
||||||
|
_selectedRes = resArray[resIdx];
|
||||||
|
|
||||||
|
// Format popup (per resolution; JPG is smaller, PNG lossless).
|
||||||
|
var formats = _asset.Formats(_selectedRes);
|
||||||
|
var fmtArray = formats.ToArray();
|
||||||
|
int fmtIdx = Mathf.Max(0, Array.IndexOf(fmtArray, _selectedFmt));
|
||||||
|
fmtIdx = EditorGUILayout.Popup("Format", fmtIdx, fmtArray);
|
||||||
|
_selectedFmt = fmtArray[fmtIdx];
|
||||||
|
|
||||||
|
if (_asset.TryGetZip(_selectedRes, _selectedFmt, out var zipInfo) && zipInfo.Size > 0)
|
||||||
|
EditorGUILayout.LabelField($"ZIP size: {zipInfo.Size / (1024f * 1024f):0.#} MB", EditorStyles.miniLabel);
|
||||||
|
|
||||||
|
EditorGUILayout.Space();
|
||||||
|
EditorGUILayout.LabelField("Maps", EditorStyles.miniBoldLabel);
|
||||||
|
foreach (var meta in PbrMapInfo.All)
|
||||||
|
{
|
||||||
|
if (!_asset.Maps.Contains(meta.Type))
|
||||||
|
continue;
|
||||||
|
bool cur = _mapEnabled.TryGetValue(meta.Type, out var v) ? v : meta.DefaultEnabled;
|
||||||
|
_mapEnabled[meta.Type] = EditorGUILayout.ToggleLeft(meta.Label, cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUILayout.Space();
|
||||||
|
bool canDownload = !_busy && _asset.DataType == "Material" && AnyMapSelected();
|
||||||
|
using (new EditorGUI.DisabledScope(!canDownload))
|
||||||
|
{
|
||||||
|
if (GUILayout.Button("Download / Import", GUILayout.Height(30)))
|
||||||
|
Download();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool AnyMapSelected()
|
||||||
|
{
|
||||||
|
foreach (var meta in PbrMapInfo.All)
|
||||||
|
{
|
||||||
|
if (!_asset.Maps.Contains(meta.Type))
|
||||||
|
continue;
|
||||||
|
if (_mapEnabled.TryGetValue(meta.Type, out var v) ? v : meta.DefaultEnabled)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Browser -----------------------------------------------------------
|
||||||
|
|
||||||
|
private void DrawBrowser()
|
||||||
|
{
|
||||||
|
_browseFoldout = EditorGUILayout.Foldout(_browseFoldout, "Browse catalog", true, EditorStyles.foldoutHeader);
|
||||||
|
if (!_browseFoldout)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using (new EditorGUI.DisabledScope(_busy))
|
||||||
|
{
|
||||||
|
if (!_grid.HasCatalog)
|
||||||
|
{
|
||||||
|
if (!_catalogLoading && !_autoLoadTried)
|
||||||
|
{
|
||||||
|
_autoLoadTried = true;
|
||||||
|
LoadCatalog();
|
||||||
|
}
|
||||||
|
using (new EditorGUILayout.HorizontalScope())
|
||||||
|
{
|
||||||
|
EditorGUILayout.LabelField(_catalogLoading
|
||||||
|
? "Loading material catalog…"
|
||||||
|
: "Catalog not loaded.");
|
||||||
|
using (new EditorGUI.DisabledScope(_catalogLoading))
|
||||||
|
if (GUILayout.Button("Load catalog", GUILayout.Width(110)))
|
||||||
|
LoadCatalog();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_grid.Draw(SelectAsset, LoadCatalog, "materials");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectAsset(AssetBrowserGrid.Entry a)
|
||||||
|
{
|
||||||
|
_urlInput = "https://ambientcg.com/a/" + a.Key;
|
||||||
|
GUI.FocusControl(null);
|
||||||
|
Fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadCatalog()
|
||||||
|
{
|
||||||
|
_catalogLoading = true;
|
||||||
|
_status = "Loading ambientCG material catalog…";
|
||||||
|
Repaint();
|
||||||
|
|
||||||
|
AmbientCgApi.FetchCatalog(
|
||||||
|
list =>
|
||||||
|
{
|
||||||
|
s_catalog = list;
|
||||||
|
_catalogLoading = false;
|
||||||
|
_grid.SetCatalog(list);
|
||||||
|
_status = $"Catalog loaded: {list.Count} materials.";
|
||||||
|
Repaint();
|
||||||
|
},
|
||||||
|
err =>
|
||||||
|
{
|
||||||
|
_catalogLoading = false;
|
||||||
|
_status = "Catalog error: " + err;
|
||||||
|
Debug.LogError("[ambientCG] " + err);
|
||||||
|
Repaint();
|
||||||
|
},
|
||||||
|
(loaded, total) =>
|
||||||
|
{
|
||||||
|
_status = $"Loading catalog… {loaded}/{total}";
|
||||||
|
Repaint();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Fetch -------------------------------------------------------------
|
||||||
|
|
||||||
|
private void Fetch()
|
||||||
|
{
|
||||||
|
string id = AmbientCgApi.ExtractId(_urlInput);
|
||||||
|
if (string.IsNullOrEmpty(id))
|
||||||
|
{
|
||||||
|
EditorUtility.DisplayDialog("ambientCG", "Could not read an asset ID from the URL.", "OK");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_busy = true;
|
||||||
|
_status = $"Fetching '{id}'…";
|
||||||
|
_asset = null;
|
||||||
|
_thumbnail = null;
|
||||||
|
_mapEnabled.Clear();
|
||||||
|
Repaint();
|
||||||
|
|
||||||
|
AmbientCgApi.FetchAsset(id, asset =>
|
||||||
|
{
|
||||||
|
_asset = asset;
|
||||||
|
EditorWebRequest.GetTexture(asset.ThumbnailUrl, tex => { _thumbnail = tex; Repaint(); }, _ => { });
|
||||||
|
|
||||||
|
var resolutions = asset.Resolutions();
|
||||||
|
if (!resolutions.Contains(_selectedRes))
|
||||||
|
_selectedRes = resolutions.Contains(DefaultRes) ? DefaultRes
|
||||||
|
: resolutions.Count > 0 ? resolutions[0] : DefaultRes;
|
||||||
|
var formats = asset.Formats(_selectedRes);
|
||||||
|
if (!formats.Contains(_selectedFmt))
|
||||||
|
_selectedFmt = formats.Contains(DefaultFmt) ? DefaultFmt
|
||||||
|
: formats.Count > 0 ? formats[0] : DefaultFmt;
|
||||||
|
|
||||||
|
_busy = false;
|
||||||
|
_status = asset.DataType == "Material"
|
||||||
|
? $"Ready: {asset.Maps.Count} map type(s), {resolutions.Count} resolution(s)."
|
||||||
|
: "Fetched, but this asset is not a material.";
|
||||||
|
Repaint();
|
||||||
|
}, err => Fail(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Download ----------------------------------------------------------
|
||||||
|
|
||||||
|
private void Download()
|
||||||
|
{
|
||||||
|
if (!_asset.TryGetZip(_selectedRes, _selectedFmt, out var zip))
|
||||||
|
{
|
||||||
|
Fail($"No ZIP for {_selectedRes}-{_selectedFmt}.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wanted = new List<PbrMapType>();
|
||||||
|
foreach (var meta in PbrMapInfo.All)
|
||||||
|
{
|
||||||
|
if (!_asset.Maps.Contains(meta.Type))
|
||||||
|
continue;
|
||||||
|
if (_mapEnabled.TryGetValue(meta.Type, out var v) ? v : meta.DefaultEnabled)
|
||||||
|
wanted.Add(meta.Type);
|
||||||
|
}
|
||||||
|
if (wanted.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
string displayName = _asset.Name;
|
||||||
|
string folder = $"{RootFolder}/{ImportUtil.SanitizeFileName(displayName)}";
|
||||||
|
|
||||||
|
folder = ImportUtil.ResolveFolderCollision(folder, "ambientCG");
|
||||||
|
if (folder == null)
|
||||||
|
return;
|
||||||
|
ImportUtil.EnsureFolder(folder);
|
||||||
|
|
||||||
|
string tempZip = Path.Combine(Path.GetTempPath(), zip.FileName);
|
||||||
|
|
||||||
|
_busy = true;
|
||||||
|
_status = $"Downloading {zip.FileName}…";
|
||||||
|
Repaint();
|
||||||
|
|
||||||
|
EditorWebRequest.DownloadFile(zip.Url, tempZip,
|
||||||
|
onDone: () => ExtractAndBuild(tempZip, folder, displayName, wanted),
|
||||||
|
onError: err =>
|
||||||
|
{
|
||||||
|
Fail(err);
|
||||||
|
},
|
||||||
|
onProgress: p =>
|
||||||
|
{
|
||||||
|
_status = $"Downloading {zip.FileName}… {p * 100f:0}%";
|
||||||
|
Repaint();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the selected maps out of the downloaded ZIP into the target folder,
|
||||||
|
/// then hands them to the shared PBR material builder. ZIP contents follow the
|
||||||
|
/// ambientCG convention "{Id}_{Res}-{Fmt}_{MapSuffix}.{ext}".
|
||||||
|
/// </summary>
|
||||||
|
private void ExtractAndBuild(string tempZip, string folder, string displayName, List<PbrMapType> wanted)
|
||||||
|
{
|
||||||
|
var results = new List<PbrMaterialBuilder.Downloaded>();
|
||||||
|
var missing = new List<PbrMapType>(wanted);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var archive = ZipFile.OpenRead(tempZip);
|
||||||
|
foreach (var entry in archive.Entries)
|
||||||
|
{
|
||||||
|
string nameNoExt = Path.GetFileNameWithoutExtension(entry.Name);
|
||||||
|
foreach (var type in wanted)
|
||||||
|
{
|
||||||
|
string suffix = "_" + AmbientCgApi.ZipSuffix(type);
|
||||||
|
if (!nameNoExt.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
string destPath = $"{folder}/{entry.Name}";
|
||||||
|
entry.ExtractToFile(Path.GetFullPath(destPath), overwrite: true);
|
||||||
|
results.Add(new PbrMaterialBuilder.Downloaded { Type = type, AssetPath = destPath });
|
||||||
|
missing.Remove(type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Fail($"ZIP extraction failed: {e.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { File.Delete(tempZip); } catch { /* temp file, best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.Count == 0)
|
||||||
|
{
|
||||||
|
Fail("No selected maps found inside the ZIP.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (var m in missing)
|
||||||
|
Debug.LogWarning($"[ambientCG] Map '{m}' not found inside the ZIP — skipped.");
|
||||||
|
|
||||||
|
AssetDatabase.Refresh();
|
||||||
|
|
||||||
|
int maxSize = ImportUtil.ResolutionToMaxSize(_selectedRes);
|
||||||
|
PbrMaterialBuilder.Build(folder, displayName, _asset.Id, maxSize, results);
|
||||||
|
|
||||||
|
_busy = false;
|
||||||
|
_status = $"Done: {results.Count} map(s) imported, material built in {folder}.";
|
||||||
|
Repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
private void Fail(string message)
|
||||||
|
{
|
||||||
|
_busy = false;
|
||||||
|
_status = "Error: " + message;
|
||||||
|
Debug.LogError("[ambientCG] " + message);
|
||||||
|
Repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b732bd5ab747dd54cb08525c089a1d8f
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using Chameleon.Editor.WebAssets;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using UnityEditor;
|
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.Networking;
|
|
||||||
|
|
||||||
namespace Chameleon.Editor.PolyHaven
|
namespace Chameleon.Editor.PolyHaven
|
||||||
{
|
{
|
||||||
@@ -34,36 +33,25 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
public string[] Categories = Array.Empty<string>();
|
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>
|
/// <summary>Parsed /files/{slug}: MapType -> resolution("2k") -> best file.</summary>
|
||||||
public sealed class PolyHavenFiles
|
public sealed class PolyHavenFiles
|
||||||
{
|
{
|
||||||
public readonly Dictionary<PolyHavenMapType, Dictionary<string, PolyHavenFile>> Maps =
|
public readonly Dictionary<PbrMapType, Dictionary<string, PolyHavenFile>> Maps =
|
||||||
new Dictionary<PolyHavenMapType, Dictionary<string, PolyHavenFile>>();
|
new Dictionary<PbrMapType, Dictionary<string, PolyHavenFile>>();
|
||||||
|
|
||||||
// Every resolution string seen across all maps (e.g. "1k","2k","4k"), sorted low->high.
|
// Every resolution string seen across all maps (e.g. "1k","2k","4k"), sorted low->high.
|
||||||
public List<string> Resolutions = new List<string>();
|
public List<string> Resolutions = new List<string>();
|
||||||
|
|
||||||
public bool Has(PolyHavenMapType type) => Maps.ContainsKey(type);
|
public bool Has(PbrMapType type) => Maps.ContainsKey(type);
|
||||||
|
|
||||||
public bool TryGet(PolyHavenMapType type, string res, out PolyHavenFile file)
|
public bool TryGet(PbrMapType type, string res, out PolyHavenFile file)
|
||||||
{
|
{
|
||||||
file = default;
|
file = default;
|
||||||
return Maps.TryGetValue(type, out var byRes) && byRes.TryGetValue(res, out file);
|
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>
|
/// <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)
|
public bool TryGetWithFallback(PbrMapType type, string res, out PolyHavenFile file, out string usedRes)
|
||||||
{
|
{
|
||||||
file = default;
|
file = default;
|
||||||
usedRes = null;
|
usedRes = null;
|
||||||
@@ -71,11 +59,11 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
return false;
|
return false;
|
||||||
if (byRes.TryGetValue(res, out file)) { usedRes = res; return true; }
|
if (byRes.TryGetValue(res, out file)) { usedRes = res; return true; }
|
||||||
|
|
||||||
int target = ResolutionRank(res);
|
int target = ImportUtil.ResolutionRank(res);
|
||||||
int bestRank = -1;
|
int bestRank = -1;
|
||||||
foreach (var kv in byRes)
|
foreach (var kv in byRes)
|
||||||
{
|
{
|
||||||
int rank = ResolutionRank(kv.Key);
|
int rank = ImportUtil.ResolutionRank(kv.Key);
|
||||||
if (rank <= target && rank > bestRank)
|
if (rank <= target && rank > bestRank)
|
||||||
{
|
{
|
||||||
bestRank = rank;
|
bestRank = rank;
|
||||||
@@ -87,7 +75,7 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
{
|
{
|
||||||
foreach (var kv in byRes)
|
foreach (var kv in byRes)
|
||||||
{
|
{
|
||||||
int rank = ResolutionRank(kv.Key);
|
int rank = ImportUtil.ResolutionRank(kv.Key);
|
||||||
if (bestRank == -1 || rank < bestRank)
|
if (bestRank == -1 || rank < bestRank)
|
||||||
{
|
{
|
||||||
bestRank = rank;
|
bestRank = rank;
|
||||||
@@ -98,23 +86,27 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
}
|
}
|
||||||
return usedRes != null;
|
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>
|
/// <summary>
|
||||||
/// Async client for the public Poly Haven API (same endpoints the Blender/UE5 plugins use).
|
/// Async client for the public Poly Haven API (same endpoints the Blender/UE5
|
||||||
/// All requests poll on EditorApplication.update — never blocks the editor.
|
/// plugins use). Transport is the shared non-blocking EditorWebRequest.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class PolyHavenApi
|
public static class PolyHavenApi
|
||||||
{
|
{
|
||||||
private const string ApiBase = "https://api.polyhaven.com";
|
private const string ApiBase = "https://api.polyhaven.com";
|
||||||
private const string UserAgent = "Chameleon-Unity-PolyHavenImporter/1.0";
|
|
||||||
|
// 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.
|
// Best format preference per map: lossless where it matters, jpg otherwise.
|
||||||
private static readonly string[] LosslessPref = { "png", "exr", "jpg" };
|
private static readonly string[] LosslessPref = { "png", "exr", "jpg" };
|
||||||
@@ -147,13 +139,11 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
|
|
||||||
public static void FetchInfo(string slug, Action<PolyHavenInfo> onDone, Action<string> onError)
|
public static void FetchInfo(string slug, Action<PolyHavenInfo> onDone, Action<string> onError)
|
||||||
{
|
{
|
||||||
var req = UnityWebRequest.Get($"{ApiBase}/info/{slug}");
|
EditorWebRequest.GetText($"{ApiBase}/info/{slug}", text =>
|
||||||
req.SetRequestHeader("User-Agent", UserAgent);
|
|
||||||
Send(req, () =>
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var o = JObject.Parse(req.downloadHandler.text);
|
var o = JObject.Parse(text);
|
||||||
var info = new PolyHavenInfo
|
var info = new PolyHavenInfo
|
||||||
{
|
{
|
||||||
Slug = slug,
|
Slug = slug,
|
||||||
@@ -173,32 +163,29 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
}, onError);
|
}, onError);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Loads the full texture catalog (keyed by slug) for the browser grid.</summary>
|
/// <summary>Loads the full texture catalog as browser-grid entries.</summary>
|
||||||
public static void FetchAssets(string type, Action<List<PolyHavenAsset>> onDone, Action<string> onError)
|
public static void FetchAssets(string type, Action<List<AssetBrowserGrid.Entry>> onDone, Action<string> onError)
|
||||||
{
|
{
|
||||||
var req = UnityWebRequest.Get($"{ApiBase}/assets?t={type}");
|
EditorWebRequest.GetText($"{ApiBase}/assets?t={type}", text =>
|
||||||
req.SetRequestHeader("User-Agent", UserAgent);
|
|
||||||
Send(req, () =>
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var root = JObject.Parse(req.downloadHandler.text);
|
var root = JObject.Parse(text);
|
||||||
var list = new List<PolyHavenAsset>(root.Count);
|
var list = new List<AssetBrowserGrid.Entry>(root.Count);
|
||||||
foreach (var prop in root)
|
foreach (var prop in root)
|
||||||
{
|
{
|
||||||
if (prop.Value is not JObject o) continue;
|
if (prop.Value is not JObject o) continue;
|
||||||
var a = new PolyHavenAsset
|
string name = (string)o["name"] ?? prop.Key;
|
||||||
|
var e = new AssetBrowserGrid.Entry
|
||||||
{
|
{
|
||||||
Slug = prop.Key,
|
Key = prop.Key,
|
||||||
Name = (string)o["name"] ?? prop.Key,
|
Name = name,
|
||||||
ThumbnailUrl = (string)o["thumbnail_url"] ?? ThumbnailUrl(prop.Key),
|
ThumbnailUrl = (string)o["thumbnail_url"] ?? ThumbnailUrl(prop.Key),
|
||||||
Categories = ToStringArray(o["categories"]),
|
|
||||||
Tags = ToStringArray(o["tags"]),
|
|
||||||
};
|
};
|
||||||
a.Search = (a.Name + " " + a.Slug + " " +
|
e.Search = (name + " " + prop.Key + " " +
|
||||||
string.Join(" ", a.Categories) + " " +
|
JoinStrings(o["categories"]) + " " +
|
||||||
string.Join(" ", a.Tags)).ToLowerInvariant();
|
JoinStrings(o["tags"])).ToLowerInvariant();
|
||||||
list.Add(a);
|
list.Add(e);
|
||||||
}
|
}
|
||||||
list.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase));
|
list.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
onDone?.Invoke(list);
|
onDone?.Invoke(list);
|
||||||
@@ -209,52 +196,25 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
|
|
||||||
public static void FetchFiles(string slug, Action<PolyHavenFiles> onDone, Action<string> onError)
|
public static void FetchFiles(string slug, Action<PolyHavenFiles> onDone, Action<string> onError)
|
||||||
{
|
{
|
||||||
var req = UnityWebRequest.Get($"{ApiBase}/files/{slug}");
|
EditorWebRequest.GetText($"{ApiBase}/files/{slug}", text =>
|
||||||
req.SetRequestHeader("User-Agent", UserAgent);
|
|
||||||
Send(req, () =>
|
|
||||||
{
|
{
|
||||||
try { onDone?.Invoke(ParseFiles(req.downloadHandler.text)); }
|
try { onDone?.Invoke(ParseFiles(text)); }
|
||||||
catch (Exception e) { onError?.Invoke($"Files parse failed: {e.Message}"); }
|
catch (Exception e) { onError?.Invoke($"Files parse failed: {e.Message}"); }
|
||||||
}, onError);
|
}, 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 -----------------------------------------------------------
|
// ---- Parsing -----------------------------------------------------------
|
||||||
|
|
||||||
private static string[] ToStringArray(JToken token)
|
private static string JoinStrings(JToken token)
|
||||||
{
|
{
|
||||||
if (token is not JArray arr) return Array.Empty<string>();
|
if (token is not JArray arr) return "";
|
||||||
var list = new List<string>(arr.Count);
|
var list = new List<string>(arr.Count);
|
||||||
foreach (var t in arr)
|
foreach (var t in arr)
|
||||||
{
|
{
|
||||||
var s = (string)t;
|
var s = (string)t;
|
||||||
if (!string.IsNullOrEmpty(s)) list.Add(s);
|
if (!string.IsNullOrEmpty(s)) list.Add(s);
|
||||||
}
|
}
|
||||||
return list.ToArray();
|
return string.Join(" ", list);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PolyHavenFiles ParseFiles(string json)
|
private static PolyHavenFiles ParseFiles(string json)
|
||||||
@@ -263,12 +223,13 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
var result = new PolyHavenFiles();
|
var result = new PolyHavenFiles();
|
||||||
var resSet = new HashSet<string>();
|
var resSet = new HashSet<string>();
|
||||||
|
|
||||||
foreach (var meta in PolyHavenMapInfo.All)
|
foreach (var (type, apiKey) in ApiKeys)
|
||||||
{
|
{
|
||||||
if (root[meta.ApiKey] is not JObject byRes)
|
if (root[apiKey] is not JObject byRes)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var pref = (meta.IsNormal || meta.Type == PolyHavenMapType.Displacement)
|
var meta = PbrMapInfo.Get(type);
|
||||||
|
var pref = (meta.IsNormal || type == PbrMapType.Displacement)
|
||||||
? LosslessPref
|
? LosslessPref
|
||||||
: LossyPref;
|
: LossyPref;
|
||||||
|
|
||||||
@@ -298,48 +259,13 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (perRes.Count > 0)
|
if (perRes.Count > 0)
|
||||||
result.Maps[meta.Type] = perRes;
|
result.Maps[type] = perRes;
|
||||||
}
|
}
|
||||||
|
|
||||||
var resList = new List<string>(resSet);
|
var resList = new List<string>(resSet);
|
||||||
resList.Sort((x, y) => PolyHavenFiles.ResolutionRank(x).CompareTo(PolyHavenFiles.ResolutionRank(y)));
|
resList.Sort((x, y) => ImportUtil.ResolutionRank(x).CompareTo(ImportUtil.ResolutionRank(y)));
|
||||||
result.Resolutions = resList;
|
result.Resolutions = resList;
|
||||||
return result;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Chameleon.Editor.WebAssets;
|
||||||
using UnityEditor;
|
using UnityEditor;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace Chameleon.Editor.PolyHaven
|
namespace Chameleon.Editor.PolyHaven
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Editor window: paste a Poly Haven asset URL, fetch its maps, pick resolution +
|
/// Editor window: browse/paste a Poly Haven asset, fetch its maps, pick resolution +
|
||||||
/// maps, then download and auto-build a URP/Lit material under Assets/Poly Haven/{Name}/.
|
/// maps, then download and auto-build a URP/Lit material under Assets/Poly Haven/{Name}/.
|
||||||
/// No browser — asset selection stays on polyhaven.com.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PolyHavenImporterWindow : EditorWindow
|
public sealed class PolyHavenImporterWindow : EditorWindow
|
||||||
{
|
{
|
||||||
@@ -22,29 +21,21 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
private PolyHavenFiles _files;
|
private PolyHavenFiles _files;
|
||||||
private Texture2D _thumbnail;
|
private Texture2D _thumbnail;
|
||||||
private string _selectedRes = DefaultRes;
|
private string _selectedRes = DefaultRes;
|
||||||
private readonly Dictionary<PolyHavenMapType, bool> _mapEnabled = new Dictionary<PolyHavenMapType, bool>();
|
private readonly Dictionary<PbrMapType, bool> _mapEnabled = new Dictionary<PbrMapType, bool>();
|
||||||
|
|
||||||
private bool _busy;
|
private bool _busy;
|
||||||
private string _status = "";
|
private string _status = "";
|
||||||
private Vector2 _scroll;
|
private Vector2 _scroll;
|
||||||
|
|
||||||
// ---- Browser ----------------------------------------------------------
|
// ---- Browser ----------------------------------------------------------
|
||||||
private const int MaxResults = 150;
|
|
||||||
private const int MaxThumbInFlight = 6;
|
|
||||||
|
|
||||||
// Catalog cached for the whole session (survives window close, cleared on domain reload).
|
// Catalog cached for the whole session (survives window close, cleared on domain reload).
|
||||||
private static List<PolyHavenAsset> s_catalog;
|
private static List<AssetBrowserGrid.Entry> s_catalog;
|
||||||
|
|
||||||
|
private AssetBrowserGrid _grid;
|
||||||
private bool _browseFoldout = true;
|
private bool _browseFoldout = true;
|
||||||
private bool _autoLoadTried;
|
private bool _autoLoadTried;
|
||||||
private bool _catalogLoading;
|
private bool _catalogLoading;
|
||||||
private string _searchQuery = "";
|
|
||||||
private readonly List<PolyHavenAsset> _filtered = new List<PolyHavenAsset>();
|
|
||||||
private Vector2 _gridScroll;
|
|
||||||
|
|
||||||
private readonly Dictionary<string, Texture2D> _thumbs = new Dictionary<string, Texture2D>();
|
|
||||||
private readonly HashSet<string> _thumbRequested = new HashSet<string>();
|
|
||||||
private int _thumbsInFlight;
|
|
||||||
|
|
||||||
[MenuItem("Tools/Poly Haven Importer")]
|
[MenuItem("Tools/Poly Haven Importer")]
|
||||||
public static void Open()
|
public static void Open()
|
||||||
@@ -55,11 +46,13 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
|
|
||||||
private void OnEnable()
|
private void OnEnable()
|
||||||
{
|
{
|
||||||
|
_grid = new AssetBrowserGrid(Repaint);
|
||||||
|
|
||||||
// Catalog may already be cached from a previous window instance this session.
|
// Catalog may already be cached from a previous window instance this session.
|
||||||
if (s_catalog != null)
|
if (s_catalog != null)
|
||||||
{
|
{
|
||||||
_autoLoadTried = true;
|
_autoLoadTried = true;
|
||||||
ApplyFilter();
|
_grid.SetCatalog(s_catalog);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +127,7 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
|
|
||||||
EditorGUILayout.Space();
|
EditorGUILayout.Space();
|
||||||
EditorGUILayout.LabelField("Maps", EditorStyles.miniBoldLabel);
|
EditorGUILayout.LabelField("Maps", EditorStyles.miniBoldLabel);
|
||||||
foreach (var meta in PolyHavenMapInfo.All)
|
foreach (var meta in PbrMapInfo.All)
|
||||||
{
|
{
|
||||||
if (!_files.Has(meta.Type))
|
if (!_files.Has(meta.Type))
|
||||||
continue;
|
continue;
|
||||||
@@ -153,11 +146,11 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
|
|
||||||
private bool AnyMapSelected()
|
private bool AnyMapSelected()
|
||||||
{
|
{
|
||||||
foreach (var meta in PolyHavenMapInfo.All)
|
foreach (var meta in PbrMapInfo.All)
|
||||||
if (_files.Has(meta.Type) && _mapEnabled.TryGetValue(meta.Type, out var v) && v)
|
if (_files.Has(meta.Type) && _mapEnabled.TryGetValue(meta.Type, out var v) && v)
|
||||||
return true;
|
return true;
|
||||||
// Fall back to defaults if the user hasn't toggled anything yet.
|
// Fall back to defaults if the user hasn't toggled anything yet.
|
||||||
foreach (var meta in PolyHavenMapInfo.All)
|
foreach (var meta in PbrMapInfo.All)
|
||||||
if (_files.Has(meta.Type) && meta.DefaultEnabled && !_mapEnabled.ContainsKey(meta.Type))
|
if (_files.Has(meta.Type) && meta.DefaultEnabled && !_mapEnabled.ContainsKey(meta.Type))
|
||||||
return true;
|
return true;
|
||||||
return false;
|
return false;
|
||||||
@@ -173,7 +166,7 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
|
|
||||||
using (new EditorGUI.DisabledScope(_busy))
|
using (new EditorGUI.DisabledScope(_busy))
|
||||||
{
|
{
|
||||||
if (s_catalog == null)
|
if (!_grid.HasCatalog)
|
||||||
{
|
{
|
||||||
// Auto-load once when the browser is first opened; still offer a manual retry.
|
// Auto-load once when the browser is first opened; still offer a manual retry.
|
||||||
if (!_catalogLoading && !_autoLoadTried)
|
if (!_catalogLoading && !_autoLoadTried)
|
||||||
@@ -193,105 +186,13 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using (new EditorGUILayout.HorizontalScope())
|
_grid.Draw(SelectAsset, LoadCatalog, "textures");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectAsset(AssetBrowserGrid.Entry a)
|
||||||
{
|
{
|
||||||
EditorGUI.BeginChangeCheck();
|
_urlInput = "https://polyhaven.com/a/" + a.Key;
|
||||||
_searchQuery = EditorGUILayout.TextField("Search", _searchQuery);
|
|
||||||
if (EditorGUI.EndChangeCheck())
|
|
||||||
ApplyFilter();
|
|
||||||
if (GUILayout.Button("↻", GUILayout.Width(26)))
|
|
||||||
LoadCatalog();
|
|
||||||
}
|
|
||||||
|
|
||||||
int shown = Mathf.Min(_filtered.Count, MaxResults);
|
|
||||||
string cap = _filtered.Count > MaxResults ? $", showing first {MaxResults}" : "";
|
|
||||||
EditorGUILayout.LabelField(
|
|
||||||
$"{_filtered.Count} match(es){cap} of {s_catalog.Count} textures.",
|
|
||||||
EditorStyles.miniLabel);
|
|
||||||
|
|
||||||
DrawGrid(shown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawGrid(int count)
|
|
||||||
{
|
|
||||||
const float cell = 104f;
|
|
||||||
const float pad = 6f;
|
|
||||||
float viewW = EditorGUIUtility.currentViewWidth - 24f;
|
|
||||||
int cols = Mathf.Max(1, Mathf.FloorToInt((viewW + pad) / (cell + pad)));
|
|
||||||
|
|
||||||
_gridScroll = EditorGUILayout.BeginScrollView(_gridScroll, GUILayout.Height(320));
|
|
||||||
|
|
||||||
int rows = Mathf.CeilToInt(count / (float)cols);
|
|
||||||
for (int row = 0; row < rows; row++)
|
|
||||||
{
|
|
||||||
using (new EditorGUILayout.HorizontalScope())
|
|
||||||
{
|
|
||||||
for (int c = 0; c < cols; c++)
|
|
||||||
{
|
|
||||||
int i = row * cols + c;
|
|
||||||
if (i >= count)
|
|
||||||
{
|
|
||||||
GUILayout.FlexibleSpace();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
DrawCell(_filtered[i], cell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
EditorGUILayout.EndScrollView();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawCell(PolyHavenAsset a, float size)
|
|
||||||
{
|
|
||||||
using (new EditorGUILayout.VerticalScope(GUILayout.Width(size)))
|
|
||||||
{
|
|
||||||
var thumbRect = GUILayoutUtility.GetRect(size, size - 22f,
|
|
||||||
GUILayout.Width(size), GUILayout.Height(size - 22f));
|
|
||||||
|
|
||||||
var tex = GetThumb(a);
|
|
||||||
if (tex)
|
|
||||||
GUI.DrawTexture(thumbRect, tex, ScaleMode.ScaleToFit);
|
|
||||||
else
|
|
||||||
EditorGUI.DrawRect(thumbRect, new Color(0f, 0f, 0f, 0.15f));
|
|
||||||
|
|
||||||
// Transparent hit target over the thumbnail.
|
|
||||||
if (GUI.Button(thumbRect, new GUIContent("", a.Name), GUIStyle.none))
|
|
||||||
SelectAsset(a);
|
|
||||||
|
|
||||||
EditorGUILayout.LabelField(new GUIContent(a.Name, a.Name),
|
|
||||||
EditorStyles.miniLabel, GUILayout.Width(size));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Lazy, throttled thumbnail loader — only visible cells request, capped in-flight.</summary>
|
|
||||||
private Texture2D GetThumb(PolyHavenAsset a)
|
|
||||||
{
|
|
||||||
if (_thumbs.TryGetValue(a.Slug, out var tex))
|
|
||||||
{
|
|
||||||
if (tex)
|
|
||||||
return tex;
|
|
||||||
// Cached texture was destroyed (e.g. play-mode cleanup) — purge and re-request.
|
|
||||||
_thumbs.Remove(a.Slug);
|
|
||||||
_thumbRequested.Remove(a.Slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't add to _thumbRequested until a slot is free, so it retries next repaint.
|
|
||||||
if (!_thumbRequested.Contains(a.Slug) && _thumbsInFlight < MaxThumbInFlight)
|
|
||||||
{
|
|
||||||
_thumbRequested.Add(a.Slug);
|
|
||||||
_thumbsInFlight++;
|
|
||||||
PolyHavenApi.FetchThumbnail(a.ThumbnailUrl,
|
|
||||||
t => { _thumbs[a.Slug] = t; _thumbsInFlight--; Repaint(); },
|
|
||||||
_ => { _thumbsInFlight--; Repaint(); }); // keep as requested: no retry-spam on error
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SelectAsset(PolyHavenAsset a)
|
|
||||||
{
|
|
||||||
_urlInput = "https://polyhaven.com/a/" + a.Slug;
|
|
||||||
GUI.FocusControl(null);
|
GUI.FocusControl(null);
|
||||||
Fetch(); // url filled -> fetch, then everything as before
|
Fetch(); // url filled -> fetch, then everything as before
|
||||||
}
|
}
|
||||||
@@ -307,7 +208,7 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
{
|
{
|
||||||
s_catalog = list;
|
s_catalog = list;
|
||||||
_catalogLoading = false;
|
_catalogLoading = false;
|
||||||
ApplyFilter();
|
_grid.SetCatalog(list);
|
||||||
_status = $"Catalog loaded: {list.Count} textures.";
|
_status = $"Catalog loaded: {list.Count} textures.";
|
||||||
Repaint();
|
Repaint();
|
||||||
},
|
},
|
||||||
@@ -320,30 +221,6 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyFilter()
|
|
||||||
{
|
|
||||||
_filtered.Clear();
|
|
||||||
if (s_catalog == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var q = _searchQuery?.Trim().ToLowerInvariant();
|
|
||||||
if (string.IsNullOrEmpty(q))
|
|
||||||
{
|
|
||||||
_filtered.AddRange(s_catalog);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var terms = q.Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
foreach (var a in s_catalog)
|
|
||||||
{
|
|
||||||
bool all = true;
|
|
||||||
foreach (var t in terms)
|
|
||||||
if (a.Search.IndexOf(t, StringComparison.Ordinal) < 0) { all = false; break; }
|
|
||||||
if (all)
|
|
||||||
_filtered.Add(a);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Fetch -------------------------------------------------------------
|
// ---- Fetch -------------------------------------------------------------
|
||||||
|
|
||||||
private void Fetch()
|
private void Fetch()
|
||||||
@@ -367,7 +244,7 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
{
|
{
|
||||||
_info = info;
|
_info = info;
|
||||||
Repaint();
|
Repaint();
|
||||||
PolyHavenApi.FetchThumbnail(info.ThumbnailUrl, tex => { _thumbnail = tex; Repaint(); }, _ => { });
|
EditorWebRequest.GetTexture(info.ThumbnailUrl, tex => { _thumbnail = tex; Repaint(); }, _ => { });
|
||||||
|
|
||||||
PolyHavenApi.FetchFiles(slug, files =>
|
PolyHavenApi.FetchFiles(slug, files =>
|
||||||
{
|
{
|
||||||
@@ -391,8 +268,8 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
private void Download()
|
private void Download()
|
||||||
{
|
{
|
||||||
// Resolve which maps to fetch.
|
// Resolve which maps to fetch.
|
||||||
var wanted = new List<PolyHavenMapType>();
|
var wanted = new List<PbrMapType>();
|
||||||
foreach (var meta in PolyHavenMapInfo.All)
|
foreach (var meta in PbrMapInfo.All)
|
||||||
{
|
{
|
||||||
if (!_files.Has(meta.Type)) continue;
|
if (!_files.Has(meta.Type)) continue;
|
||||||
bool on = _mapEnabled.TryGetValue(meta.Type, out var v) ? v : meta.DefaultEnabled;
|
bool on = _mapEnabled.TryGetValue(meta.Type, out var v) ? v : meta.DefaultEnabled;
|
||||||
@@ -401,39 +278,32 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
|
|
||||||
// ARM fallback: if the asset packs AO/Rough/Metal only into ARM (no dedicated
|
// ARM fallback: if the asset packs AO/Rough/Metal only into ARM (no dedicated
|
||||||
// map for a channel), pull ARM so the builder can unpack the missing channels.
|
// map for a channel), pull ARM so the builder can unpack the missing channels.
|
||||||
if (_files.Has(PolyHavenMapType.Arm) && !wanted.Contains(PolyHavenMapType.Arm))
|
if (_files.Has(PbrMapType.Arm) && !wanted.Contains(PbrMapType.Arm))
|
||||||
{
|
{
|
||||||
bool channelMissing = !_files.Has(PolyHavenMapType.Metal)
|
bool channelMissing = !_files.Has(PbrMapType.Metal)
|
||||||
|| !_files.Has(PolyHavenMapType.Rough)
|
|| !_files.Has(PbrMapType.Rough)
|
||||||
|| !_files.Has(PolyHavenMapType.AO);
|
|| !_files.Has(PbrMapType.AO);
|
||||||
if (channelMissing)
|
if (channelMissing)
|
||||||
wanted.Add(PolyHavenMapType.Arm);
|
wanted.Add(PbrMapType.Arm);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wanted.Count == 0)
|
if (wanted.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
string displayName = _info.Name;
|
string displayName = _info.Name;
|
||||||
string folder = $"{RootFolder}/{PolyHavenMaterialBuilder.SanitizeFileName(displayName)}";
|
string folder = $"{RootFolder}/{ImportUtil.SanitizeFileName(displayName)}";
|
||||||
|
|
||||||
// Folder-collision handling (non-destructive default).
|
folder = ImportUtil.ResolveFolderCollision(folder, "Poly Haven");
|
||||||
if (AssetDatabase.IsValidFolder(folder))
|
if (folder == null)
|
||||||
{
|
return;
|
||||||
int choice = EditorUtility.DisplayDialogComplex("Poly Haven",
|
ImportUtil.EnsureFolder(folder);
|
||||||
$"Folder already exists:\n{folder}", "New folder", "Cancel", "Overwrite");
|
|
||||||
if (choice == 1) return; // Cancel
|
|
||||||
if (choice == 0) // New folder
|
|
||||||
folder = AssetDatabase.GenerateUniqueAssetPath(folder);
|
|
||||||
// choice == 2 => Overwrite: reuse folder as-is.
|
|
||||||
}
|
|
||||||
EnsureFolder(folder);
|
|
||||||
|
|
||||||
int maxSize = ResolutionToMaxSize(_selectedRes);
|
int maxSize = ImportUtil.ResolutionToMaxSize(_selectedRes);
|
||||||
_busy = true;
|
_busy = true;
|
||||||
_status = $"Downloading {wanted.Count} map(s) at {_selectedRes.ToUpperInvariant()}…";
|
_status = $"Downloading {wanted.Count} map(s) at {_selectedRes.ToUpperInvariant()}…";
|
||||||
Repaint();
|
Repaint();
|
||||||
|
|
||||||
var results = new List<PolyHavenMaterialBuilder.Downloaded>();
|
var results = new List<PbrMaterialBuilder.Downloaded>();
|
||||||
var failures = new List<string>();
|
var failures = new List<string>();
|
||||||
int remaining = wanted.Count;
|
int remaining = wanted.Count;
|
||||||
|
|
||||||
@@ -458,7 +328,7 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
PolyHavenMaterialBuilder.Build(folder, displayName, _info.Slug, maxSize, results);
|
PbrMaterialBuilder.Build(folder, displayName, _info.Slug, maxSize, results);
|
||||||
_busy = false;
|
_busy = false;
|
||||||
_status = $"Done: {results.Count} map(s) imported, material built in {folder}.";
|
_status = $"Done: {results.Count} map(s) imported, material built in {folder}.";
|
||||||
if (failures.Count > 0)
|
if (failures.Count > 0)
|
||||||
@@ -477,14 +347,14 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
if (usedRes != _selectedRes)
|
if (usedRes != _selectedRes)
|
||||||
Debug.LogWarning($"[PolyHaven] {type}: {_selectedRes} unavailable, using {usedRes}.");
|
Debug.LogWarning($"[PolyHaven] {type}: {_selectedRes} unavailable, using {usedRes}.");
|
||||||
|
|
||||||
var meta = PolyHavenMapInfo.Get(type);
|
var meta = PbrMapInfo.Get(type);
|
||||||
string fileName = $"{_info.Slug}_{meta.Suffix}.{file.Format}";
|
string fileName = $"{_info.Slug}_{meta.Suffix}.{file.Format}";
|
||||||
string destPath = $"{folder}/{fileName}";
|
string destPath = $"{folder}/{fileName}";
|
||||||
|
|
||||||
PolyHavenApi.DownloadFile(file, Path.GetFullPath(destPath),
|
EditorWebRequest.DownloadFile(file.Url, Path.GetFullPath(destPath),
|
||||||
onDone: () =>
|
onDone: () =>
|
||||||
{
|
{
|
||||||
results.Add(new PolyHavenMaterialBuilder.Downloaded { Type = type, AssetPath = destPath });
|
results.Add(new PbrMaterialBuilder.Downloaded { Type = type, AssetPath = destPath });
|
||||||
FinishOne();
|
FinishOne();
|
||||||
},
|
},
|
||||||
onError: err =>
|
onError: err =>
|
||||||
@@ -504,31 +374,5 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
Debug.LogError("[PolyHaven] " + message);
|
Debug.LogError("[PolyHaven] " + message);
|
||||||
Repaint();
|
Repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int ResolutionToMaxSize(string res)
|
|
||||||
{
|
|
||||||
int k = PolyHavenFiles.ResolutionRank(res);
|
|
||||||
int px = k * 1024;
|
|
||||||
// Clamp to a valid Unity max texture size (power of two up to 16384).
|
|
||||||
int[] valid = { 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384 };
|
|
||||||
foreach (var v in valid)
|
|
||||||
if (px <= v) return v;
|
|
||||||
return 16384;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void EnsureFolder(string folder)
|
|
||||||
{
|
|
||||||
if (AssetDatabase.IsValidFolder(folder))
|
|
||||||
return;
|
|
||||||
var parts = folder.Split('/');
|
|
||||||
string cur = parts[0]; // "Assets"
|
|
||||||
for (int i = 1; i < parts.Length; i++)
|
|
||||||
{
|
|
||||||
string next = $"{cur}/{parts[i]}";
|
|
||||||
if (!AssetDatabase.IsValidFolder(next))
|
|
||||||
AssetDatabase.CreateFolder(cur, parts[i]);
|
|
||||||
cur = next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace Chameleon.Editor.PolyHaven
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The PBR map types the importer understands. Values map onto Poly Haven's
|
|
||||||
/// dynamic file-tree keys (e.g. "nor_gl", "Rough"). "Arm" is the packed
|
|
||||||
/// AO/Rough/Metal fallback some assets ship instead of separate maps.
|
|
||||||
/// </summary>
|
|
||||||
public enum PolyHavenMapType
|
|
||||||
{
|
|
||||||
Diffuse,
|
|
||||||
NormalGL,
|
|
||||||
Rough,
|
|
||||||
AO,
|
|
||||||
Metal,
|
|
||||||
Displacement,
|
|
||||||
Arm,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Static per-map metadata: API key, file suffix, colour space, defaults.</summary>
|
|
||||||
public static class PolyHavenMapInfo
|
|
||||||
{
|
|
||||||
public readonly struct Meta
|
|
||||||
{
|
|
||||||
public readonly PolyHavenMapType Type;
|
|
||||||
public readonly string ApiKey; // exact key in the /files JSON
|
|
||||||
public readonly string Suffix; // file name suffix on disk
|
|
||||||
public readonly bool SRGB; // true => colour, false => linear data
|
|
||||||
public readonly bool IsNormal; // import as TextureImporterType.NormalMap
|
|
||||||
public readonly bool DefaultEnabled; // pre-checked in the UI when available
|
|
||||||
public readonly string Label; // UI label
|
|
||||||
|
|
||||||
public Meta(PolyHavenMapType type, string apiKey, string suffix, bool srgb,
|
|
||||||
bool isNormal, bool defaultEnabled, string label)
|
|
||||||
{
|
|
||||||
Type = type;
|
|
||||||
ApiKey = apiKey;
|
|
||||||
Suffix = suffix;
|
|
||||||
SRGB = srgb;
|
|
||||||
IsNormal = isNormal;
|
|
||||||
DefaultEnabled = defaultEnabled;
|
|
||||||
Label = label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Order here defines UI order.
|
|
||||||
public static readonly Meta[] All =
|
|
||||||
{
|
|
||||||
new Meta(PolyHavenMapType.Diffuse, "Diffuse", "diffuse", true, false, true, "Diffuse (Albedo)"),
|
|
||||||
new Meta(PolyHavenMapType.NormalGL, "nor_gl", "normal", false, true, true, "Normal (OpenGL)"),
|
|
||||||
new Meta(PolyHavenMapType.Rough, "Rough", "roughness", false, false, true, "Roughness"),
|
|
||||||
new Meta(PolyHavenMapType.AO, "AO", "ao", false, false, true, "Ambient Occlusion"),
|
|
||||||
new Meta(PolyHavenMapType.Metal, "Metal", "metallic", false, false, true, "Metallic"),
|
|
||||||
new Meta(PolyHavenMapType.Displacement, "Displacement", "height", false, false, false, "Displacement (Height)"),
|
|
||||||
new Meta(PolyHavenMapType.Arm, "arm", "arm", false, false, false, "ARM (packed AO/Rough/Metal)"),
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly Dictionary<PolyHavenMapType, Meta> ByType = BuildLookup();
|
|
||||||
|
|
||||||
private static Dictionary<PolyHavenMapType, Meta> BuildLookup()
|
|
||||||
{
|
|
||||||
var d = new Dictionary<PolyHavenMapType, Meta>(All.Length);
|
|
||||||
foreach (var m in All)
|
|
||||||
d[m.Type] = m;
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Meta Get(PolyHavenMapType type) => ByType[type];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: d47448fcb2873654d8d7b4369c6c5da4
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 3c242963f05cc5545b0034908b5807f1
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: be7bdf4fbe283ec4cae8f0ec0c6a9de2
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Chameleon.Editor.WebAssets
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reusable catalog browser for importer windows: search field + responsive
|
||||||
|
/// thumbnail grid with lazy, throttled thumbnail loading. The host window owns
|
||||||
|
/// the catalog data and reacts to cell clicks via the onSelect callback.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AssetBrowserGrid
|
||||||
|
{
|
||||||
|
public sealed class Entry
|
||||||
|
{
|
||||||
|
public string Key; // slug / asset id
|
||||||
|
public string Name; // display name (cell label)
|
||||||
|
public string ThumbnailUrl;
|
||||||
|
public string Search; // pre-lowered haystack for filtering
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int MaxResults = 150;
|
||||||
|
private const int MaxThumbInFlight = 6;
|
||||||
|
|
||||||
|
private readonly Action _repaint;
|
||||||
|
|
||||||
|
private List<Entry> _catalog;
|
||||||
|
private readonly List<Entry> _filtered = new List<Entry>();
|
||||||
|
private string _searchQuery = "";
|
||||||
|
private Vector2 _gridScroll;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, Texture2D> _thumbs = new Dictionary<string, Texture2D>();
|
||||||
|
private readonly HashSet<string> _thumbRequested = new HashSet<string>();
|
||||||
|
private int _thumbsInFlight;
|
||||||
|
|
||||||
|
public AssetBrowserGrid(Action repaint)
|
||||||
|
{
|
||||||
|
_repaint = repaint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasCatalog => _catalog != null;
|
||||||
|
|
||||||
|
public void SetCatalog(List<Entry> catalog)
|
||||||
|
{
|
||||||
|
_catalog = catalog;
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Draws search field, match count and the grid. Call only when HasCatalog.</summary>
|
||||||
|
public void Draw(Action<Entry> onSelect, Action reloadCatalog, string itemNoun, float gridHeight = 320f)
|
||||||
|
{
|
||||||
|
using (new EditorGUILayout.HorizontalScope())
|
||||||
|
{
|
||||||
|
EditorGUI.BeginChangeCheck();
|
||||||
|
_searchQuery = EditorGUILayout.TextField("Search", _searchQuery);
|
||||||
|
if (EditorGUI.EndChangeCheck())
|
||||||
|
ApplyFilter();
|
||||||
|
if (reloadCatalog != null && GUILayout.Button("↻", GUILayout.Width(26)))
|
||||||
|
reloadCatalog();
|
||||||
|
}
|
||||||
|
|
||||||
|
int shown = Mathf.Min(_filtered.Count, MaxResults);
|
||||||
|
string cap = _filtered.Count > MaxResults ? $", showing first {MaxResults}" : "";
|
||||||
|
EditorGUILayout.LabelField(
|
||||||
|
$"{_filtered.Count} match(es){cap} of {_catalog.Count} {itemNoun}.",
|
||||||
|
EditorStyles.miniLabel);
|
||||||
|
|
||||||
|
DrawGrid(shown, onSelect, gridHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawGrid(int count, Action<Entry> onSelect, float gridHeight)
|
||||||
|
{
|
||||||
|
const float cell = 104f;
|
||||||
|
const float pad = 6f;
|
||||||
|
float viewW = EditorGUIUtility.currentViewWidth - 24f;
|
||||||
|
int cols = Mathf.Max(1, Mathf.FloorToInt((viewW + pad) / (cell + pad)));
|
||||||
|
|
||||||
|
_gridScroll = EditorGUILayout.BeginScrollView(_gridScroll, GUILayout.Height(gridHeight));
|
||||||
|
|
||||||
|
int rows = Mathf.CeilToInt(count / (float)cols);
|
||||||
|
for (int row = 0; row < rows; row++)
|
||||||
|
{
|
||||||
|
using (new EditorGUILayout.HorizontalScope())
|
||||||
|
{
|
||||||
|
for (int c = 0; c < cols; c++)
|
||||||
|
{
|
||||||
|
int i = row * cols + c;
|
||||||
|
if (i >= count)
|
||||||
|
{
|
||||||
|
GUILayout.FlexibleSpace();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
DrawCell(_filtered[i], cell, onSelect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUILayout.EndScrollView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawCell(Entry a, float size, Action<Entry> onSelect)
|
||||||
|
{
|
||||||
|
using (new EditorGUILayout.VerticalScope(GUILayout.Width(size)))
|
||||||
|
{
|
||||||
|
var thumbRect = GUILayoutUtility.GetRect(size, size - 22f,
|
||||||
|
GUILayout.Width(size), GUILayout.Height(size - 22f));
|
||||||
|
|
||||||
|
var tex = GetThumb(a);
|
||||||
|
if (tex)
|
||||||
|
GUI.DrawTexture(thumbRect, tex, ScaleMode.ScaleToFit);
|
||||||
|
else
|
||||||
|
EditorGUI.DrawRect(thumbRect, new Color(0f, 0f, 0f, 0.15f));
|
||||||
|
|
||||||
|
// Transparent hit target over the thumbnail.
|
||||||
|
if (GUI.Button(thumbRect, new GUIContent("", a.Name), GUIStyle.none))
|
||||||
|
onSelect?.Invoke(a);
|
||||||
|
|
||||||
|
EditorGUILayout.LabelField(new GUIContent(a.Name, a.Name),
|
||||||
|
EditorStyles.miniLabel, GUILayout.Width(size));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lazy, throttled thumbnail loader — only visible cells request, capped in-flight.</summary>
|
||||||
|
private Texture2D GetThumb(Entry a)
|
||||||
|
{
|
||||||
|
if (_thumbs.TryGetValue(a.Key, out var tex))
|
||||||
|
{
|
||||||
|
if (tex)
|
||||||
|
return tex;
|
||||||
|
// Cached texture was destroyed (e.g. play-mode cleanup) — purge and re-request.
|
||||||
|
_thumbs.Remove(a.Key);
|
||||||
|
_thumbRequested.Remove(a.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't add to _thumbRequested until a slot is free, so it retries next repaint.
|
||||||
|
if (!_thumbRequested.Contains(a.Key) && _thumbsInFlight < MaxThumbInFlight)
|
||||||
|
{
|
||||||
|
_thumbRequested.Add(a.Key);
|
||||||
|
_thumbsInFlight++;
|
||||||
|
EditorWebRequest.GetTexture(a.ThumbnailUrl,
|
||||||
|
t => { _thumbs[a.Key] = t; _thumbsInFlight--; _repaint?.Invoke(); },
|
||||||
|
_ => { _thumbsInFlight--; _repaint?.Invoke(); }); // no retry-spam on error
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyFilter()
|
||||||
|
{
|
||||||
|
_filtered.Clear();
|
||||||
|
if (_catalog == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var q = _searchQuery?.Trim().ToLowerInvariant();
|
||||||
|
if (string.IsNullOrEmpty(q))
|
||||||
|
{
|
||||||
|
_filtered.AddRange(_catalog);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var terms = q.Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
foreach (var a in _catalog)
|
||||||
|
{
|
||||||
|
bool all = true;
|
||||||
|
foreach (var t in terms)
|
||||||
|
if (a.Search.IndexOf(t, StringComparison.Ordinal) < 0) { all = false; break; }
|
||||||
|
if (all)
|
||||||
|
_filtered.Add(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 464e6d1b4d5744241ac0dcdfc3d7cf78
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.Networking;
|
||||||
|
|
||||||
|
namespace Chameleon.Editor.WebAssets
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Non-blocking UnityWebRequest transport for editor tools. All requests poll on
|
||||||
|
/// EditorApplication.update — never blocks the editor. Shared by the Poly Haven
|
||||||
|
/// and ambientCG importers.
|
||||||
|
/// </summary>
|
||||||
|
public static class EditorWebRequest
|
||||||
|
{
|
||||||
|
public const string UserAgent = "Chameleon-Unity-AssetImporter/1.0";
|
||||||
|
|
||||||
|
/// <summary>GET a URL and return the response body as text.</summary>
|
||||||
|
public static void GetText(string url, Action<string> onDone, Action<string> onError)
|
||||||
|
{
|
||||||
|
var req = UnityWebRequest.Get(url);
|
||||||
|
req.SetRequestHeader("User-Agent", UserAgent);
|
||||||
|
Send(req, () => onDone?.Invoke(req.downloadHandler.text), onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fetches an image as a Texture2D (browser thumbnails, previews).</summary>
|
||||||
|
public static void GetTexture(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 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 downloads).</summary>
|
||||||
|
public static void DownloadFile(string url, string destPath, Action onDone,
|
||||||
|
Action<string> onError, Action<float> onProgress = null)
|
||||||
|
{
|
||||||
|
var req = new UnityWebRequest(url, UnityWebRequest.kHttpVerbGET);
|
||||||
|
req.downloadHandler = new DownloadHandlerFile(destPath) { removeFileOnAbort = true };
|
||||||
|
req.SetRequestHeader("User-Agent", UserAgent);
|
||||||
|
Send(req, () => onDone?.Invoke(), onError, onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
public 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: edbbb3505f143824590b8d77228d2407
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System.IO;
|
||||||
|
using UnityEditor;
|
||||||
|
|
||||||
|
namespace Chameleon.Editor.WebAssets
|
||||||
|
{
|
||||||
|
/// <summary>Small shared helpers for the web-asset importer windows.</summary>
|
||||||
|
public static class ImportUtil
|
||||||
|
{
|
||||||
|
public static string SanitizeFileName(string name)
|
||||||
|
{
|
||||||
|
foreach (var c in Path.GetInvalidFileNameChars())
|
||||||
|
name = name.Replace(c, ' ');
|
||||||
|
return name.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>"2k"/"2K" -> 2. Unknown strings sort last.</summary>
|
||||||
|
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>Maps a "2k"-style resolution to a valid Unity max texture size.</summary>
|
||||||
|
public static int ResolutionToMaxSize(string res)
|
||||||
|
{
|
||||||
|
int k = ResolutionRank(res);
|
||||||
|
int px = k * 1024;
|
||||||
|
int[] valid = { 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384 };
|
||||||
|
foreach (var v in valid)
|
||||||
|
if (px <= v) return v;
|
||||||
|
return 16384;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void EnsureFolder(string folder)
|
||||||
|
{
|
||||||
|
if (AssetDatabase.IsValidFolder(folder))
|
||||||
|
return;
|
||||||
|
var parts = folder.Split('/');
|
||||||
|
string cur = parts[0]; // "Assets"
|
||||||
|
for (int i = 1; i < parts.Length; i++)
|
||||||
|
{
|
||||||
|
string next = $"{cur}/{parts[i]}";
|
||||||
|
if (!AssetDatabase.IsValidFolder(next))
|
||||||
|
AssetDatabase.CreateFolder(cur, parts[i]);
|
||||||
|
cur = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Non-destructive folder-collision handling: if the folder exists, asks the user
|
||||||
|
/// (New folder / Cancel / Overwrite). Returns the folder to use, or null on cancel.
|
||||||
|
/// </summary>
|
||||||
|
public static string ResolveFolderCollision(string folder, string dialogTitle)
|
||||||
|
{
|
||||||
|
if (!AssetDatabase.IsValidFolder(folder))
|
||||||
|
return folder;
|
||||||
|
|
||||||
|
int choice = EditorUtility.DisplayDialogComplex(dialogTitle,
|
||||||
|
$"Folder already exists:\n{folder}", "New folder", "Cancel", "Overwrite");
|
||||||
|
if (choice == 1) return null; // Cancel
|
||||||
|
if (choice == 0) return AssetDatabase.GenerateUniqueAssetPath(folder); // New folder
|
||||||
|
return folder; // Overwrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 22c722d093b45124fb66addc8fa83211
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Chameleon.Editor.WebAssets
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The PBR map types the importers understand, provider-agnostic. "Arm" is the
|
||||||
|
/// packed AO/Rough/Metal map some Poly Haven assets ship instead of separate maps.
|
||||||
|
/// </summary>
|
||||||
|
public enum PbrMapType
|
||||||
|
{
|
||||||
|
Diffuse,
|
||||||
|
NormalGL,
|
||||||
|
Rough,
|
||||||
|
AO,
|
||||||
|
Metal,
|
||||||
|
Displacement,
|
||||||
|
Arm,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Static per-map metadata: file suffix, colour space, UI defaults.</summary>
|
||||||
|
public static class PbrMapInfo
|
||||||
|
{
|
||||||
|
public readonly struct Meta
|
||||||
|
{
|
||||||
|
public readonly PbrMapType Type;
|
||||||
|
public readonly string Suffix; // file name suffix on disk
|
||||||
|
public readonly bool SRGB; // true => colour, false => linear data
|
||||||
|
public readonly bool IsNormal; // import as TextureImporterType.NormalMap
|
||||||
|
public readonly bool DefaultEnabled; // pre-checked in the UI when available
|
||||||
|
public readonly string Label; // UI label
|
||||||
|
|
||||||
|
public Meta(PbrMapType type, string suffix, bool srgb, bool isNormal,
|
||||||
|
bool defaultEnabled, string label)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
Suffix = suffix;
|
||||||
|
SRGB = srgb;
|
||||||
|
IsNormal = isNormal;
|
||||||
|
DefaultEnabled = defaultEnabled;
|
||||||
|
Label = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order here defines UI order.
|
||||||
|
public static readonly Meta[] All =
|
||||||
|
{
|
||||||
|
new Meta(PbrMapType.Diffuse, "diffuse", true, false, true, "Diffuse (Albedo)"),
|
||||||
|
new Meta(PbrMapType.NormalGL, "normal", false, true, true, "Normal (OpenGL)"),
|
||||||
|
new Meta(PbrMapType.Rough, "roughness", false, false, true, "Roughness"),
|
||||||
|
new Meta(PbrMapType.AO, "ao", false, false, true, "Ambient Occlusion"),
|
||||||
|
new Meta(PbrMapType.Metal, "metallic", false, false, true, "Metallic"),
|
||||||
|
new Meta(PbrMapType.Displacement, "height", false, false, false, "Displacement (Height)"),
|
||||||
|
new Meta(PbrMapType.Arm, "arm", false, false, false, "ARM (packed AO/Rough/Metal)"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<PbrMapType, Meta> ByType = BuildLookup();
|
||||||
|
|
||||||
|
private static Dictionary<PbrMapType, Meta> BuildLookup()
|
||||||
|
{
|
||||||
|
var d = new Dictionary<PbrMapType, Meta>(All.Length);
|
||||||
|
foreach (var m in All)
|
||||||
|
d[m.Type] = m;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Meta Get(PbrMapType type) => ByType[type];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6e3acec0a249ed549a95141ea60978bc
|
||||||
+21
-28
@@ -3,26 +3,26 @@ using System.IO;
|
|||||||
using UnityEditor;
|
using UnityEditor;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace Chameleon.Editor.PolyHaven
|
namespace Chameleon.Editor.WebAssets
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Turns downloaded Poly Haven maps into a ready URP/Lit material:
|
/// Turns downloaded PBR maps (Poly Haven, ambientCG, ...) into a ready URP/Lit
|
||||||
/// correct import settings per map, Roughness→Smoothness repacking into a
|
/// material: correct import settings per map, Roughness→Smoothness repacking into
|
||||||
/// MetallicGloss texture, and wired-up keywords.
|
/// a MetallicGloss texture, and wired-up keywords.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class PolyHavenMaterialBuilder
|
public static class PbrMaterialBuilder
|
||||||
{
|
{
|
||||||
/// <summary>A map that was successfully downloaded, ready to import.</summary>
|
/// <summary>A map that was successfully downloaded, ready to import.</summary>
|
||||||
public struct Downloaded
|
public struct Downloaded
|
||||||
{
|
{
|
||||||
public PolyHavenMapType Type;
|
public PbrMapType Type;
|
||||||
public string AssetPath; // "Assets/Poly Haven/.../foo_diffuse.jpg"
|
public string AssetPath; // "Assets/Poly Haven/.../foo_diffuse.jpg"
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Build(string folder, string displayName, string slug, int maxSize,
|
public static void Build(string folder, string displayName, string slug, int maxSize,
|
||||||
List<Downloaded> maps)
|
List<Downloaded> maps)
|
||||||
{
|
{
|
||||||
var byType = new Dictionary<PolyHavenMapType, string>();
|
var byType = new Dictionary<PbrMapType, string>();
|
||||||
foreach (var m in maps)
|
foreach (var m in maps)
|
||||||
byType[m.Type] = m.AssetPath;
|
byType[m.Type] = m.AssetPath;
|
||||||
|
|
||||||
@@ -32,9 +32,9 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
|
|
||||||
// 2) Build a MetallicSmoothness texture. Rough/Metal come from the dedicated
|
// 2) Build a MetallicSmoothness texture. Rough/Metal come from the dedicated
|
||||||
// maps when present, otherwise from the packed ARM map (G=rough, B=metal).
|
// maps when present, otherwise from the packed ARM map (G=rough, B=metal).
|
||||||
byType.TryGetValue(PolyHavenMapType.Arm, out var armPath);
|
byType.TryGetValue(PbrMapType.Arm, out var armPath);
|
||||||
byType.TryGetValue(PolyHavenMapType.Rough, out var roughPath);
|
byType.TryGetValue(PbrMapType.Rough, out var roughPath);
|
||||||
byType.TryGetValue(PolyHavenMapType.Metal, out var metalPath);
|
byType.TryGetValue(PbrMapType.Metal, out var metalPath);
|
||||||
|
|
||||||
string metallicGlossPath = null;
|
string metallicGlossPath = null;
|
||||||
if (!string.IsNullOrEmpty(roughPath) || !string.IsNullOrEmpty(armPath))
|
if (!string.IsNullOrEmpty(roughPath) || !string.IsNullOrEmpty(armPath))
|
||||||
@@ -45,13 +45,13 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
if (!string.IsNullOrEmpty(metallicGlossPath) && !string.IsNullOrEmpty(roughPath))
|
if (!string.IsNullOrEmpty(metallicGlossPath) && !string.IsNullOrEmpty(roughPath))
|
||||||
{
|
{
|
||||||
AssetDatabase.DeleteAsset(roughPath);
|
AssetDatabase.DeleteAsset(roughPath);
|
||||||
byType.Remove(PolyHavenMapType.Rough);
|
byType.Remove(PbrMapType.Rough);
|
||||||
Debug.Log($"[PolyHaven] Deleted consumed roughness source: {roughPath}");
|
Debug.Log($"[PBR Import] Deleted consumed roughness source: {roughPath}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// AO comes from the dedicated map, or the ARM red channel as a fallback.
|
// AO comes from the dedicated map, or the ARM red channel as a fallback.
|
||||||
string occlusionPath = null;
|
string occlusionPath = null;
|
||||||
if (byType.TryGetValue(PolyHavenMapType.AO, out var aoSeparate))
|
if (byType.TryGetValue(PbrMapType.AO, out var aoSeparate))
|
||||||
occlusionPath = aoSeparate;
|
occlusionPath = aoSeparate;
|
||||||
else if (!string.IsNullOrEmpty(armPath))
|
else if (!string.IsNullOrEmpty(armPath))
|
||||||
occlusionPath = BuildAoFromArm(folder, slug, armPath, maxSize);
|
occlusionPath = BuildAoFromArm(folder, slug, armPath, maxSize);
|
||||||
@@ -60,19 +60,19 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
var shader = Shader.Find("Universal Render Pipeline/Lit");
|
var shader = Shader.Find("Universal Render Pipeline/Lit");
|
||||||
if (!shader)
|
if (!shader)
|
||||||
{
|
{
|
||||||
Debug.LogError("[PolyHaven] URP/Lit shader not found — is URP installed?");
|
Debug.LogError("[PBR Import] URP/Lit shader not found — is URP installed?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var mat = new Material(shader) { name = displayName };
|
var mat = new Material(shader) { name = displayName };
|
||||||
string matPath = Path.Combine(folder, SanitizeFileName(displayName) + ".mat").Replace('\\', '/');
|
string matPath = Path.Combine(folder, ImportUtil.SanitizeFileName(displayName) + ".mat").Replace('\\', '/');
|
||||||
matPath = AssetDatabase.GenerateUniqueAssetPath(matPath);
|
matPath = AssetDatabase.GenerateUniqueAssetPath(matPath);
|
||||||
AssetDatabase.CreateAsset(mat, matPath);
|
AssetDatabase.CreateAsset(mat, matPath);
|
||||||
|
|
||||||
if (byType.TryGetValue(PolyHavenMapType.Diffuse, out var diffPath))
|
if (byType.TryGetValue(PbrMapType.Diffuse, out var diffPath))
|
||||||
mat.SetTexture("_BaseMap", Load(diffPath));
|
mat.SetTexture("_BaseMap", Load(diffPath));
|
||||||
|
|
||||||
if (byType.TryGetValue(PolyHavenMapType.NormalGL, out var norPath))
|
if (byType.TryGetValue(PbrMapType.NormalGL, out var norPath))
|
||||||
{
|
{
|
||||||
mat.SetTexture("_BumpMap", Load(norPath));
|
mat.SetTexture("_BumpMap", Load(norPath));
|
||||||
mat.SetFloat("_BumpScale", 1f);
|
mat.SetFloat("_BumpScale", 1f);
|
||||||
@@ -93,7 +93,7 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
mat.EnableKeyword("_OCCLUSIONMAP");
|
mat.EnableKeyword("_OCCLUSIONMAP");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (byType.TryGetValue(PolyHavenMapType.Displacement, out var heightPath))
|
if (byType.TryGetValue(PbrMapType.Displacement, out var heightPath))
|
||||||
{
|
{
|
||||||
mat.SetTexture("_ParallaxMap", Load(heightPath));
|
mat.SetTexture("_ParallaxMap", Load(heightPath));
|
||||||
mat.SetFloat("_Parallax", 0.03f);
|
mat.SetFloat("_Parallax", 0.03f);
|
||||||
@@ -104,18 +104,18 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
AssetDatabase.SaveAssets();
|
AssetDatabase.SaveAssets();
|
||||||
EditorGUIUtility.PingObject(mat);
|
EditorGUIUtility.PingObject(mat);
|
||||||
Selection.activeObject = mat;
|
Selection.activeObject = mat;
|
||||||
Debug.Log($"[PolyHaven] Material created: {matPath}");
|
Debug.Log($"[PBR Import] Material created: {matPath}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Texture2D Load(string path) => AssetDatabase.LoadAssetAtPath<Texture2D>(path);
|
private static Texture2D Load(string path) => AssetDatabase.LoadAssetAtPath<Texture2D>(path);
|
||||||
|
|
||||||
public static void ApplyImportSettings(string assetPath, PolyHavenMapType type, int maxSize)
|
public static void ApplyImportSettings(string assetPath, PbrMapType type, int maxSize)
|
||||||
{
|
{
|
||||||
var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
|
var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
|
||||||
if (!importer)
|
if (!importer)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var meta = PolyHavenMapInfo.Get(type);
|
var meta = PbrMapInfo.Get(type);
|
||||||
importer.textureType = meta.IsNormal ? TextureImporterType.NormalMap : TextureImporterType.Default;
|
importer.textureType = meta.IsNormal ? TextureImporterType.NormalMap : TextureImporterType.Default;
|
||||||
if (!meta.IsNormal)
|
if (!meta.IsNormal)
|
||||||
importer.sRGBTexture = meta.SRGB;
|
importer.sRGBTexture = meta.SRGB;
|
||||||
@@ -252,12 +252,5 @@ namespace Chameleon.Editor.PolyHaven
|
|||||||
}
|
}
|
||||||
return tex;
|
return tex;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string SanitizeFileName(string name)
|
|
||||||
{
|
|
||||||
foreach (var c in Path.GetInvalidFileNameChars())
|
|
||||||
name = name.Replace(c, ' ');
|
|
||||||
return name.Trim();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8c4d448a85521344783f6d1b0dc64c53
|
||||||
Reference in new issue
Block a user