using System; using System.Collections.Generic; using Chameleon.Editor.WebAssets; using Newtonsoft.Json.Linq; namespace Chameleon.Editor.AmbientCg { /// One downloadable ZIP variant of an asset (e.g. attribute "2K-JPG"). 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); } /// Details for one asset: display data, available maps and ZIP downloads. 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 Maps = new List(); public readonly List Zips = new List(); public List Resolutions() { var set = new HashSet(); foreach (var z in Zips) set.Add(z.Resolution); var list = new List(set); list.Sort((x, y) => ImportUtil.ResolutionRank(x).CompareTo(ImportUtil.ResolutionRank(y))); return list; } public List Formats(string resolution) { var list = new List(); 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; } } /// /// 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, ...). /// 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) ------------------------------------------------ /// /// 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). /// public static void FetchCatalog(Action> onDone, Action onError, Action onProgress = null) { var all = new List(); FetchCatalogPage(all, 0, onDone, onError, onProgress); } private static void FetchCatalogPage(List all, int offset, Action> onDone, Action onError, Action 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 onDone, Action 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(arr.Count); foreach (var t in arr) { var s = (string)t; if (!string.IsNullOrEmpty(s)) list.Add(s); } return string.Join(" ", list); } } }