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:
adminandClaude Fable 5 committed 2026-07-16 15:27:02 +02:00
1 parent 9b35ee2a97
commit fb4098b9f8
21 files changed
+1208 -420

No files matched your search

+8
View File
@@ -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
+49 -123
View File
@@ -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
+8
View File
@@ -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
@@ -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