Add model import support to Poly Haven importer
Browser gets Textures/Models tabs (per-type catalog cache). Model file JSON is parsed into per-set texture maps (generic set-prefix matching), FBX is downloaded with resolution fallback, one URP/Lit material is built per set via the shared PbrMaterialBuilder (which now returns the created Material), and new ModelImportUtil remaps FBX material slots to the built materials by set-name matching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2b9a189d58
commit
eab5081354
5 files changed
+391
-98
No files matched your search
@@ -10,7 +10,7 @@ namespace Chameleon.Editor.PolyHaven
|
||||
public readonly struct PolyHavenFile
|
||||
{
|
||||
public readonly string Url;
|
||||
public readonly string Format; // "jpg" / "png" / "exr"
|
||||
public readonly string Format; // "jpg" / "png" / "exr" / "fbx"
|
||||
public readonly long Size;
|
||||
|
||||
public PolyHavenFile(string url, string format, long size)
|
||||
@@ -33,29 +33,74 @@ namespace Chameleon.Editor.PolyHaven
|
||||
public string[] Categories = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>Parsed /files/{slug}: MapType -> resolution("2k") -> best file.</summary>
|
||||
public sealed class PolyHavenFiles
|
||||
/// <summary>
|
||||
/// One texture set of an asset. Textures have a single unnamed set; models may ship
|
||||
/// several (e.g. "cart"/"props"/"mugs"), keyed by the prefix of the API map keys.
|
||||
/// </summary>
|
||||
public sealed class PolyHavenMapSet
|
||||
{
|
||||
public string Name = ""; // "" for the default/only set
|
||||
|
||||
public readonly Dictionary<PbrMapType, Dictionary<string, PolyHavenFile>> Maps =
|
||||
new Dictionary<PbrMapType, Dictionary<string, PolyHavenFile>>();
|
||||
|
||||
// Every resolution string seen across all maps (e.g. "1k","2k","4k"), sorted low->high.
|
||||
public List<string> Resolutions = new List<string>();
|
||||
|
||||
public bool Has(PbrMapType type) => Maps.ContainsKey(type);
|
||||
|
||||
public bool TryGet(PbrMapType type, string res, out PolyHavenFile file)
|
||||
{
|
||||
file = default;
|
||||
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>
|
||||
public bool TryGetWithFallback(PbrMapType type, string res, out PolyHavenFile file, out string usedRes)
|
||||
{
|
||||
file = default;
|
||||
usedRes = null;
|
||||
if (!Maps.TryGetValue(type, out var byRes))
|
||||
return Maps.TryGetValue(type, out var byRes)
|
||||
&& PolyHavenFiles.PickWithFallback(byRes, res, out file, out usedRes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Parsed /files/{slug}: texture sets plus (for models) the FBX per resolution.</summary>
|
||||
public sealed class PolyHavenFiles
|
||||
{
|
||||
public readonly List<PolyHavenMapSet> Sets = new List<PolyHavenMapSet>();
|
||||
|
||||
/// <summary>Model geometry: resolution("2k") -> FBX file. Empty for texture assets.</summary>
|
||||
public readonly Dictionary<string, PolyHavenFile> FbxByRes =
|
||||
new Dictionary<string, PolyHavenFile>();
|
||||
|
||||
// Every resolution string seen across all maps (e.g. "1k","2k","4k"), sorted low->high.
|
||||
public List<string> Resolutions = new List<string>();
|
||||
|
||||
public bool HasFbx => FbxByRes.Count > 0;
|
||||
|
||||
public int MapTypeCount
|
||||
{
|
||||
get
|
||||
{
|
||||
var types = new HashSet<PbrMapType>();
|
||||
foreach (var set in Sets)
|
||||
foreach (var t in set.Maps.Keys)
|
||||
types.Add(t);
|
||||
return types.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True if any set carries this map type.</summary>
|
||||
public bool Has(PbrMapType type)
|
||||
{
|
||||
foreach (var set in Sets)
|
||||
if (set.Has(type))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetFbxWithFallback(string res, out PolyHavenFile file, out string usedRes) =>
|
||||
PickWithFallback(FbxByRes, res, out file, out usedRes);
|
||||
|
||||
/// <summary>Exact res, else the highest one at-or-below it, else the lowest available.</summary>
|
||||
internal static bool PickWithFallback(Dictionary<string, PolyHavenFile> byRes, string res,
|
||||
out PolyHavenFile file, out string usedRes)
|
||||
{
|
||||
file = default;
|
||||
usedRes = null;
|
||||
if (byRes == null || byRes.Count == 0)
|
||||
return false;
|
||||
if (byRes.TryGetValue(res, out file)) { usedRes = res; return true; }
|
||||
|
||||
@@ -96,16 +141,23 @@ namespace Chameleon.Editor.PolyHaven
|
||||
{
|
||||
private const string ApiBase = "https://api.polyhaven.com";
|
||||
|
||||
// Poly Haven's dynamic file-tree keys per map type.
|
||||
private static readonly (PbrMapType type, string apiKey)[] ApiKeys =
|
||||
// Map-key suffixes in the dynamic file tree. Textures use the bare names
|
||||
// ("Diffuse", "nor_gl"); multi-set models prefix them ("cart_diff",
|
||||
// "props_roughness"). Longer suffixes first so "roughness" wins over "rough".
|
||||
private static readonly (string suffix, PbrMapType type)[] SuffixMap =
|
||||
{
|
||||
(PbrMapType.Diffuse, "Diffuse"),
|
||||
(PbrMapType.NormalGL, "nor_gl"),
|
||||
(PbrMapType.Rough, "Rough"),
|
||||
(PbrMapType.AO, "AO"),
|
||||
(PbrMapType.Metal, "Metal"),
|
||||
(PbrMapType.Displacement, "Displacement"),
|
||||
(PbrMapType.Arm, "arm"),
|
||||
("displacement", PbrMapType.Displacement),
|
||||
("roughness", PbrMapType.Rough),
|
||||
("metallic", PbrMapType.Metal),
|
||||
("diffuse", PbrMapType.Diffuse),
|
||||
("height", PbrMapType.Displacement),
|
||||
("nor_gl", PbrMapType.NormalGL),
|
||||
("metal", PbrMapType.Metal),
|
||||
("rough", PbrMapType.Rough),
|
||||
("diff", PbrMapType.Diffuse),
|
||||
("disp", PbrMapType.Displacement),
|
||||
("arm", PbrMapType.Arm),
|
||||
("ao", PbrMapType.AO),
|
||||
};
|
||||
|
||||
// Best format preference per map: lossless where it matters, jpg otherwise.
|
||||
@@ -163,7 +215,7 @@ namespace Chameleon.Editor.PolyHaven
|
||||
}, onError);
|
||||
}
|
||||
|
||||
/// <summary>Loads the full texture catalog as browser-grid entries.</summary>
|
||||
/// <summary>Loads the asset catalog ("textures"/"models") as browser-grid entries.</summary>
|
||||
public static void FetchAssets(string type, Action<List<AssetBrowserGrid.Entry>> onDone, Action<string> onError)
|
||||
{
|
||||
EditorWebRequest.GetText($"{ApiBase}/assets?t={type}", text =>
|
||||
@@ -217,15 +269,60 @@ namespace Chameleon.Editor.PolyHaven
|
||||
return string.Join(" ", list);
|
||||
}
|
||||
|
||||
private static bool MatchMapKey(string key, out string setName, out PbrMapType type)
|
||||
{
|
||||
setName = null;
|
||||
type = default;
|
||||
string k = key.ToLowerInvariant();
|
||||
foreach (var (suffix, t) in SuffixMap)
|
||||
{
|
||||
if (k == suffix)
|
||||
{
|
||||
setName = "";
|
||||
type = t;
|
||||
return true;
|
||||
}
|
||||
if (k.EndsWith("_" + suffix, StringComparison.Ordinal))
|
||||
{
|
||||
setName = key.Substring(0, key.Length - suffix.Length - 1);
|
||||
type = t;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PolyHavenFiles ParseFiles(string json)
|
||||
{
|
||||
var root = JObject.Parse(json);
|
||||
var result = new PolyHavenFiles();
|
||||
var resSet = new HashSet<string>();
|
||||
var setsByName = new Dictionary<string, PolyHavenMapSet>();
|
||||
|
||||
foreach (var (type, apiKey) in ApiKeys)
|
||||
foreach (var prop in root)
|
||||
{
|
||||
if (root[apiKey] is not JObject byRes)
|
||||
// Model geometry: fbx -> res -> "fbx" -> {url,size}.
|
||||
if (string.Equals(prop.Key, "fbx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (prop.Value is not JObject fbxByRes) continue;
|
||||
foreach (var resProp in fbxByRes)
|
||||
{
|
||||
if (resProp.Value is JObject inner && inner["fbx"] is JObject fo && fo["url"] != null)
|
||||
{
|
||||
result.FbxByRes[resProp.Key] = new PolyHavenFile(
|
||||
(string)fo["url"], "fbx",
|
||||
fo["size"] != null ? (long)fo["size"] : 0);
|
||||
resSet.Add(resProp.Key);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Everything else: either a map key (possibly set-prefixed) or an
|
||||
// unsupported container (blend/gltf/usd/nor_dx/...) we skip.
|
||||
if (!MatchMapKey(prop.Key, out var setName, out var type))
|
||||
continue;
|
||||
if (prop.Value is not JObject byRes)
|
||||
continue;
|
||||
|
||||
var meta = PbrMapInfo.Get(type);
|
||||
@@ -258,10 +355,26 @@ namespace Chameleon.Editor.PolyHaven
|
||||
}
|
||||
}
|
||||
|
||||
if (perRes.Count > 0)
|
||||
result.Maps[type] = perRes;
|
||||
if (perRes.Count == 0)
|
||||
continue;
|
||||
|
||||
if (!setsByName.TryGetValue(setName, out var set))
|
||||
{
|
||||
set = new PolyHavenMapSet { Name = setName };
|
||||
setsByName[setName] = set;
|
||||
result.Sets.Add(set);
|
||||
}
|
||||
set.Maps[type] = perRes;
|
||||
}
|
||||
|
||||
// Deterministic set order: default set first, then alphabetical.
|
||||
result.Sets.Sort((a, b) =>
|
||||
{
|
||||
if (a.Name.Length == 0 != (b.Name.Length == 0))
|
||||
return a.Name.Length == 0 ? -1 : 1;
|
||||
return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
|
||||
var resList = new List<string>(resSet);
|
||||
resList.Sort((x, y) => ImportUtil.ResolutionRank(x).CompareTo(ImportUtil.ResolutionRank(y)));
|
||||
result.Resolutions = resList;
|
||||
|
||||
@@ -8,14 +8,19 @@ using UnityEngine;
|
||||
namespace Chameleon.Editor.PolyHaven
|
||||
{
|
||||
/// <summary>
|
||||
/// 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}/.
|
||||
/// Editor window: browse/paste a Poly Haven asset (texture or model), fetch its files,
|
||||
/// pick resolution + maps, then download into Assets/Poly Haven/{Name}/. Textures get an
|
||||
/// auto-built URP/Lit material; models additionally download the FBX and remap its
|
||||
/// material slots to the built material(s) — one per texture set.
|
||||
/// </summary>
|
||||
public sealed class PolyHavenImporterWindow : EditorWindow
|
||||
{
|
||||
private const string RootFolder = "Assets/Poly Haven";
|
||||
private const string DefaultRes = "2k";
|
||||
|
||||
private const int TypeTexture = 1;
|
||||
private const int TypeModel = 2;
|
||||
|
||||
private string _urlInput = "https://polyhaven.com/a/";
|
||||
private PolyHavenInfo _info;
|
||||
private PolyHavenFiles _files;
|
||||
@@ -29,14 +34,22 @@ namespace Chameleon.Editor.PolyHaven
|
||||
|
||||
// ---- Browser ----------------------------------------------------------
|
||||
|
||||
// Catalog cached for the whole session (survives window close, cleared on domain reload).
|
||||
private static List<AssetBrowserGrid.Entry> s_catalog;
|
||||
private static readonly string[] TabLabels = { "Textures", "Models" };
|
||||
private static readonly string[] TabTypes = { "textures", "models" };
|
||||
|
||||
// Catalogs cached per type for the whole session (survive window close,
|
||||
// cleared on domain reload).
|
||||
private static readonly Dictionary<string, List<AssetBrowserGrid.Entry>> s_catalogs =
|
||||
new Dictionary<string, List<AssetBrowserGrid.Entry>>();
|
||||
|
||||
private AssetBrowserGrid _grid;
|
||||
private bool _browseFoldout = true;
|
||||
private bool _autoLoadTried;
|
||||
private int _catalogTab;
|
||||
private readonly HashSet<string> _autoLoadTried = new HashSet<string>();
|
||||
private bool _catalogLoading;
|
||||
|
||||
private string CatalogType => TabTypes[_catalogTab];
|
||||
|
||||
[MenuItem("Tools/Poly Haven Importer")]
|
||||
public static void Open()
|
||||
{
|
||||
@@ -49,10 +62,10 @@ namespace Chameleon.Editor.PolyHaven
|
||||
_grid = new AssetBrowserGrid(Repaint);
|
||||
|
||||
// Catalog may already be cached from a previous window instance this session.
|
||||
if (s_catalog != null)
|
||||
if (s_catalogs.TryGetValue(CatalogType, out var cached))
|
||||
{
|
||||
_autoLoadTried = true;
|
||||
_grid.SetCatalog(s_catalog);
|
||||
_autoLoadTried.Add(CatalogType);
|
||||
_grid.SetCatalog(cached);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +75,9 @@ namespace Chameleon.Editor.PolyHaven
|
||||
|
||||
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.",
|
||||
"Pick a texture or model 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; " +
|
||||
"models also import the FBX with the material(s) wired up.",
|
||||
MessageType.Info);
|
||||
|
||||
EditorGUILayout.Space();
|
||||
@@ -93,6 +107,9 @@ namespace Chameleon.Editor.PolyHaven
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private bool IsSupportedType => _info != null && (_info.Type == TypeTexture || _info.Type == TypeModel);
|
||||
private bool IsModel => _info != null && _info.Type == TypeModel;
|
||||
|
||||
private void DrawAssetSection()
|
||||
{
|
||||
EditorGUILayout.Space();
|
||||
@@ -106,11 +123,15 @@ namespace Chameleon.Editor.PolyHaven
|
||||
using (new EditorGUILayout.VerticalScope())
|
||||
{
|
||||
EditorGUILayout.LabelField(_info.Name, EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField($"Slug: {_info.Slug}", EditorStyles.miniLabel);
|
||||
EditorGUILayout.LabelField(
|
||||
$"Slug: {_info.Slug} ({(IsModel ? "Model" : _info.Type == TypeTexture ? "Texture" : "Unsupported")})",
|
||||
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 (!IsSupportedType)
|
||||
EditorGUILayout.HelpBox("Only textures and models are supported (no HDRIs).", MessageType.Warning);
|
||||
if (IsModel && _files != null && !_files.HasFbx)
|
||||
EditorGUILayout.HelpBox("This model has no FBX download — cannot import.", MessageType.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +147,10 @@ namespace Chameleon.Editor.PolyHaven
|
||||
_selectedRes = resArray[resIdx];
|
||||
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.LabelField("Maps", EditorStyles.miniBoldLabel);
|
||||
string mapsLabel = "Maps";
|
||||
if (_files.Sets.Count > 1)
|
||||
mapsLabel += $" ({_files.Sets.Count} texture sets: {string.Join(", ", _files.Sets.Select(s => s.Name))})";
|
||||
EditorGUILayout.LabelField(mapsLabel, EditorStyles.miniBoldLabel);
|
||||
foreach (var meta in PbrMapInfo.All)
|
||||
{
|
||||
if (!_files.Has(meta.Type))
|
||||
@@ -136,10 +160,11 @@ namespace Chameleon.Editor.PolyHaven
|
||||
}
|
||||
|
||||
EditorGUILayout.Space();
|
||||
bool canDownload = !_busy && _info.Type == 1 && AnyMapSelected();
|
||||
bool canDownload = !_busy && IsSupportedType && AnyMapSelected()
|
||||
&& (!IsModel || _files.HasFbx);
|
||||
using (new EditorGUI.DisabledScope(!canDownload))
|
||||
{
|
||||
if (GUILayout.Button("Download / Import", GUILayout.Height(30)))
|
||||
if (GUILayout.Button(IsModel ? "Download / Import Model" : "Download / Import", GUILayout.Height(30)))
|
||||
Download();
|
||||
}
|
||||
}
|
||||
@@ -166,18 +191,28 @@ namespace Chameleon.Editor.PolyHaven
|
||||
|
||||
using (new EditorGUI.DisabledScope(_busy))
|
||||
{
|
||||
using (new EditorGUI.DisabledScope(_catalogLoading))
|
||||
{
|
||||
int newTab = GUILayout.Toolbar(_catalogTab, TabLabels);
|
||||
if (newTab != _catalogTab)
|
||||
{
|
||||
_catalogTab = newTab;
|
||||
_grid.SetCatalog(s_catalogs.TryGetValue(CatalogType, out var cached) ? cached : null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_grid.HasCatalog)
|
||||
{
|
||||
// Auto-load once when the browser is first opened; still offer a manual retry.
|
||||
if (!_catalogLoading && !_autoLoadTried)
|
||||
// Auto-load once per type when first shown; still offer a manual retry.
|
||||
if (!_catalogLoading && !_autoLoadTried.Contains(CatalogType))
|
||||
{
|
||||
_autoLoadTried = true;
|
||||
_autoLoadTried.Add(CatalogType);
|
||||
LoadCatalog();
|
||||
}
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.LabelField(_catalogLoading
|
||||
? "Loading texture catalog…"
|
||||
? $"Loading {CatalogType} catalog…"
|
||||
: "Catalog not loaded.");
|
||||
using (new EditorGUI.DisabledScope(_catalogLoading))
|
||||
if (GUILayout.Button("Load catalog", GUILayout.Width(110)))
|
||||
@@ -186,7 +221,7 @@ namespace Chameleon.Editor.PolyHaven
|
||||
return;
|
||||
}
|
||||
|
||||
_grid.Draw(SelectAsset, LoadCatalog, "textures");
|
||||
_grid.Draw(SelectAsset, LoadCatalog, CatalogType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,17 +234,19 @@ namespace Chameleon.Editor.PolyHaven
|
||||
|
||||
private void LoadCatalog()
|
||||
{
|
||||
string type = CatalogType;
|
||||
_catalogLoading = true;
|
||||
_status = "Loading Poly Haven texture catalog…";
|
||||
_status = $"Loading Poly Haven {type} catalog…";
|
||||
Repaint();
|
||||
|
||||
PolyHavenApi.FetchAssets("textures",
|
||||
PolyHavenApi.FetchAssets(type,
|
||||
list =>
|
||||
{
|
||||
s_catalog = list;
|
||||
s_catalogs[type] = list;
|
||||
_catalogLoading = false;
|
||||
_grid.SetCatalog(list);
|
||||
_status = $"Catalog loaded: {list.Count} textures.";
|
||||
if (type == CatalogType)
|
||||
_grid.SetCatalog(list);
|
||||
_status = $"Catalog loaded: {list.Count} {type}.";
|
||||
Repaint();
|
||||
},
|
||||
err =>
|
||||
@@ -255,9 +292,11 @@ namespace Chameleon.Editor.PolyHaven
|
||||
_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.";
|
||||
_status = IsSupportedType
|
||||
? $"Ready: {files.MapTypeCount} map type(s), {files.Resolutions.Count} resolution(s)" +
|
||||
(files.Sets.Count > 1 ? $", {files.Sets.Count} texture sets" : "") +
|
||||
(IsModel ? (files.HasFbx ? ", FBX available." : ", but no FBX!") : ".")
|
||||
: "Fetched, but this asset type is not supported (textures/models only).";
|
||||
Repaint();
|
||||
}, err => Fail(err));
|
||||
}, err => Fail(err));
|
||||
@@ -265,29 +304,27 @@ namespace Chameleon.Editor.PolyHaven
|
||||
|
||||
// ---- Download ----------------------------------------------------------
|
||||
|
||||
/// <summary>One pending file download: which set/map it belongs to (Type null for the FBX).</summary>
|
||||
private struct Pending
|
||||
{
|
||||
public PolyHavenMapSet Set;
|
||||
public PbrMapType Type;
|
||||
public bool IsFbx;
|
||||
public PolyHavenFile File;
|
||||
public string DestPath;
|
||||
}
|
||||
|
||||
private void Download()
|
||||
{
|
||||
// Resolve which maps to fetch.
|
||||
var wanted = new List<PbrMapType>();
|
||||
// Resolve which map types to fetch (per files-level availability).
|
||||
var wantedTypes = new List<PbrMapType>();
|
||||
foreach (var meta in PbrMapInfo.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);
|
||||
if (on) wantedTypes.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(PbrMapType.Arm) && !wanted.Contains(PbrMapType.Arm))
|
||||
{
|
||||
bool channelMissing = !_files.Has(PbrMapType.Metal)
|
||||
|| !_files.Has(PbrMapType.Rough)
|
||||
|| !_files.Has(PbrMapType.AO);
|
||||
if (channelMissing)
|
||||
wanted.Add(PbrMapType.Arm);
|
||||
}
|
||||
|
||||
if (wanted.Count == 0)
|
||||
if (wantedTypes.Count == 0)
|
||||
return;
|
||||
|
||||
string displayName = _info.Name;
|
||||
@@ -298,14 +335,74 @@ namespace Chameleon.Editor.PolyHaven
|
||||
return;
|
||||
ImportUtil.EnsureFolder(folder);
|
||||
|
||||
// Build the per-set download list. ARM fallback per set: if a set packs
|
||||
// AO/Rough/Metal only into ARM, pull ARM so the builder can unpack it.
|
||||
var pending = new List<Pending>();
|
||||
foreach (var set in _files.Sets)
|
||||
{
|
||||
var setTypes = new List<PbrMapType>();
|
||||
foreach (var t in wantedTypes)
|
||||
if (set.Has(t))
|
||||
setTypes.Add(t);
|
||||
|
||||
if (set.Has(PbrMapType.Arm) && !setTypes.Contains(PbrMapType.Arm))
|
||||
{
|
||||
bool channelMissing = !set.Has(PbrMapType.Metal)
|
||||
|| !set.Has(PbrMapType.Rough)
|
||||
|| !set.Has(PbrMapType.AO);
|
||||
if (channelMissing)
|
||||
setTypes.Add(PbrMapType.Arm);
|
||||
}
|
||||
|
||||
string setPart = string.IsNullOrEmpty(set.Name)
|
||||
? ""
|
||||
: "_" + ImportUtil.SanitizeFileName(set.Name);
|
||||
|
||||
foreach (var type in setTypes)
|
||||
{
|
||||
if (!set.TryGetWithFallback(type, _selectedRes, out var file, out var usedRes))
|
||||
continue;
|
||||
if (usedRes != _selectedRes)
|
||||
Debug.LogWarning($"[PolyHaven] {set.Name}/{type}: {_selectedRes} unavailable, using {usedRes}.");
|
||||
|
||||
var meta = PbrMapInfo.Get(type);
|
||||
pending.Add(new Pending
|
||||
{
|
||||
Set = set,
|
||||
Type = type,
|
||||
File = file,
|
||||
DestPath = $"{folder}/{_info.Slug}{setPart}_{meta.Suffix}.{file.Format}",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Model: the FBX geometry itself.
|
||||
string fbxPath = null;
|
||||
if (IsModel)
|
||||
{
|
||||
if (!_files.TryGetFbxWithFallback(_selectedRes, out var fbx, out var fbxRes))
|
||||
{
|
||||
_status = "No FBX file available for this model.";
|
||||
return;
|
||||
}
|
||||
if (fbxRes != _selectedRes)
|
||||
Debug.LogWarning($"[PolyHaven] FBX: {_selectedRes} unavailable, using {fbxRes}.");
|
||||
fbxPath = $"{folder}/{_info.Slug}_{fbxRes}.fbx";
|
||||
pending.Add(new Pending { IsFbx = true, File = fbx, DestPath = fbxPath });
|
||||
}
|
||||
|
||||
if (pending.Count == 0)
|
||||
return;
|
||||
|
||||
int maxSize = ImportUtil.ResolutionToMaxSize(_selectedRes);
|
||||
_busy = true;
|
||||
_status = $"Downloading {wanted.Count} map(s) at {_selectedRes.ToUpperInvariant()}…";
|
||||
_status = $"Downloading {pending.Count} file(s) at {_selectedRes.ToUpperInvariant()}…";
|
||||
Repaint();
|
||||
|
||||
var results = new List<PbrMaterialBuilder.Downloaded>();
|
||||
var resultsBySet = new Dictionary<PolyHavenMapSet, List<PbrMaterialBuilder.Downloaded>>();
|
||||
var failures = new List<string>();
|
||||
int remaining = wanted.Count;
|
||||
bool fbxOk = false;
|
||||
int remaining = pending.Count;
|
||||
|
||||
AssetDatabase.StartAssetEditing();
|
||||
|
||||
@@ -318,7 +415,8 @@ namespace Chameleon.Editor.PolyHaven
|
||||
AssetDatabase.StopAssetEditing();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
if (results.Count == 0)
|
||||
int mapCount = resultsBySet.Sum(kv => kv.Value.Count);
|
||||
if (mapCount == 0)
|
||||
{
|
||||
_busy = false;
|
||||
_status = "Download failed — no maps saved.";
|
||||
@@ -328,38 +426,53 @@ namespace Chameleon.Editor.PolyHaven
|
||||
return;
|
||||
}
|
||||
|
||||
PbrMaterialBuilder.Build(folder, displayName, _info.Slug, maxSize, results);
|
||||
// One material per texture set (single-set assets keep the plain name).
|
||||
var setMaterials = new List<(string setName, Material mat)>();
|
||||
foreach (var set in _files.Sets)
|
||||
{
|
||||
if (!resultsBySet.TryGetValue(set, out var maps) || maps.Count == 0)
|
||||
continue;
|
||||
bool named = !string.IsNullOrEmpty(set.Name) && _files.Sets.Count > 1;
|
||||
string matName = named ? $"{displayName} {set.Name}" : displayName;
|
||||
string setSlug = named ? $"{_info.Slug}_{ImportUtil.SanitizeFileName(set.Name)}" : _info.Slug;
|
||||
var mat = PbrMaterialBuilder.Build(folder, matName, setSlug, maxSize, maps);
|
||||
if (mat)
|
||||
setMaterials.Add((set.Name, mat));
|
||||
}
|
||||
|
||||
// Model: wire the built materials into the imported FBX.
|
||||
if (IsModel && fbxOk && setMaterials.Count > 0)
|
||||
ModelImportUtil.RemapMaterials(fbxPath, setMaterials);
|
||||
|
||||
_busy = false;
|
||||
_status = $"Done: {results.Count} map(s) imported, material built in {folder}.";
|
||||
_status = $"Done: {mapCount} map(s) imported, {setMaterials.Count} material(s) built in {folder}"
|
||||
+ (IsModel ? (fbxOk ? ", FBX imported." : ", but the FBX download FAILED.") : ".");
|
||||
if (failures.Count > 0)
|
||||
_status += $" ({failures.Count} map(s) failed — see console.)";
|
||||
_status += $" ({failures.Count} file(s) failed — see console.)";
|
||||
Repaint();
|
||||
}
|
||||
|
||||
foreach (var type in wanted)
|
||||
foreach (var p in pending)
|
||||
{
|
||||
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 = PbrMapInfo.Get(type);
|
||||
string fileName = $"{_info.Slug}_{meta.Suffix}.{file.Format}";
|
||||
string destPath = $"{folder}/{fileName}";
|
||||
|
||||
EditorWebRequest.DownloadFile(file.Url, Path.GetFullPath(destPath),
|
||||
var item = p; // capture per iteration
|
||||
EditorWebRequest.DownloadFile(item.File.Url, Path.GetFullPath(item.DestPath),
|
||||
onDone: () =>
|
||||
{
|
||||
results.Add(new PbrMaterialBuilder.Downloaded { Type = type, AssetPath = destPath });
|
||||
if (item.IsFbx)
|
||||
{
|
||||
fbxOk = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!resultsBySet.TryGetValue(item.Set, out var list))
|
||||
resultsBySet[item.Set] = list = new List<PbrMaterialBuilder.Downloaded>();
|
||||
list.Add(new PbrMaterialBuilder.Downloaded { Type = item.Type, AssetPath = item.DestPath });
|
||||
}
|
||||
FinishOne();
|
||||
},
|
||||
onError: err =>
|
||||
{
|
||||
failures.Add($"{type}: {err}");
|
||||
failures.Add($"{(item.IsFbx ? "FBX" : item.Type.ToString())}: {err}");
|
||||
FinishOne();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Chameleon.Editor.WebAssets
|
||||
{
|
||||
/// <summary>Shared helpers for imported 3D models (FBX etc.).</summary>
|
||||
public static class ModelImportUtil
|
||||
{
|
||||
/// <summary>
|
||||
/// Remaps every source material of an imported model to the given built materials.
|
||||
/// With a single set, all slots get that material; with multiple sets, the source
|
||||
/// material name is matched against the set names (substring, case-insensitive).
|
||||
/// </summary>
|
||||
public static void RemapMaterials(string modelAssetPath, List<(string setName, Material mat)> sets)
|
||||
{
|
||||
if (sets == null || sets.Count == 0)
|
||||
return;
|
||||
var importer = AssetImporter.GetAtPath(modelAssetPath) as ModelImporter;
|
||||
if (!importer)
|
||||
{
|
||||
Debug.LogWarning($"[PBR Import] No model importer at {modelAssetPath} — skipping material remap.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Source material names = the embedded materials Unity generated on import.
|
||||
var srcNames = new List<string>();
|
||||
foreach (var obj in AssetDatabase.LoadAllAssetsAtPath(modelAssetPath))
|
||||
if (obj is Material m)
|
||||
srcNames.Add(m.name);
|
||||
|
||||
if (srcNames.Count == 0)
|
||||
{
|
||||
Debug.LogWarning($"[PBR Import] {modelAssetPath} exposes no materials to remap.");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var name in srcNames)
|
||||
{
|
||||
var target = Pick(name, sets);
|
||||
if (!target)
|
||||
continue;
|
||||
importer.AddRemap(new AssetImporter.SourceAssetIdentifier(typeof(Material), name), target);
|
||||
}
|
||||
importer.SaveAndReimport();
|
||||
Debug.Log($"[PBR Import] Remapped {srcNames.Count} material slot(s) on {modelAssetPath}.");
|
||||
}
|
||||
|
||||
private static Material Pick(string srcName, List<(string setName, Material mat)> sets)
|
||||
{
|
||||
if (sets.Count == 1)
|
||||
return sets[0].mat;
|
||||
|
||||
string s = srcName.ToLowerInvariant();
|
||||
foreach (var (setName, mat) in sets)
|
||||
if (!string.IsNullOrEmpty(setName) && s.Contains(setName.ToLowerInvariant()))
|
||||
return mat;
|
||||
|
||||
Debug.LogWarning($"[PBR Import] Material '{srcName}' matched no texture set — using '{sets[0].setName}'.");
|
||||
return sets[0].mat;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a54d5f7a2461bb54ca28df0d87665f8e
|
||||
@@ -19,7 +19,8 @@ namespace Chameleon.Editor.WebAssets
|
||||
public string AssetPath; // "Assets/Poly Haven/.../foo_diffuse.jpg"
|
||||
}
|
||||
|
||||
public static void Build(string folder, string displayName, string slug, int maxSize,
|
||||
/// <summary>Builds the material and returns it (null if it could not be created).</summary>
|
||||
public static Material Build(string folder, string displayName, string slug, int maxSize,
|
||||
List<Downloaded> maps)
|
||||
{
|
||||
var byType = new Dictionary<PbrMapType, string>();
|
||||
@@ -61,7 +62,7 @@ namespace Chameleon.Editor.WebAssets
|
||||
if (!shader)
|
||||
{
|
||||
Debug.LogError("[PBR Import] URP/Lit shader not found — is URP installed?");
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
var mat = new Material(shader) { name = displayName };
|
||||
@@ -105,6 +106,7 @@ namespace Chameleon.Editor.WebAssets
|
||||
EditorGUIUtility.PingObject(mat);
|
||||
Selection.activeObject = mat;
|
||||
Debug.Log($"[PBR Import] Material created: {matPath}");
|
||||
return mat;
|
||||
}
|
||||
|
||||
private static Texture2D Load(string path) => AssetDatabase.LoadAssetAtPath<Texture2D>(path);
|
||||
|
||||
Reference in new issue
Block a user