Editor/UI/ArchPaletteBrowser.cs

Editor UI dialog that browses architecture map palettes. It lists available palette documents and a blockout option, shows previews and details, allows editing materials, rebuilding thumbnails, and applying a palette to an ArchPalette target.

File AccessNetworking
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 ArchPaletteBrowser : Dialog
{
	readonly ArchPalette target;
	readonly Action applied;
	readonly List<ArchMapPalette.Document> documents = new();

	Layout list;
	Label heading;
	Label blurb;
	ArchTileGrid grid;
	Button apply;
	Button edit;

	ArchMapPalette.Document chosen;
	bool blockout;

	public ArchPaletteBrowser( Widget parent, ArchPalette target, Action applied ) : base( parent )
	{
		this.target = target;
		this.applied = applied;

		Window.Title = "Map Palettes";
		Window.SetWindowIcon( "palette" );
		Window.Size = Proportional( parent, 0.6f );

		foreach ( var file in ArchMapPalette.Available() )
		{
			var document = ArchMapPalette.ReadDocument( file );

			if ( document is not null )
			{
				documents.Add( document );
			}
		}

		Build();

		chosen = documents.FirstOrDefault();
		blockout = chosen is null;
		ShowChosen();
	}

	static Vector2 Proportional( Widget parent, float fraction )
	{
		var screen = parent?.ScreenGeometry ?? new Rect( 0, 0, 1600, 900 );
		var width = Math.Max( 900f, screen.Width * fraction );
		var height = Math.Max( 600f, screen.Height * fraction );

		return new Vector2( width, height );
	}

	void Build()
	{
		Layout = Layout.Column();
		Layout.Margin = 0;

		var split = Layout.AddRow( 1 );

		var sidebar = new Widget( this ) { Layout = Layout.Column() };
		sidebar.MinimumWidth = 250;
		sidebar.MaximumWidth = 250;
		sidebar.Layout.Margin = 8;
		sidebar.Layout.Spacing = 6;

		var sidebarScroll = new ScrollArea( sidebar );
		sidebarScroll.HorizontalScrollbarMode = ScrollbarMode.Off;

		var sidebarCanvas = new Widget( sidebarScroll ) { Layout = Layout.Column() };
		sidebarCanvas.Layout.Spacing = 4;
		sidebarScroll.Canvas = sidebarCanvas;

		list = sidebarCanvas.Layout;
		sidebar.Layout.Add( sidebarScroll, 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;

		heading = new Label( "" );
		heading.SetStyles( "font-size: 15px; font-weight: 700;" );
		head.Add( heading );

		blurb = new Label( "" );
		blurb.SetStyles( "color: rgba(255,255,255,0.55); font-size: 11px;" );
		blurb.WordWrap = true;
		head.Add( blurb );

		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;

		edit = new Button( "Edit Materials", "tune" ) { Clicked = EditChosen };
		footer.Add( edit );

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

		footer.AddStretchCell();

		footer.Add( new Button( "Close", "close" ) { Clicked = Close } );

		apply = new Button.Primary( "Apply Palette", "check" ) { Clicked = ApplyChosen };
		footer.Add( apply );

		split.Add( detail, 1 );

		RebuildList();
	}

	void RebuildList()
	{
		list.Clear( true );

		// The blockout skin sits with the scan palettes: picked the same way, materials from a colour table not harvests.
		list.Add( new ArchTile( null, ArchBlockout.Grid(), "blockout", $"{ArchBlockout.Roles.Count} roles, one grid", false, new Vector2( 230, 150 ) )
		{
			Clicked = () => { blockout = true; ShowChosen(); RefreshSelection(); },
			Current = () => blockout
		} );

		foreach ( var document in documents )
		{
			var captured = document;

			var card = new ArchTile( null, Mosaic( captured ), captured.Map, $"{captured.Surfaces.Count} surfaces", false, new Vector2( 230, 150 ) )
			{
				Clicked = () => { chosen = captured; blockout = false; ShowChosen(); RefreshSelection(); },
				Current = () => !blockout && ReferenceEquals( chosen, captured )
			};

			list.Add( card );
		}

		list.AddStretchCell();
	}

	void RefreshSelection()
	{
		Update();
	}

	static Pixmap Mosaic( ArchMapPalette.Document document )
	{
		foreach ( var surface in document.Surfaces )
		{
			var thumb = ArchThumb.For( surface.Category, surface.Name, surface.Id );

			if ( thumb is not null )
			{
				return thumb;
			}
		}

		return null;
	}

	void ShowChosen()
	{
		if ( blockout )
		{
			ShowBlockout();
			return;
		}

		edit.Enabled = true;

		if ( chosen is null )
		{
			heading.Text = "No palettes found";
			blurb.Text = "Nothing in tools/megascans/palettes.";
			grid.Set( Enumerable.Empty<ArchTileSection>() );
			return;
		}

		heading.Text = string.IsNullOrWhiteSpace( chosen.Title ) ? chosen.Map : chosen.Title;

		var bindings = ArchMapPalette.Resolve( chosen );
		var bound = new HashSet<ArchMapPalette.Surface>( bindings.Values );
		var ready = bindings.Values.Distinct().Count( ArchMapPalette.IsImported );

		blurb.Text = $"The builder skins {bindings.Count} surface slots from {bound.Count} of this palette's {chosen.Surfaces.Count} materials"
			+ $" - {ready} imported and ready. Everything else is here for hand-painting faces in the Mapping tool.";

		var builder = new ArchTileSection
		{
			Title = $"WHAT THE BUILDER USES - {bindings.Count} slots",
			Note = "Rendered in place on a test building, with the affected mesh outlined in white. The badge is the surface slot it drives."
		};

		foreach ( var slot in ArchMapPalette.SlotOrder )
		{
			if ( !bindings.TryGetValue( slot, out var surface ) )
			{
				continue;
			}

			var captured = surface;
			var captureSlot = slot;

			builder.Tiles.Add( parent => SlotTile( parent, captured, captureSlot, bindings ) );
		}

		var library = new ArchTileSection
		{
			Title = $"ALSO IN THE PALETTE - {chosen.Surfaces.Count - bound.Count}",
			Note = "Not auto-assigned. Pick these per-face in the Mapping tool at 0.3076 units/texel."
		};

		foreach ( var surface in chosen.Surfaces.Where( s => !bound.Contains( s ) ) )
		{
			var captured = surface;
			library.Tiles.Add( parent => Tile( parent, captured, null, 0.6f ) );
		}

		grid.Set( new[] { builder, library } );
	}

	void ShowBlockout()
	{
		heading.Text = "Blockout grid";
		blurb.Text = ArchBlockout.Describe();

		// Materials come from the colour table in code; nothing to reassign by hand.
		edit.Enabled = false;

		var section = new ArchTileSection
		{
			Title = $"WHAT THE BUILDER USES - {ArchBlockout.Roles.Count} slots",
			Note = "One material per role, the same grid under all of them. Change a hue in ArchBlockout.Roles and delete its vmat to re-generate."
		};

		foreach ( var pair in ArchBlockout.Roles )
		{
			var role = pair.Role;

			section.Tiles.Add( parent => new ArchTile( parent, ArchBlockout.Grid(), $"#{pair.Hex}", ArchBlockout.PathFor( role ), false, grid.TileSize )
			{
				Fill = ArchBlockout.ColourOf( role ),
				Badge = role.ToString(),
				Glyph = box => ArchSlotIcon.Draw( box, role, Color.White )
			} );
		}

		grid.Set( new[] { section } );
	}

	ArchTile Tile( Widget parent, ArchMapPalette.Surface surface, string badge, float dim )
	{
		var thumb = ArchThumb.For( surface.Category, surface.Name, surface.Id );
		var missing = !ArchMapPalette.IsImported( surface );

		return new ArchTile( parent, thumb, surface.Role, missing ? "NOT IMPORTED" : surface.Category, missing, grid.TileSize )
		{
			Badge = badge,
			Dim = dim,
			ToolTip = surface.Name
		};
	}

	ArchTile SlotTile( Widget parent, ArchMapPalette.Surface surface, ArchSurface slot, Dictionary<ArchSurface, ArchMapPalette.Surface> bindings )
	{
		var missing = !ArchMapPalette.IsImported( surface );

		var thumb = missing
			? ArchThumb.For( surface.Category, surface.Name, surface.Id )
			: ArchSlotPreview.For( chosen.Map, slot, bindings, 512 ) ?? ArchThumb.For( surface.Category, surface.Name, surface.Id );

		return new ArchTile( parent, thumb, surface.Role, missing ? "NOT IMPORTED" : surface.Category, missing, grid.TileSize )
		{
			Badge = slot.ToString(),
			Glyph = box => ArchSlotIcon.Draw( box, slot, Color.White ),
			ToolTip = surface.Name
		};
	}

	void ApplyChosen()
	{
		if ( blockout )
		{
			if ( ArchBlockout.Apply( target ) > 0 )
			{
				applied?.Invoke();
			}

			Close();
			return;
		}

		if ( chosen is null )
		{
			return;
		}

		if ( ArchMapPalette.Apply( chosen.File, target ) > 0 )
		{
			applied?.Invoke();
		}

		Close();
	}

	void EditChosen()
	{
		if ( blockout || chosen is null )
		{
			return;
		}

		var browser = new ArchMaterialBrowser( this, chosen, () =>
		{
			var reloaded = ArchMapPalette.ReadDocument( chosen.File );

			if ( reloaded is null )
			{
				return;
			}

			var index = documents.FindIndex( d => d.File == chosen.File );

			if ( index >= 0 )
			{
				documents[index] = reloaded;
			}

			chosen = reloaded;
			ArchThumb.Forget();
			ArchSlotPreview.Invalidate();
			RebuildList();
			ShowChosen();
		} );

		browser.Show();
	}
}