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
+1205 -417

No files matched your search

@@ -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