Editor/UI/ArchMaterialBrowser.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Editor;
using Sandbox;
using Margin = Sandbox.UI.Margin;
namespace Sunless.Architecture;
public sealed class ArchMaterialBrowser : Dialog {
sealed class Entry {
public string Name { get; init; }
public string Category { get; init; }
public string Id { get; init; }
public string Kind { get; init; }
}
readonly ArchMapPalette.Document document;
readonly Action changed;
readonly List<Entry> library = new();
ArchTileGrid roles;
ArchTileGrid grid;
Label roleHeading;
Label roleDetail;
LineEdit search;
ComboBox categories;
Dictionary<ArchSurface, ArchMapPalette.Surface> bindings = new();
ArchMapPalette.Surface selected;
string categoryFilter = "";
string query = "";
public ArchMaterialBrowser( Widget parent, ArchMapPalette.Document document, Action changed ) : base( parent ) {
this.document = document;
this.changed = changed;
Window.Title = $"RoP Materials - {document.Map}";
Window.SetWindowIcon( "texture" );
var screen = parent?.ScreenGeometry ?? new Rect( 0, 0, 1600, 900 );
Window.Size = new Vector2( Math.Max( 1280f, screen.Width * 0.7f ), Math.Max( 720f, screen.Height * 0.7f ) );
ScanLibrary();
Build();
bindings = ArchMapPalette.Resolve( document );
selected = document.Surfaces.FirstOrDefault();
ShowRoles();
ShowSelected();
ShowLibrary();
}
void ScanLibrary() {
library.Clear();
var wanted = ArchHost.Load().MaterialLibrary.Replace( '/', Path.DirectorySeparatorChar );
var root = Path.Combine( Project.Current?.GetRootPath() ?? "", wanted );
if ( !Directory.Exists( root ) ) {
return;
}
foreach ( var categoryDir in Directory.GetDirectories( root ).OrderBy( p => p ) ) {
var category = Path.GetFileName( categoryDir );
foreach ( var materialDir in Directory.GetDirectories( categoryDir ).OrderBy( p => p ) ) {
var name = Path.GetFileName( materialDir );
if ( !File.Exists( Path.Combine( materialDir, $"{name}.vmat" ) ) ) {
continue;
}
library.Add( new Entry {
Name = name,
Category = category,
Id = SourceId( materialDir ),
Kind = name.StartsWith( "map_trim_", StringComparison.OrdinalIgnoreCase ) ? "trim" : "tile"
} );
}
}
}
// The importer stamps "// SOURCE megascans:<id>" into the vmat, or leaves _source.txt beside the pngs.
static string SourceId( string materialDir ) {
foreach ( var candidate in Directory.GetFiles( materialDir, "_source.txt" ).Concat( Directory.GetFiles( materialDir, "*.vmat" ) ) ) {
try {
var text = File.ReadAllText( candidate );
var marker = text.IndexOf( "megascans:", StringComparison.OrdinalIgnoreCase );
if ( marker < 0 ) {
continue;
}
var id = new string( text.Skip( marker + 10 ).TakeWhile( char.IsLetterOrDigit ).ToArray() );
if ( !string.IsNullOrWhiteSpace( id ) ) {
return id;
}
} catch ( Exception ) {
}
}
return null;
}
void Build() {
Layout = Layout.Column();
Layout.Margin = 0;
var split = Layout.AddRow( 1 );
var sidebar = new Widget( this ) { Layout = Layout.Column() };
sidebar.MinimumWidth = 300;
sidebar.MaximumWidth = 300;
sidebar.Layout.Margin = 0;
sidebar.Layout.Spacing = 0;
var caption = new Label( "Palette roles", sidebar );
caption.SetStyles( "font-weight: 700; font-size: 13px; margin: 10px 12px 6px 12px;" );
sidebar.Layout.Add( caption );
sidebar.Layout.AddSeparator();
roles = new ArchTileGrid( sidebar, ArchTileSize.Role );
sidebar.Layout.Add( roles, 1 );
split.Add( sidebar );
split.AddSeparator();
var detail = new Widget( this ) { Layout = Layout.Column() };
detail.Layout.Margin = 0;
detail.Layout.Spacing = 0;
var head = detail.Layout.AddColumn();
head.Margin = new Margin( 12, 10, 12, 8 );
head.Spacing = 3;
roleHeading = new Label( "" );
roleHeading.SetStyles( "font-size: 15px; font-weight: 700;" );
head.Add( roleHeading );
roleDetail = new Label( "" );
roleDetail.SetStyles( "color: rgba(255,255,255,0.55); font-size: 11px;" );
roleDetail.WordWrap = true;
head.Add( roleDetail );
var filters = detail.Layout.AddRow();
filters.Margin = new Margin( 12, 0, 12, 8 );
filters.Spacing = 6;
search = new LineEdit( "" ) { PlaceholderText = "Filter materials..." };
search.TextEdited += text => { query = text ?? ""; ShowLibrary(); };
filters.Add( search, 1 );
categories = new ComboBox();
categories.AddItem( "All categories", null, () => { categoryFilter = ""; ShowLibrary(); } );
foreach ( var category in library.Select( e => e.Category ).Distinct().OrderBy( c => c ) ) {
var captured = category;
categories.AddItem( captured, null, () => { categoryFilter = captured; ShowLibrary(); } );
}
filters.Add( categories );
detail.Layout.AddSeparator();
grid = new ArchTileGrid( detail, ArchTileSize.Preview );
detail.Layout.Add( grid, 1 );
detail.Layout.AddSeparator();
var footer = detail.Layout.AddRow();
footer.Margin = new Margin( 12, 8, 12, 10 );
footer.Spacing = 6;
footer.Add( new Button( "Rebuild Previews", "refresh" ) {
ToolTip = "Wipe the cached thumbnails and 3D slot previews, then re-render them",
Clicked = () => {
ArchThumb.Forget();
ArchSlotPreview.Invalidate();
ShowRoles();
ShowLibrary();
}
} );
footer.AddStretchCell();
footer.Add( new Button.Primary( "Done", "check" ) { Clicked = Close } );
split.Add( detail, 1 );
}
void ShowRoles() {
bindings = ArchMapPalette.Resolve( document );
var bound = new HashSet<ArchMapPalette.Surface>( bindings.Values );
var builder = new ArchTileSection {
Title = $"BUILDER SLOTS - {bindings.Count}",
Note = "Assigned automatically by the Architecture tool."
};
foreach ( var slot in ArchMapPalette.SlotOrder ) {
if ( !bindings.TryGetValue( slot, out var surface ) ) {
continue;
}
var captured = surface;
var captureSlot = slot;
builder.Tiles.Add( parent => RoleTile( parent, captured, captureSlot ) );
}
var extra = new ArchTileSection {
Title = $"PALETTE ONLY - {document.Surfaces.Count - bound.Count}",
Note = "Hand-painted per face in the Mapping tool."
};
foreach ( var surface in document.Surfaces.Where( s => !bound.Contains( s ) ) ) {
var captured = surface;
extra.Tiles.Add( parent => RoleTile( parent, captured, null ) );
}
roles.Set( new[] { builder, extra } );
}
ArchTile RoleTile( Widget parent, ArchMapPalette.Surface surface, ArchSurface? slot ) {
var missing = !ArchMapPalette.IsImported( surface );
var thumb = slot.HasValue && !missing
? ArchSlotPreview.For( document.Map, slot.Value, bindings, 512 ) ?? ArchThumb.For( surface.Category, surface.Name, surface.Id )
: ArchThumb.For( surface.Category, surface.Name, surface.Id );
return new ArchTile( parent, thumb, surface.Role, missing ? "NOT IMPORTED" : surface.Category, missing, ArchTileSize.Role ) {
Badge = slot?.ToString(),
Glyph = slot.HasValue ? box => ArchSlotIcon.Draw( box, slot.Value, Color.White ) : null,
Dim = slot.HasValue ? 1f : 0.7f,
ToolTip = surface.Name,
Clicked = () => { selected = surface; ShowSelected(); ShowLibrary(); Update(); },
Current = () => ReferenceEquals( selected, surface )
};
}
void ShowSelected() {
if ( selected is null ) {
roleHeading.Text = "No roles";
roleDetail.Text = "";
return;
}
var slots = ArchMapPalette.SlotsDrivenBy( bindings, selected );
roleHeading.Text = selected.Role;
roleDetail.Text = slots.Count > 0
? $"Drives {string.Join( ", ", slots )} - currently {selected.Name} ({selected.Kind}, {selected.Category})"
: $"Not auto-assigned - currently {selected.Name} ({selected.Kind}, {selected.Category})";
}
void ShowLibrary() {
var matches = library.Where( Matches ).ToList();
var section = new ArchTileSection {
Title = $"MATERIAL LIBRARY - {matches.Count} of {library.Count}",
Note = selected is null
? "Select a role on the left first."
: $"Click one to reassign '{selected.Role}'."
};
foreach ( var entry in matches ) {
var captured = entry;
section.Tiles.Add( parent => {
var trim = captured.Kind == "trim";
return new ArchTile( parent, ArchThumb.For( captured.Category, captured.Name, captured.Id ),
Shorten( captured.Name ), trim ? $"{captured.Category} - trim" : captured.Category, trim, grid.TileSize ) {
ToolTip = $"{captured.Category}/{captured.Name}",
Current = () => selected is not null && selected.Name == captured.Name && selected.Category == captured.Category,
Clicked = () => Reassign( captured )
};
} );
}
grid.Set( new[] { section } );
}
bool Matches( Entry entry ) {
if ( !string.IsNullOrEmpty( categoryFilter ) && entry.Category != categoryFilter ) {
return false;
}
if ( string.IsNullOrWhiteSpace( query ) ) {
return true;
}
return entry.Name.Contains( query, StringComparison.OrdinalIgnoreCase )
|| entry.Category.Contains( query, StringComparison.OrdinalIgnoreCase );
}
static string Shorten( string name ) {
if ( name.StartsWith( "map_seamless_", StringComparison.OrdinalIgnoreCase ) ) {
return name.Substring( 13 );
}
if ( name.StartsWith( "map_trim_", StringComparison.OrdinalIgnoreCase ) ) {
return name.Substring( 9 );
}
return name;
}
void Reassign( Entry entry ) {
if ( selected is null ) {
return;
}
var replacement = new ArchMapPalette.Surface {
Role = selected.Role,
Kind = entry.Kind,
Id = entry.Id,
Name = entry.Name,
Category = entry.Category,
Use = selected.Use
};
if ( !ArchMapPalette.Reassign( document.File, selected.Role, replacement ) ) {
return;
}
selected.Kind = replacement.Kind;
selected.Id = replacement.Id;
selected.Name = replacement.Name;
selected.Category = replacement.Category;
ArchSlotPreview.Invalidate();
ShowRoles();
ShowSelected();
ShowLibrary();
changed?.Invoke();
}
}