Editor/MaterialGallery.cs
using Sandbox;
using Sandbox.UI;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Editor;
[Dock( "Editor", "Materials Gallery", "collections" )]
public class MaterialGallery : Widget
{
private MaterialGalleryView GalleryView;
private LineEdit SearchBar;
private Widget HeaderBar;
private FloatSlider SizeSlider;
private Label CountLabel;
private Button RefreshButton;
private Checkbox LocalMaterialsCheckbox;
private ComboBox SortCombo;
private static MaterialGallery _instance;
private List<Package> _allPackages = new();
private List<Asset> _localMaterials = new();
private bool _isLoading = false;
private bool _showLocalMaterials = false;
private string _currentSort = "a-z";
private static readonly Dictionary<string, string> SortOptions = new()
{
{ "a-z", "A-Z" },
{ "z-a", "Z-A" },
{ "newest", "Newest" },
{ "updated", "Updated" }
};
public static MaterialGallery Instance
{
get
{
if ( !_instance.IsValid() ) return null;
return _instance;
}
private set => _instance = value;
}
public MaterialGallery( Widget parent ) : base( parent )
{
Instance ??= this;
WindowTitle = "Materials Gallery";
Size = new Vector2( 1200, 800 );
Layout = Layout.Column();
Layout.Spacing = 0;
CreateHeader();
CreateGallery();
_ = LoadMaterialsAsync();
}
private void CreateHeader()
{
HeaderBar = new Widget( this );
HeaderBar.Layout = Layout.Row();
HeaderBar.Layout.Spacing = 8;
HeaderBar.Layout.Margin = 8;
HeaderBar.FixedHeight = 44;
Layout.Add( HeaderBar );
SearchBar = new LineEdit();
SearchBar.PlaceholderText = "⌕ Search materials...";
SearchBar.TextEdited += ( text ) => FilterMaterials();
SearchBar.MinimumHeight = 36;
SearchBar.MinimumWidth = 200;
HeaderBar.Layout.Add( SearchBar );
SortCombo = new ComboBox();
SortCombo.MinimumWidth = 100;
SortCombo.ToolTip = "Sort materials";
foreach ( var sort in SortOptions )
{
SortCombo.AddItem( sort.Value, null );
}
SortCombo.CurrentIndex = 0;
SortCombo.ItemChanged += () =>
{
var sortKey = SortOptions.ElementAt( SortCombo.CurrentIndex ).Key;
_currentSort = sortKey;
ApplySortAndFilter();
};
HeaderBar.Layout.Add( SortCombo );
LocalMaterialsCheckbox = new Checkbox();
LocalMaterialsCheckbox.Text = "Local";
LocalMaterialsCheckbox.ToolTip = "Include local project materials";
LocalMaterialsCheckbox.State = CheckState.Off;
LocalMaterialsCheckbox.StateChanged = isChecked =>
{
_showLocalMaterials = isChecked == CheckState.On;
_ = LoadMaterialsAsync();
};
HeaderBar.Layout.Add( LocalMaterialsCheckbox );
SizeSlider = new FloatSlider(HeaderBar);
SizeSlider.Minimum = 64;
SizeSlider.Maximum = 512;
SizeSlider.Value = 256;
SizeSlider.Step = 8;
SizeSlider.MinimumWidth = 150;
SizeSlider.ToolTip = "Thumbnail Size";
SizeSlider.OnValueEdited += () =>
{
GalleryView?.SetThumbnailSize( (int)SizeSlider.Value );
};
HeaderBar.Layout.Add( SizeSlider );
CountLabel = new Label( "Loading..." );
CountLabel.MinimumWidth = 120;
HeaderBar.Layout.Add( CountLabel );
RefreshButton = new Button( "↻" );
RefreshButton.FixedSize = 36;
RefreshButton.ToolTip = "Refresh materials";
RefreshButton.Clicked += () => _ = LoadMaterialsAsync();
HeaderBar.Layout.Add( RefreshButton );
}
private void CreateGallery()
{
GalleryView = new MaterialGalleryView( this );
GalleryView.OnPackageSelected = OnPackageSelected;
GalleryView.OnAssetSelected = OnAssetSelected;
Layout.Add( GalleryView );
}
private async Task LoadMaterialsAsync()
{
if ( _isLoading ) return;
_isLoading = true;
RefreshButton.Enabled = false;
CountLabel.Text = "Loading...";
try
{
_allPackages.Clear();
_localMaterials.Clear();
GalleryView.ClearCache();
// Get organizations from settings
var organizations = new List<string> { MappingToolSettings.DefaultOrganization };
organizations.AddRange( MappingToolSettings.AdditionalOrganizations );
// Remove duplicates and empty entries
organizations = organizations
.Where( org => !string.IsNullOrWhiteSpace( org ) )
.Distinct( StringComparer.OrdinalIgnoreCase )
.ToList();
const int pageSize = 100;
// Load materials from all configured organizations
foreach ( var org in organizations )
{
try
{
int skip = 0;
bool hasMore = true;
while ( hasMore )
{
var query = $"type:material org:{org}";
var result = await Package.FindAsync(
query,
take: pageSize,
skip: skip
);
if ( result.Packages.Any() )
{
_allPackages.AddRange( result.Packages );
skip += pageSize;
CountLabel.Text = $"Loading... {_allPackages.Count} from {organizations.Count} org(s)";
if ( result.Packages.Count() < pageSize )
{
hasMore = false;
}
}
else
{
hasMore = false;
}
}
}
catch ( Exception ex )
{
Log.Warning( $"Failed to load materials from org '{org}': {ex.Message}" );
}
}
if ( _showLocalMaterials )
{
_localMaterials = AssetSystem.All
.Where( a => a.AssetType == AssetType.Material )
.ToList();
CountLabel.Text = $"Loading... {_allPackages.Count} online + {_localMaterials.Count} local";
}
if ( _allPackages.Count == 0 && _localMaterials.Count == 0 )
{
CountLabel.Text = "No materials found";
}
else
{
ApplySortAndFilter();
}
}
catch ( System.Exception ex )
{
Log.Warning( $"Failed to load materials: {ex.Message}" );
CountLabel.Text = "Error loading";
}
finally
{
_isLoading = false;
RefreshButton.Enabled = true;
}
}
private void FilterMaterials()
{
ApplySortAndFilter();
}
private void ApplySortAndFilter()
{
var packages = _allPackages;
var localAssets = _localMaterials;
if ( !string.IsNullOrWhiteSpace( SearchBar.Text ) )
{
var searchLower = SearchBar.Text.ToLower();
packages = packages.Where( p =>
p.Title.ToLower().Contains( searchLower ) ||
p.Org.Title.ToLower().Contains( searchLower ) ||
(p.Summary?.ToLower().Contains( searchLower ) ?? false)
).ToList();
localAssets = localAssets.Where( a =>
a.Name.ToLower().Contains( searchLower ) ||
a.Path.ToLower().Contains( searchLower )
).ToList();
}
packages = SortPackages( packages );
localAssets = SortAssets( localAssets );
GalleryView.SetMaterials( packages, localAssets );
UpdateCount();
}
private List<Package> SortPackages( List<Package> packages )
{
return _currentSort switch
{
"a-z" => packages.OrderBy( p => p.Title ).ToList(),
"z-a" => packages.OrderByDescending( p => p.Title ).ToList(),
"newest" => packages.OrderByDescending( p => p.Created ).ToList(),
"updated" => packages.OrderByDescending( p => p.Updated ).ToList(),
_ => packages
};
}
private List<Asset> SortAssets( List<Asset> assets )
{
return _currentSort switch
{
"a-z" => assets.OrderBy( a => a.Name ).ToList(),
"z-a" => assets.OrderByDescending( a => a.Name ).ToList(),
"newest" => assets.OrderByDescending( a => a.LastOpened ).ToList(),
_ => assets
};
}
private void UpdateCount()
{
var current = GalleryView?.ItemCount ?? 0;
var totalOnline = _allPackages.Count;
var totalLocal = _localMaterials.Count;
if ( _showLocalMaterials )
{
CountLabel.Text = $"{current} of {totalOnline + totalLocal} ({totalLocal} local)";
}
else if ( current == totalOnline )
{
CountLabel.Text = $"{totalOnline} materials";
}
else
{
CountLabel.Text = $"{current} of {totalOnline}";
}
}
private void OnPackageSelected( Package package )
{
_ = SetActiveMaterial( package );
}
private void OnAssetSelected( Asset asset )
{
_ = SetActiveMaterialFromAsset( asset );
}
CancellationTokenSource packageCTS;
private async Task SetActiveMaterial( Package package )
{
try
{
packageCTS?.Cancel();
packageCTS = new CancellationTokenSource();
package = await Package.FetchAsync( package.FullIdent, false );
var asset = await AssetSystem.InstallAsync( package.FullIdent, true, null, packageCTS.Token );
if ( asset is null )
{
Log.Warning( $"Failed to install material: {package.Title}" );
return;
}
if ( packageCTS.Token.IsCancellationRequested )
return;
SetMaterialOnTool( asset );
EditorUtility.InspectorObject = asset;
EditorUtility.PlayAssetSound( asset );
Log.Info( $"Installed material: {package.Title} at {asset.RelativePath}" );
}
catch ( System.Exception ex )
{
Log.Warning( $"Failed to install material: {ex.Message}" );
}
}
private async Task SetActiveMaterialFromAsset( Asset asset )
{
try
{
await Task.Delay( 1 );
SetMaterialOnTool( asset );
EditorUtility.InspectorObject = asset;
EditorUtility.PlayAssetSound( asset );
Log.Info( $"Selected local material: {asset.Name}" );
}
catch ( System.Exception ex )
{
Log.Warning( $"Failed to set material: {ex.Message}" );
}
}
private void SetMaterialOnTool( Asset asset )
{
var sceneView = SceneViewWidget.Current;
if ( sceneView?.Tools?.CurrentTool is Editor.MeshEditor.MeshTool meshtool )
{
try
{
if ( asset.LoadResource<Material>() is Material mat )
{
meshtool.ActiveMaterial = mat;
}
}
catch
{
}
}
}
}
public class MaterialGalleryView : Widget
{
private ListView _listView;
private List<object> _items = new();
private int _thumbnailSize = 256;
private Dictionary<string, Pixmap> _pixmapCache = new();
private Dictionary<string, int> _loadingRetries = new();
private const int MaxRetries = 5;
public Action<Package> OnPackageSelected;
public Action<Asset> OnAssetSelected;
public int ItemCount => _items.Count;
public MaterialGalleryView( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Spacing = 0;
_listView = new ListView( this );
_listView.ItemSize = new Vector2( _thumbnailSize + 16, _thumbnailSize + 16 );
_listView.ItemSpacing = 1;
_listView.ItemAlign = Align.Center;
_listView.ItemPaint = PaintItem;
_listView.ItemActivated = OnItemDoubleClicked;
Layout.Add( _listView );
}
protected override void OnPaint()
{
base.OnPaint();
Paint.ClearBrush();
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground );
Paint.DrawRect( LocalRect );
}
public void ClearCache()
{
_pixmapCache.Clear();
_loadingRetries.Clear();
}
public void SetMaterials( List<Package> packages, List<Asset> localAssets )
{
_items.Clear();
_items.AddRange( packages );
_items.AddRange( localAssets );
_listView.SetItems( _items );
}
public void SetThumbnailSize( int size )
{
_thumbnailSize = size;
_listView.ItemSize = new Vector2( _thumbnailSize + 16, _thumbnailSize + 16 );
ClearCache();
_listView.SetItems( _items );
}
private void OnItemDoubleClicked( object item )
{
if ( item is Package package )
{
OnPackageSelected?.Invoke( package );
}
else if ( item is Asset asset )
{
OnAssetSelected?.Invoke( asset );
}
}
private void PaintItem( VirtualWidget item )
{
var obj = item.Object;
if ( obj is Package package )
{
PaintPackageItem( item, package );
}
else if ( obj is Asset asset )
{
PaintAssetItem( item, asset );
}
}
private void PaintPackageItem( VirtualWidget item, Package package )
{
var rect = item.Rect;
var hovered = item.Hovered;
PaintCardBackground( rect, hovered );
var titleBarHeight = Math.Max( 16, _thumbnailSize * 0.08f );
var fontSize = Math.Max( 7, (int)(_thumbnailSize * 0.03f) );
var thumbRect = rect.Shrink( 8, 8, 8, 8 + titleBarHeight );
var thumbUrl = package.Thumb;
if ( !string.IsNullOrEmpty( thumbUrl ) )
{
if ( _pixmapCache.TryGetValue( thumbUrl, out var cachedPixmap ) )
{
PaintThumbnail( thumbRect, cachedPixmap, hovered );
}
else
{
var retries = _loadingRetries.GetValueOrDefault( thumbUrl, 0 );
if ( retries < MaxRetries )
{
PaintLoadingPlaceholder( thumbRect, retries );
if ( retries == 0 || !_loadingRetries.ContainsKey( thumbUrl ) )
{
_ = LoadThumbnailAsync( thumbUrl );
}
}
else
{
PaintErrorPlaceholder( thumbRect );
}
}
}
else
{
PaintErrorPlaceholder( thumbRect );
}
var titleRect = new Rect( rect.Left + 8, thumbRect.Bottom, rect.Width - 16, titleBarHeight );
PaintTitleBar( titleRect, fontSize, package.Title );
}
private void PaintAssetItem( VirtualWidget item, Asset asset )
{
var rect = item.Rect;
var hovered = item.Hovered;
PaintCardBackground( rect, hovered, Theme.Green.WithAlpha( 0.2f ) );
var titleBarHeight = Math.Max( 16, _thumbnailSize * 0.08f );
var fontSize = Math.Max( 7, (int)(_thumbnailSize * 0.03f) );
var thumbRect = rect.Shrink( 8, 8, 8, 8 + titleBarHeight );
var thumbPath = asset.GetAssetThumb();
if ( thumbPath != null )
{
PaintThumbnail( thumbRect, thumbPath, hovered );
}
else
{
PaintErrorPlaceholder( thumbRect );
}
var badgeRect = new Rect( thumbRect.Right - 50, thumbRect.Top + 4, 45, 16 );
Paint.SetBrush( Theme.Green );
Paint.DrawRect( badgeRect, 8 );
Paint.SetPen( Color.White );
Paint.SetDefaultFont( 7, 700 );
Paint.DrawText( badgeRect, "LOCAL", TextFlag.Center );
var titleRect = new Rect( rect.Left + 8, thumbRect.Bottom, rect.Width - 16, titleBarHeight );
PaintTitleBar( titleRect, fontSize, asset.Name );
}
private void PaintCardBackground( Rect rect, bool hovered, Color? highlightColor = null )
{
Paint.ClearPen();
if ( hovered )
{
Paint.SetBrush( highlightColor ?? Theme.Blue.WithAlpha( 0.2f ) );
Paint.DrawRect( rect, 8 );
Paint.SetPen( highlightColor ?? Theme.Blue, 2 );
Paint.DrawRect( rect, 8 );
}
else
{
Paint.SetBrush( Theme.ControlBackground );
Paint.DrawRect( rect, 8 );
}
}
private void PaintThumbnail( Rect thumbRect, Pixmap pixmap, bool hovered )
{
Paint.SetBrush( Color.Black );
Paint.DrawRect( thumbRect, 4 );
Paint.Draw( thumbRect, pixmap );
Paint.ClearBrush();
Paint.SetPen( hovered ? Theme.Blue : Theme.ControlBackground.Lighten( 0.2f ), 1 );
Paint.DrawRect( thumbRect, 4 );
}
private void PaintTitleBar( Rect titleRect, int fontSize, string title )
{
Paint.SetPen( Theme.TextControl );
Paint.SetDefaultFont( fontSize, 600 );
Paint.DrawText( titleRect, title, TextFlag.Center | TextFlag.SingleLine );
}
private async Task LoadThumbnailAsync( string thumbUrl )
{
var retryCount = _loadingRetries.GetValueOrDefault( thumbUrl, 0 );
try
{
if ( retryCount > 0 )
{
var delay = (int)(Math.Pow( 2, retryCount ) * 100 - 100);
await Task.Delay( delay );
}
var texture = Texture.Load( thumbUrl );
if ( texture == null )
{
await Task.Delay( 200 );
texture = Texture.Load( thumbUrl );
}
if ( texture != null )
{
var pixmap = Pixmap.FromTexture( texture );
if ( pixmap != null )
{
_pixmapCache[thumbUrl] = pixmap;
_loadingRetries.Remove( thumbUrl );
Update();
return;
}
}
_loadingRetries[thumbUrl] = retryCount + 1;
if ( retryCount + 1 < MaxRetries )
{
Update();
_ = LoadThumbnailAsync( thumbUrl );
}
else
{
Update();
}
}
catch ( Exception ex )
{
if ( retryCount == 0 )
{
Log.Warning( $"Failed to load thumbnail {thumbUrl}: {ex.Message}" );
}
_loadingRetries[thumbUrl] = retryCount + 1;
if ( retryCount + 1 < MaxRetries )
{
Update();
_ = LoadThumbnailAsync( thumbUrl );
}
else
{
Update();
}
}
}
private async Task LoadLocalThumbnailAsync( string thumbPath, Asset asset )
{
var retryCount = _loadingRetries.GetValueOrDefault( thumbPath, 0 );
try
{
if ( retryCount > 0 )
{
var delay = (int)(Math.Pow( 2, retryCount ) * 100 - 100);
await Task.Delay( delay );
}
var material = asset.LoadResource<Material>();
if ( material != null )
{
var texture = material.GetTexture( "Color" ) ?? material.GetTexture( "Albedo" );
if ( texture != null )
{
var pixmap = Pixmap.FromTexture( texture );
if ( pixmap != null )
{
_pixmapCache[thumbPath] = pixmap;
_loadingRetries.Remove( thumbPath );
Update();
return;
}
}
}
_loadingRetries[thumbPath] = retryCount + 1;
if ( retryCount + 1 < MaxRetries )
{
Update();
_ = LoadLocalThumbnailAsync( thumbPath, asset );
}
else
{
Update();
}
}
catch ( Exception ex )
{
if ( retryCount == 0 )
{
Log.Warning( $"Failed to load local thumbnail for {asset.Name}: {ex.Message}" );
}
_loadingRetries[thumbPath] = retryCount + 1;
if ( retryCount + 1 < MaxRetries )
{
Update();
_ = LoadLocalThumbnailAsync( thumbPath, asset );
}
else
{
Update();
}
}
}
private void PaintLoadingPlaceholder( Rect thumbRect, int retries )
{
Paint.SetBrush( Theme.ControlBackground.Darken( 0.3f ) );
Paint.DrawRect( thumbRect, 4 );
Paint.SetPen( Theme.TextDark );
Paint.SetDefaultFont( 9 );
var dots = new string( '●', Math.Min( retries + 1, 3 ) );
Paint.DrawText( thumbRect, dots, TextFlag.Center );
}
private void PaintErrorPlaceholder( Rect thumbRect )
{
Paint.SetBrush( Theme.ControlBackground.Darken( 0.5f ) );
Paint.DrawRect( thumbRect, 4 );
Paint.SetPen( Theme.Red.WithAlpha( 0.5f ) );
Paint.SetDefaultFont( 10 );
Paint.DrawText( thumbRect, "✖", TextFlag.Center );
}
}