diff --git a/Assets/Scripts/Editor/PolyHaven.meta b/Assets/Scripts/Editor/PolyHaven.meta new file mode 100644 index 0000000..be711df --- /dev/null +++ b/Assets/Scripts/Editor/PolyHaven.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e7186f7ca5e56f14382071f77388acaa +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenApi.cs b/Assets/Scripts/Editor/PolyHaven/PolyHavenApi.cs new file mode 100644 index 0000000..a17a840 --- /dev/null +++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenApi.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.Networking; + +namespace Chameleon.Editor.PolyHaven +{ + /// One downloadable file variant (a specific map/resolution/format). + public readonly struct PolyHavenFile + { + public readonly string Url; + public readonly string Format; // "jpg" / "png" / "exr" + public readonly long Size; + + public PolyHavenFile(string url, string format, long size) + { + Url = url; + Format = format; + Size = size; + } + + public bool IsValid => !string.IsNullOrEmpty(Url); + } + + /// Asset metadata from /info/{slug}. + public sealed class PolyHavenInfo + { + public string Slug; + public string Name; + public int Type; // 0=HDRI, 1=Texture, 2=Model + public string ThumbnailUrl; + public string[] Categories = Array.Empty(); + } + + /// One catalog entry from /assets (lightweight, for the browser grid). + public sealed class PolyHavenAsset + { + public string Slug; + public string Name; + public string ThumbnailUrl; + public string[] Categories = Array.Empty(); + public string[] Tags = Array.Empty(); + public string Search; // pre-lowered "name slug categories tags" haystack + } + + /// Parsed /files/{slug}: MapType -> resolution("2k") -> best file. + public sealed class PolyHavenFiles + { + public readonly Dictionary> Maps = + new Dictionary>(); + + // Every resolution string seen across all maps (e.g. "1k","2k","4k"), sorted low->high. + public List Resolutions = new List(); + + public bool Has(PolyHavenMapType type) => Maps.ContainsKey(type); + + public bool TryGet(PolyHavenMapType type, string res, out PolyHavenFile file) + { + file = default; + return Maps.TryGetValue(type, out var byRes) && byRes.TryGetValue(res, out file); + } + + /// Best available file at res, else next-lower resolution of the same map. + public bool TryGetWithFallback(PolyHavenMapType type, string res, out PolyHavenFile file, out string usedRes) + { + file = default; + usedRes = null; + if (!Maps.TryGetValue(type, out var byRes)) + return false; + if (byRes.TryGetValue(res, out file)) { usedRes = res; return true; } + + int target = ResolutionRank(res); + int bestRank = -1; + foreach (var kv in byRes) + { + int rank = ResolutionRank(kv.Key); + if (rank <= target && rank > bestRank) + { + bestRank = rank; + file = kv.Value; + usedRes = kv.Key; + } + } + if (usedRes == null) // nothing at-or-below target: take the lowest available + { + foreach (var kv in byRes) + { + int rank = ResolutionRank(kv.Key); + if (bestRank == -1 || rank < bestRank) + { + bestRank = rank; + file = kv.Value; + usedRes = kv.Key; + } + } + } + return usedRes != null; + } + + public static int ResolutionRank(string res) + { + if (string.IsNullOrEmpty(res)) return int.MaxValue; + var digits = res.TrimEnd('k', 'K'); + return int.TryParse(digits, out var n) ? n : int.MaxValue; + } + } + + /// + /// Async client for the public Poly Haven API (same endpoints the Blender/UE5 plugins use). + /// All requests poll on EditorApplication.update — never blocks the editor. + /// + public static class PolyHavenApi + { + private const string ApiBase = "https://api.polyhaven.com"; + private const string UserAgent = "Chameleon-Unity-PolyHavenImporter/1.0"; + + // Best format preference per map: lossless where it matters, jpg otherwise. + private static readonly string[] LosslessPref = { "png", "exr", "jpg" }; + private static readonly string[] LossyPref = { "jpg", "png", "exr" }; + + public static string ExtractSlug(string urlOrSlug) + { + if (string.IsNullOrWhiteSpace(urlOrSlug)) return null; + var s = urlOrSlug.Trim(); + + // Full URL like https://polyhaven.com/a/broken_brick_wall + int a = s.IndexOf("/a/", StringComparison.OrdinalIgnoreCase); + if (a >= 0) + s = s.Substring(a + 3); + + // Strip any trailing path/query/fragment. + foreach (var sep in new[] { '?', '#', '/' }) + { + int i = s.IndexOf(sep); + if (i >= 0) s = s.Substring(0, i); + } + s = s.Trim(); + return s.Length == 0 ? null : s; + } + + public static string ThumbnailUrl(string slug) => + $"https://cdn.polyhaven.com/asset_img/thumbs/{slug}.png?width=256&height=256"; + + // ---- Requests ---------------------------------------------------------- + + public static void FetchInfo(string slug, Action onDone, Action onError) + { + var req = UnityWebRequest.Get($"{ApiBase}/info/{slug}"); + req.SetRequestHeader("User-Agent", UserAgent); + Send(req, () => + { + try + { + var o = JObject.Parse(req.downloadHandler.text); + var info = new PolyHavenInfo + { + Slug = slug, + Name = (string)o["name"] ?? slug, + Type = o["type"] != null ? (int)o["type"] : -1, + ThumbnailUrl = (string)o["thumbnail_url"] ?? ThumbnailUrl(slug), + }; + if (o["categories"] is JArray cats) + { + var list = new List(); + foreach (var c in cats) list.Add((string)c); + info.Categories = list.ToArray(); + } + onDone?.Invoke(info); + } + catch (Exception e) { onError?.Invoke($"Info parse failed: {e.Message}"); } + }, onError); + } + + /// Loads the full texture catalog (keyed by slug) for the browser grid. + public static void FetchAssets(string type, Action> onDone, Action onError) + { + var req = UnityWebRequest.Get($"{ApiBase}/assets?t={type}"); + req.SetRequestHeader("User-Agent", UserAgent); + Send(req, () => + { + try + { + var root = JObject.Parse(req.downloadHandler.text); + var list = new List(root.Count); + foreach (var prop in root) + { + if (prop.Value is not JObject o) continue; + var a = new PolyHavenAsset + { + Slug = prop.Key, + Name = (string)o["name"] ?? prop.Key, + ThumbnailUrl = (string)o["thumbnail_url"] ?? ThumbnailUrl(prop.Key), + Categories = ToStringArray(o["categories"]), + Tags = ToStringArray(o["tags"]), + }; + a.Search = (a.Name + " " + a.Slug + " " + + string.Join(" ", a.Categories) + " " + + string.Join(" ", a.Tags)).ToLowerInvariant(); + list.Add(a); + } + list.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase)); + onDone?.Invoke(list); + } + catch (Exception e) { onError?.Invoke($"Assets parse failed: {e.Message}"); } + }, onError); + } + + public static void FetchFiles(string slug, Action onDone, Action onError) + { + var req = UnityWebRequest.Get($"{ApiBase}/files/{slug}"); + req.SetRequestHeader("User-Agent", UserAgent); + Send(req, () => + { + try { onDone?.Invoke(ParseFiles(req.downloadHandler.text)); } + catch (Exception e) { onError?.Invoke($"Files parse failed: {e.Message}"); } + }, onError); + } + + public static void FetchThumbnail(string url, Action onDone, Action 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); + } + + /// Streams a file straight to disk (no RAM peak on big EXR/8K files). + public static void DownloadFile(PolyHavenFile file, string destPath, Action onDone, + Action onError, Action onProgress = null) + { + var req = new UnityWebRequest(file.Url, UnityWebRequest.kHttpVerbGET); + req.downloadHandler = new DownloadHandlerFile(destPath) { removeFileOnAbort = true }; + req.SetRequestHeader("User-Agent", UserAgent); + Send(req, () => onDone?.Invoke(), onError, onProgress); + } + + // ---- Parsing ----------------------------------------------------------- + + private static string[] ToStringArray(JToken token) + { + if (token is not JArray arr) return Array.Empty(); + var list = new List(arr.Count); + foreach (var t in arr) + { + var s = (string)t; + if (!string.IsNullOrEmpty(s)) list.Add(s); + } + return list.ToArray(); + } + + private static PolyHavenFiles ParseFiles(string json) + { + var root = JObject.Parse(json); + var result = new PolyHavenFiles(); + var resSet = new HashSet(); + + foreach (var meta in PolyHavenMapInfo.All) + { + if (root[meta.ApiKey] is not JObject byRes) + continue; + + var pref = (meta.IsNormal || meta.Type == PolyHavenMapType.Displacement) + ? LosslessPref + : LossyPref; + + var perRes = new Dictionary(); + foreach (var resProp in byRes) + { + string res = resProp.Key; + if (resProp.Value is not JObject formats) + continue; + + PolyHavenFile picked = default; + foreach (var fmt in pref) + { + if (formats[fmt] is JObject fo && fo["url"] != null) + { + picked = new PolyHavenFile( + (string)fo["url"], fmt, + fo["size"] != null ? (long)fo["size"] : 0); + break; + } + } + if (picked.IsValid) + { + perRes[res] = picked; + resSet.Add(res); + } + } + + if (perRes.Count > 0) + result.Maps[meta.Type] = perRes; + } + + var resList = new List(resSet); + resList.Sort((x, y) => PolyHavenFiles.ResolutionRank(x).CompareTo(PolyHavenFiles.ResolutionRank(y))); + result.Resolutions = resList; + return result; + } + + // ---- Non-blocking transport ------------------------------------------- + + private static void Send(UnityWebRequest req, Action onSuccess, Action onError, + Action 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; + } + } +} diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenApi.cs.meta b/Assets/Scripts/Editor/PolyHaven/PolyHavenApi.cs.meta new file mode 100644 index 0000000..b981c46 --- /dev/null +++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenApi.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dc6fa3c02522bc342877a7d50977f482 \ No newline at end of file diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenImporterWindow.cs b/Assets/Scripts/Editor/PolyHaven/PolyHavenImporterWindow.cs new file mode 100644 index 0000000..28b11d0 --- /dev/null +++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenImporterWindow.cs @@ -0,0 +1,534 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace Chameleon.Editor.PolyHaven +{ + /// + /// Editor window: paste a Poly Haven asset URL, fetch its maps, pick resolution + + /// maps, then download and auto-build a URP/Lit material under Assets/Poly Haven/{Name}/. + /// No browser — asset selection stays on polyhaven.com. + /// + public sealed class PolyHavenImporterWindow : EditorWindow + { + private const string RootFolder = "Assets/Poly Haven"; + private const string DefaultRes = "2k"; + + private string _urlInput = "https://polyhaven.com/a/"; + private PolyHavenInfo _info; + private PolyHavenFiles _files; + private Texture2D _thumbnail; + private string _selectedRes = DefaultRes; + private readonly Dictionary _mapEnabled = new Dictionary(); + + private bool _busy; + private string _status = ""; + private Vector2 _scroll; + + // ---- Browser ---------------------------------------------------------- + private const int MaxResults = 150; + private const int MaxThumbInFlight = 6; + + // Catalog cached for the whole session (survives window close, cleared on domain reload). + private static List s_catalog; + + private bool _browseFoldout = true; + private bool _autoLoadTried; + private bool _catalogLoading; + private string _searchQuery = ""; + private readonly List _filtered = new List(); + private Vector2 _gridScroll; + + private readonly Dictionary _thumbs = new Dictionary(); + private readonly HashSet _thumbRequested = new HashSet(); + private int _thumbsInFlight; + + [MenuItem("Tools/Poly Haven Importer")] + public static void Open() + { + var w = GetWindow("Poly Haven Importer"); + w.minSize = new Vector2(440, 480); + } + + private void OnEnable() + { + // Catalog may already be cached from a previous window instance this session. + if (s_catalog != null) + { + _autoLoadTried = true; + ApplyFilter(); + } + } + + private void OnGUI() + { + _scroll = EditorGUILayout.BeginScrollView(_scroll); + + EditorGUILayout.LabelField("Poly Haven Importer", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "Pick a texture on polyhaven.com, paste its URL here (e.g. .../a/broken_brick_wall), " + + "Fetch, choose resolution + maps, then Download. A URP/Lit material is built automatically.", + MessageType.Info); + + EditorGUILayout.Space(); + + DrawBrowser(); + + EditorGUILayout.Space(); + + using (new EditorGUI.DisabledScope(_busy)) + { + EditorGUILayout.LabelField("Asset URL or slug", EditorStyles.miniBoldLabel); + using (new EditorGUILayout.HorizontalScope()) + { + _urlInput = EditorGUILayout.TextField(_urlInput); + if (GUILayout.Button("Fetch", GUILayout.Width(70))) + Fetch(); + } + } + + if (_info != 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(_info.Name, EditorStyles.boldLabel); + EditorGUILayout.LabelField($"Slug: {_info.Slug}", EditorStyles.miniLabel); + if (_info.Categories != null && _info.Categories.Length > 0) + EditorGUILayout.LabelField($"Categories: {string.Join(", ", _info.Categories)}", EditorStyles.miniLabel); + if (_info.Type != 1) + EditorGUILayout.HelpBox("This asset is not a texture — only textures are supported.", MessageType.Warning); + } + } + + if (_files == null || _files.Resolutions.Count == 0) + return; + + EditorGUILayout.Space(); + + // Resolution popup. + var resArray = _files.Resolutions.ToArray(); + int resIdx = Mathf.Max(0, System.Array.IndexOf(resArray, _selectedRes)); + resIdx = EditorGUILayout.Popup("Resolution", resIdx, resArray.Select(r => r.ToUpperInvariant()).ToArray()); + _selectedRes = resArray[resIdx]; + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Maps", EditorStyles.miniBoldLabel); + foreach (var meta in PolyHavenMapInfo.All) + { + if (!_files.Has(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 && _info.Type == 1 && AnyMapSelected(); + using (new EditorGUI.DisabledScope(!canDownload)) + { + if (GUILayout.Button("Download / Import", GUILayout.Height(30))) + Download(); + } + } + + private bool AnyMapSelected() + { + foreach (var meta in PolyHavenMapInfo.All) + if (_files.Has(meta.Type) && _mapEnabled.TryGetValue(meta.Type, out var v) && v) + return true; + // Fall back to defaults if the user hasn't toggled anything yet. + foreach (var meta in PolyHavenMapInfo.All) + if (_files.Has(meta.Type) && meta.DefaultEnabled && !_mapEnabled.ContainsKey(meta.Type)) + 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 (s_catalog == null) + { + // Auto-load once when the browser is first opened; still offer a manual retry. + if (!_catalogLoading && !_autoLoadTried) + { + _autoLoadTried = true; + LoadCatalog(); + } + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.LabelField(_catalogLoading + ? "Loading texture catalog…" + : "Catalog not loaded."); + using (new EditorGUI.DisabledScope(_catalogLoading)) + if (GUILayout.Button("Load catalog", GUILayout.Width(110))) + LoadCatalog(); + } + return; + } + + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUI.BeginChangeCheck(); + _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)); + } + } + + /// Lazy, throttled thumbnail loader — only visible cells request, capped in-flight. + 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); + Fetch(); // url filled -> fetch, then everything as before + } + + private void LoadCatalog() + { + _catalogLoading = true; + _status = "Loading Poly Haven texture catalog…"; + Repaint(); + + PolyHavenApi.FetchAssets("textures", + list => + { + s_catalog = list; + _catalogLoading = false; + ApplyFilter(); + _status = $"Catalog loaded: {list.Count} textures."; + Repaint(); + }, + err => + { + _catalogLoading = false; + _status = "Catalog error: " + err; + Debug.LogError("[PolyHaven] " + err); + Repaint(); + }); + } + + 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 ------------------------------------------------------------- + + private void Fetch() + { + string slug = PolyHavenApi.ExtractSlug(_urlInput); + if (string.IsNullOrEmpty(slug)) + { + EditorUtility.DisplayDialog("Poly Haven", "Could not read a slug from the URL.", "OK"); + return; + } + + _busy = true; + _status = $"Fetching '{slug}'…"; + _info = null; + _files = null; + _thumbnail = null; + _mapEnabled.Clear(); + Repaint(); + + PolyHavenApi.FetchInfo(slug, info => + { + _info = info; + Repaint(); + PolyHavenApi.FetchThumbnail(info.ThumbnailUrl, tex => { _thumbnail = tex; Repaint(); }, _ => { }); + + PolyHavenApi.FetchFiles(slug, files => + { + _files = files; + if (files.Resolutions.Contains(DefaultRes)) + _selectedRes = DefaultRes; + else if (files.Resolutions.Count > 0) + _selectedRes = files.Resolutions[Mathf.Min(1, files.Resolutions.Count - 1)]; + + _busy = false; + _status = info.Type == 1 + ? $"Ready: {files.Maps.Count} map type(s), {files.Resolutions.Count} resolution(s)." + : "Fetched, but this asset is not a texture."; + Repaint(); + }, err => Fail(err)); + }, err => Fail(err)); + } + + // ---- Download ---------------------------------------------------------- + + private void Download() + { + // Resolve which maps to fetch. + var wanted = new List(); + foreach (var meta in PolyHavenMapInfo.All) + { + if (!_files.Has(meta.Type)) continue; + bool on = _mapEnabled.TryGetValue(meta.Type, out var v) ? v : meta.DefaultEnabled; + if (on) wanted.Add(meta.Type); + } + + // 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. + if (_files.Has(PolyHavenMapType.Arm) && !wanted.Contains(PolyHavenMapType.Arm)) + { + bool channelMissing = !_files.Has(PolyHavenMapType.Metal) + || !_files.Has(PolyHavenMapType.Rough) + || !_files.Has(PolyHavenMapType.AO); + if (channelMissing) + wanted.Add(PolyHavenMapType.Arm); + } + + if (wanted.Count == 0) + return; + + string displayName = _info.Name; + string folder = $"{RootFolder}/{PolyHavenMaterialBuilder.SanitizeFileName(displayName)}"; + + // Folder-collision handling (non-destructive default). + if (AssetDatabase.IsValidFolder(folder)) + { + int choice = EditorUtility.DisplayDialogComplex("Poly Haven", + $"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); + _busy = true; + _status = $"Downloading {wanted.Count} map(s) at {_selectedRes.ToUpperInvariant()}…"; + Repaint(); + + var results = new List(); + var failures = new List(); + int remaining = wanted.Count; + + AssetDatabase.StartAssetEditing(); + + void FinishOne() + { + remaining--; + if (remaining > 0) + return; + + AssetDatabase.StopAssetEditing(); + AssetDatabase.Refresh(); + + if (results.Count == 0) + { + _busy = false; + _status = "Download failed — no maps saved."; + if (failures.Count > 0) + Debug.LogError("[PolyHaven] " + string.Join("; ", failures)); + Repaint(); + return; + } + + PolyHavenMaterialBuilder.Build(folder, displayName, _info.Slug, maxSize, results); + _busy = false; + _status = $"Done: {results.Count} map(s) imported, material built in {folder}."; + if (failures.Count > 0) + _status += $" ({failures.Count} map(s) failed — see console.)"; + Repaint(); + } + + foreach (var type in wanted) + { + if (!_files.TryGetWithFallback(type, _selectedRes, out var file, out var usedRes)) + { + failures.Add($"{type}: no file"); + FinishOne(); + continue; + } + if (usedRes != _selectedRes) + Debug.LogWarning($"[PolyHaven] {type}: {_selectedRes} unavailable, using {usedRes}."); + + var meta = PolyHavenMapInfo.Get(type); + string fileName = $"{_info.Slug}_{meta.Suffix}.{file.Format}"; + string destPath = $"{folder}/{fileName}"; + + PolyHavenApi.DownloadFile(file, Path.GetFullPath(destPath), + onDone: () => + { + results.Add(new PolyHavenMaterialBuilder.Downloaded { Type = type, AssetPath = destPath }); + FinishOne(); + }, + onError: err => + { + failures.Add($"{type}: {err}"); + FinishOne(); + }); + } + } + + // ---- Helpers ----------------------------------------------------------- + + private void Fail(string message) + { + _busy = false; + _status = "Error: " + message; + Debug.LogError("[PolyHaven] " + message); + 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; + } + } + } +} diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenImporterWindow.cs.meta b/Assets/Scripts/Editor/PolyHaven/PolyHavenImporterWindow.cs.meta new file mode 100644 index 0000000..c889cb0 --- /dev/null +++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenImporterWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 401846ce5f8a2eb4fbd4db733689b7b8 \ No newline at end of file diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenMapType.cs b/Assets/Scripts/Editor/PolyHaven/PolyHavenMapType.cs new file mode 100644 index 0000000..b6eebb4 --- /dev/null +++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenMapType.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; + +namespace Chameleon.Editor.PolyHaven +{ + /// + /// 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. + /// + public enum PolyHavenMapType + { + Diffuse, + NormalGL, + Rough, + AO, + Metal, + Displacement, + Arm, + } + + /// Static per-map metadata: API key, file suffix, colour space, defaults. + 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 ByType = BuildLookup(); + + private static Dictionary BuildLookup() + { + var d = new Dictionary(All.Length); + foreach (var m in All) + d[m.Type] = m; + return d; + } + + public static Meta Get(PolyHavenMapType type) => ByType[type]; + } +} diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenMapType.cs.meta b/Assets/Scripts/Editor/PolyHaven/PolyHavenMapType.cs.meta new file mode 100644 index 0000000..1ead6ef --- /dev/null +++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenMapType.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d47448fcb2873654d8d7b4369c6c5da4 \ No newline at end of file diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenMaterialBuilder.cs b/Assets/Scripts/Editor/PolyHaven/PolyHavenMaterialBuilder.cs new file mode 100644 index 0000000..b3e1de9 --- /dev/null +++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenMaterialBuilder.cs @@ -0,0 +1,254 @@ +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace Chameleon.Editor.PolyHaven +{ + /// + /// Turns downloaded Poly Haven maps into a ready URP/Lit material: + /// correct import settings per map, Roughness→Smoothness repacking into a + /// MetallicGloss texture, and wired-up keywords. + /// + public static class PolyHavenMaterialBuilder + { + /// A map that was successfully downloaded, ready to import. + public struct Downloaded + { + public PolyHavenMapType Type; + public string AssetPath; // "Assets/Poly Haven/.../foo_diffuse.jpg" + } + + public static void Build(string folder, string displayName, string slug, int maxSize, + List maps) + { + var byType = new Dictionary(); + foreach (var m in maps) + byType[m.Type] = m.AssetPath; + + // 1) Import settings for every raw map. + foreach (var m in maps) + ApplyImportSettings(m.AssetPath, m.Type, maxSize); + + // 2) Build a MetallicSmoothness texture. Rough/Metal come from the dedicated + // maps when present, otherwise from the packed ARM map (G=rough, B=metal). + byType.TryGetValue(PolyHavenMapType.Arm, out var armPath); + byType.TryGetValue(PolyHavenMapType.Rough, out var roughPath); + byType.TryGetValue(PolyHavenMapType.Metal, out var metalPath); + + string metallicGlossPath = null; + if (!string.IsNullOrEmpty(roughPath) || !string.IsNullOrEmpty(armPath)) + metallicGlossPath = BuildMetallicSmoothness(folder, slug, roughPath, metalPath, armPath, maxSize); + + // AO comes from the dedicated map, or the ARM red channel as a fallback. + string occlusionPath = null; + if (byType.TryGetValue(PolyHavenMapType.AO, out var aoSeparate)) + occlusionPath = aoSeparate; + else if (!string.IsNullOrEmpty(armPath)) + occlusionPath = BuildAoFromArm(folder, slug, armPath, maxSize); + + // 3) Create the URP/Lit material. + var shader = Shader.Find("Universal Render Pipeline/Lit"); + if (!shader) + { + Debug.LogError("[PolyHaven] URP/Lit shader not found — is URP installed?"); + return; + } + + var mat = new Material(shader) { name = displayName }; + string matPath = Path.Combine(folder, SanitizeFileName(displayName) + ".mat").Replace('\\', '/'); + matPath = AssetDatabase.GenerateUniqueAssetPath(matPath); + AssetDatabase.CreateAsset(mat, matPath); + + if (byType.TryGetValue(PolyHavenMapType.Diffuse, out var diffPath)) + mat.SetTexture("_BaseMap", Load(diffPath)); + + if (byType.TryGetValue(PolyHavenMapType.NormalGL, out var norPath)) + { + mat.SetTexture("_BumpMap", Load(norPath)); + mat.SetFloat("_BumpScale", 1f); + mat.EnableKeyword("_NORMALMAP"); + } + + if (!string.IsNullOrEmpty(metallicGlossPath)) + { + mat.SetTexture("_MetallicGlossMap", Load(metallicGlossPath)); + mat.SetFloat("_Smoothness", 1f); // acts as multiplier on the map's alpha + mat.EnableKeyword("_METALLICSPECGLOSSMAP"); + } + + if (!string.IsNullOrEmpty(occlusionPath)) + { + mat.SetTexture("_OcclusionMap", Load(occlusionPath)); + mat.SetFloat("_OcclusionStrength", 1f); + mat.EnableKeyword("_OCCLUSIONMAP"); + } + + if (byType.TryGetValue(PolyHavenMapType.Displacement, out var heightPath)) + { + mat.SetTexture("_ParallaxMap", Load(heightPath)); + mat.SetFloat("_Parallax", 0.03f); + mat.EnableKeyword("_PARALLAXMAP"); + } + + EditorUtility.SetDirty(mat); + AssetDatabase.SaveAssets(); + EditorGUIUtility.PingObject(mat); + Selection.activeObject = mat; + Debug.Log($"[PolyHaven] Material created: {matPath}"); + } + + private static Texture2D Load(string path) => AssetDatabase.LoadAssetAtPath(path); + + public static void ApplyImportSettings(string assetPath, PolyHavenMapType type, int maxSize) + { + var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter; + if (!importer) + return; + + var meta = PolyHavenMapInfo.Get(type); + importer.textureType = meta.IsNormal ? TextureImporterType.NormalMap : TextureImporterType.Default; + if (!meta.IsNormal) + importer.sRGBTexture = meta.SRGB; + importer.maxTextureSize = maxSize; + importer.SaveAndReimport(); + } + + /// + /// Packs metallic into RGB and smoothness (1-roughness) into A, as URP/Lit expects + /// in _MetallicGlossMap. Roughness/Metal are read from the dedicated maps when given, + /// otherwise unpacked from the ARM map (G=rough, B=metal). Reads raw bytes off disk + /// into linear textures to avoid sRGB sampling on data maps. + /// + private static string BuildMetallicSmoothness(string folder, string slug, string roughPath, + string metalPath, string armPath, int maxSize) + { + Texture2D rough = string.IsNullOrEmpty(roughPath) ? null : LoadLinear(roughPath); + Texture2D metal = string.IsNullOrEmpty(metalPath) ? null : LoadLinear(metalPath); + Texture2D arm = string.IsNullOrEmpty(armPath) ? null : LoadLinear(armPath); + + // Sizing grid: prefer the dedicated roughness map, else the ARM map. + Texture2D grid = rough ? rough : arm; + if (!grid) + { + if (rough) Object.DestroyImmediate(rough); + if (metal) Object.DestroyImmediate(metal); + if (arm) Object.DestroyImmediate(arm); + return null; + } + + int w = grid.width, h = grid.height; + var outPixels = new Color[w * h]; + + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + int i = y * w + x; + float u = (x + 0.5f) / w, v = (y + 0.5f) / h; + + // Roughness: dedicated map .r, else ARM .g. Smoothness = 1 - roughness. + float roughness = rough ? rough.GetPixelBilinear(u, v).r + : arm ? arm.GetPixelBilinear(u, v).g + : 1f; + float smoothness = 1f - roughness; + + // Metallic: dedicated map .r, else ARM .b. + float m = metal ? metal.GetPixelBilinear(u, v).r + : arm ? arm.GetPixelBilinear(u, v).b + : 0f; + + outPixels[i] = new Color(m, m, m, smoothness); + } + } + + var packed = new Texture2D(w, h, TextureFormat.RGBA32, true, true); + packed.SetPixels(outPixels); + packed.Apply(); + + string outPath = Path.Combine(folder, slug + "_metallic_smoothness.png").Replace('\\', '/'); + File.WriteAllBytes(outPath, packed.EncodeToPNG()); + + Object.DestroyImmediate(packed); + if (rough) Object.DestroyImmediate(rough); + if (metal) Object.DestroyImmediate(metal); + if (arm) Object.DestroyImmediate(arm); + + ReimportLinear(outPath, maxSize, withAlpha: true); + return outPath; + } + + /// + /// Extracts the AO (red) channel from a packed ARM map into a standalone grayscale + /// occlusion texture, used when the asset ships no dedicated AO map. + /// + private static string BuildAoFromArm(string folder, string slug, string armPath, int maxSize) + { + var arm = LoadLinear(armPath); + if (!arm) + return null; + + int w = arm.width, h = arm.height; + var src = arm.GetPixels(); + var outPixels = new Color[src.Length]; + for (int i = 0; i < src.Length; i++) + { + float ao = src[i].r; // ARM red channel = ambient occlusion + outPixels[i] = new Color(ao, ao, ao, 1f); + } + + var tex = new Texture2D(w, h, TextureFormat.RGBA32, true, true); + tex.SetPixels(outPixels); + tex.Apply(); + + string outPath = Path.Combine(folder, slug + "_ao.png").Replace('\\', '/'); + File.WriteAllBytes(outPath, tex.EncodeToPNG()); + + Object.DestroyImmediate(tex); + Object.DestroyImmediate(arm); + + ReimportLinear(outPath, maxSize, withAlpha: false); + return outPath; + } + + /// Reimports a generated data texture as linear, at the target size. + private static void ReimportLinear(string outPath, int maxSize, bool withAlpha) + { + AssetDatabase.ImportAsset(outPath, ImportAssetOptions.ForceSynchronousImport); + var imp = AssetImporter.GetAtPath(outPath) as TextureImporter; + if (!imp) + return; + imp.sRGBTexture = false; + imp.maxTextureSize = maxSize; + if (withAlpha) + { + imp.alphaSource = TextureImporterAlphaSource.FromInput; + imp.alphaIsTransparency = false; + } + imp.SaveAndReimport(); + } + + /// Loads an image file straight off disk into a linear Texture2D (no import needed). + private static Texture2D LoadLinear(string assetPath) + { + string full = Path.GetFullPath(assetPath); + if (!File.Exists(full)) + return null; + var bytes = File.ReadAllBytes(full); + var tex = new Texture2D(2, 2, TextureFormat.RGBA32, true, true); + if (!tex.LoadImage(bytes)) + { + Object.DestroyImmediate(tex); + return null; + } + return tex; + } + + public static string SanitizeFileName(string name) + { + foreach (var c in Path.GetInvalidFileNameChars()) + name = name.Replace(c, ' '); + return name.Trim(); + } + } +} diff --git a/Assets/Scripts/Editor/PolyHaven/PolyHavenMaterialBuilder.cs.meta b/Assets/Scripts/Editor/PolyHaven/PolyHavenMaterialBuilder.cs.meta new file mode 100644 index 0000000..ff5caf2 --- /dev/null +++ b/Assets/Scripts/Editor/PolyHaven/PolyHavenMaterialBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3c242963f05cc5545b0034908b5807f1 \ No newline at end of file