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>
280 lines
11 KiB
C#
280 lines
11 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|