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:
adminandClaude Fable 5 committed 2026-07-16 16:43:24 +02:00
1 parent 2b9a189d58
commit eab5081354
5 files changed
+391 -98

No files matched your search

@@ -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();
});
}