Editor/Data/ArchMapPalette.cs

Editor utility for architecture map palettes. It reads JSON palette documents from a tools/megascans/palettes folder, parses surface entries, resolves them to ArchSurface slots, applies palettes to an ArchPalette target, and can rewrite individual surface entries in-place.

File Access
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

public static class ArchMapPalette
{
	const string PaletteFolder = "tools/megascans/palettes";

	public const string SharedRoot = "environment/seamless";

	public sealed class Surface
	{
		public string Role { get; set; }
		public string Kind { get; set; }
		public string Id { get; set; }
		public string Name { get; set; }
		public string Category { get; set; }
		public string Use { get; set; }
		public string Root { get; set; } = SharedRoot;
		public float TexelScale { get; set; }

		public string MaterialPath => $"{Root}/{Category}/{Name}/{Name}.vmat";
	}

	public sealed class Document
	{
		public string File { get; set; }
		public string Map { get; set; }
		public string Title { get; set; }
		public string Mood { get; set; }
		public string Root { get; set; } = SharedRoot;
		public List<Surface> Surfaces { get; } = new();
	}

	public static IEnumerable<string> Available()
	{
		var folder = Path.Combine( ProjectRoot(), PaletteFolder );

		if ( !Directory.Exists( folder ) )
		{
			return Enumerable.Empty<string>();
		}

		return Directory.GetFiles( folder, "*.json" ).OrderBy( path => path );
	}

	public static bool IsImported( Surface surface )
	{
		return surface is not null && AssetSystem.FindByPath( surface.MaterialPath ) is not null;
	}

	// Opens even with nothing staged - the blockout grid is always in the list.
	public static void ShowPicker( Widget parent, ArchPalette target, Action applied )
	{
		if ( !Available().Any() )
		{
			Log.Info( $"Architecture: no map palettes staged in {PaletteFolder} - the blockout grid is the only skin on offer." );
		}

		new ArchPaletteBrowser( parent, target, applied ).Show();
	}

	public static int Apply( string file, ArchPalette target )
	{
		var document = ReadDocument( file );

		if ( document is null || document.Surfaces.Count == 0 )
		{
			Log.Warning( $"Architecture: {Path.GetFileName( file )} declared no usable surfaces." );
			return 0;
		}

		var assigned = 0;

		foreach ( var pair in Resolve( document ) )
		{
			if ( AssetSystem.FindByPath( pair.Value.MaterialPath ) is null )
			{
				Log.Warning( $"Architecture: {pair.Value.Role} points at {pair.Value.MaterialPath}, which is not imported yet." );
				continue;
			}

			target.Set( pair.Key, pair.Value.MaterialPath, pair.Value.TexelScale );
			assigned++;
		}

		Log.Info( $"Architecture: applied {assigned} roles from {document.Map}." );

		return assigned;
	}

	public static readonly ArchSurface[] SlotOrder =
	{
		ArchSurface.WallExterior,
		ArchSurface.Siding,
		ArchSurface.WallInterior,
		ArchSurface.Wainscot,
		ArchSurface.Reveal,
		ArchSurface.Pillar,
		ArchSurface.Foundation,
		ArchSurface.Floor,
		ArchSurface.Deck,
		ArchSurface.Ceiling,
		ArchSurface.StairTread,
		ArchSurface.StairRiser,
		ArchSurface.Roof,
		ArchSurface.RoofEdge,
		ArchSurface.Soffit,
		ArchSurface.Gutter,
		ArchSurface.Trim,
		ArchSurface.WindowFrame,
		ArchSurface.WindowSash,
		ArchSurface.Railing,
		ArchSurface.Baseboard,
		ArchSurface.Road,
		ArchSurface.Pavement,
		ArchSurface.Kerb,
		ArchSurface.RoadLine,
		ArchSurface.Panel,
		ArchSurface.Post,
		ArchSurface.Wire,
		ArchSurface.Grille,
		ArchSurface.Shutter,
		ArchSurface.Sign,
		ArchSurface.Plant,
		ArchSurface.Pipework,
		ArchSurface.Cabling,
		ArchSurface.Bracket,
		ArchSurface.Bridge,
		ArchSurface.Pier,
		ArchSurface.Lining,
		ArchSurface.Portal
	};

	public static Dictionary<ArchSurface, Surface> Resolve( Document document )
	{
		var result = new Dictionary<ArchSurface, Surface>();

		if ( document is null )
		{
			return result;
		}

		var surfaces = document.Surfaces;

		var walls = Prefixed( surfaces, "wall_" );
		var grounds = Prefixed( surfaces, "ground_", "floor_" );
		var roofs = Prefixed( surfaces, "roof_" );
		var trims = Prefixed( surfaces, "trim_" );
		var metals = Prefixed( surfaces, "metal_" );
		var sidings = Prefixed( surfaces, "siding_" );
		var joinery = Prefixed( surfaces, "joinery_" );
		var decks = Prefixed( surfaces, "deck_" );
		var wainscots = Prefixed( surfaces, "wainscot_" );
		var glass = Prefixed( surfaces, "glass_" );

		Bind( result, ArchSurface.WallExterior, walls.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.WallInterior, walls.ElementAtOrDefault( 1 ) ?? walls.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Reveal, walls.ElementAtOrDefault( 1 ) ?? walls.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Floor, grounds.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Ceiling, grounds.ElementAtOrDefault( 1 ) ?? grounds.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Roof, roofs.ElementAtOrDefault( 0 ) ?? metals.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Trim, trims.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.RoofEdge, trims.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Soffit, trims.ElementAtOrDefault( 1 ) ?? trims.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Gutter, metals.ElementAtOrDefault( 0 ) ?? trims.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Baseboard, trims.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Pillar, walls.FirstOrDefault( entry => entry.Category == "concrete" ) ?? walls.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Foundation, walls.FirstOrDefault( entry => entry.Category == "concrete" ) ?? walls.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.StairTread, grounds.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.StairRiser, grounds.ElementAtOrDefault( 1 ) ?? grounds.ElementAtOrDefault( 0 ) );

		// Only bind when declared - the fallback chain skins the building without them.
		Bind( result, ArchSurface.Siding, sidings.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.WindowFrame, joinery.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.WindowSash, joinery.ElementAtOrDefault( 1 ) ?? joinery.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Railing, joinery.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Deck, decks.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Wainscot, wainscots.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Glass, glass.ElementAtOrDefault( 0 ) );

		// Picked by role, not ordinal - ground[0] as road would hand a street the lawn.
		var concrete = walls.FirstOrDefault( entry => entry.Category == "concrete" );

		Bind( result, ArchSurface.Road, Named( grounds, "asphalt", "tarmac", "road" ) );
		Bind( result, ArchSurface.Pavement, Named( grounds, "paver", "pavement", "sidewalk" ) ?? grounds.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Kerb, Named( trims, "apron", "kerb", "curb" ) ?? concrete );
		Bind( result, ArchSurface.Panel, concrete ?? walls.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Post, concrete ?? walls.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Wire, metals.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Grille, metals.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Shutter, metals.ElementAtOrDefault( 1 ) ?? metals.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Plant, metals.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Pipework, metals.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Cabling, metals.ElementAtOrDefault( 1 ) ?? metals.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Bracket, metals.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Bridge, concrete ?? walls.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Pier, concrete ?? walls.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Lining, concrete ?? walls.ElementAtOrDefault( 0 ) );
		Bind( result, ArchSurface.Portal, Named( walls, "stone", "rubble", "masonry" ) ?? concrete ?? walls.ElementAtOrDefault( 0 ) );

		return result;
	}

	public static List<ArchSurface> SlotsDrivenBy( Dictionary<ArchSurface, Surface> bindings, Surface surface )
	{
		return SlotOrder
			.Where( slot => bindings.TryGetValue( slot, out var bound ) && ReferenceEquals( bound, surface ) )
			.ToList();
	}

	static void Bind( Dictionary<ArchSurface, Surface> result, ArchSurface slot, Surface entry )
	{
		if ( entry is not null )
		{
			result[slot] = entry;
		}
	}

	public static bool Reassign( string file, string role, Surface replacement )
	{
		try
		{
			var node = JsonNode.Parse( System.IO.File.ReadAllText( file ) );
			var surfaces = node?["surfaces"]?.AsArray();

			if ( surfaces is null )
			{
				return false;
			}

			foreach ( var element in surfaces )
			{
				if ( element is null || (string)element["role"] != role )
				{
					continue;
				}

				element["kind"] = replacement.Kind;
				element["id"] = replacement.Id;
				element["name"] = replacement.Name;
				element["category"] = replacement.Category;

				// Without the relaxed encoder every em-dash in the prose comes back as —.
				var options = new JsonSerializerOptions
				{
					WriteIndented = true,
					Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
				};

				System.IO.File.WriteAllText( file, node.ToJsonString( options ) );
				return true;
			}

			return false;
		}
		catch ( Exception exception )
		{
			Log.Warning( $"Architecture: could not rewrite {Path.GetFileName( file )}: {exception.Message}" );
			return false;
		}
	}

	static Surface Named( List<Surface> surfaces, params string[] words )
	{
		return surfaces.FirstOrDefault( entry => words.Any( word => entry.Role.Contains( word, StringComparison.OrdinalIgnoreCase ) ) );
	}

	static List<Surface> Prefixed( List<Surface> surfaces, params string[] prefixes )
	{
		return surfaces
			.Where( entry => prefixes.Any( prefix => entry.Role.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) ) )
			.ToList();
	}

	public static Document ReadDocument( string file )
	{
		try
		{
			using var json = JsonDocument.Parse( System.IO.File.ReadAllText( file ) );
			var root = json.RootElement;

			var document = new Document
			{
				File = file,
				Map = Text( root, "map" ) ?? Path.GetFileNameWithoutExtension( file ),
				Title = Text( root, "title" ),
				Mood = Text( root, "mood" ),
				Root = Text( root, "root" ) ?? SharedRoot
			};

			if ( !root.TryGetProperty( "surfaces", out var surfaces ) )
			{
				return document;
			}

			foreach ( var element in surfaces.EnumerateArray() )
			{
				var role = Text( element, "role" );
				var name = Text( element, "name" );
				var category = Text( element, "category" );

				if ( string.IsNullOrWhiteSpace( role ) || string.IsNullOrWhiteSpace( name ) || string.IsNullOrWhiteSpace( category ) )
				{
					continue;
				}

				document.Surfaces.Add( new Surface
				{
					Role = role,
					Name = name,
					Category = category,
					Kind = Text( element, "kind" ) ?? "tile",
					Id = Text( element, "id" ),
					Use = Text( element, "use" ),
					Root = document.Root,
					TexelScale = Number( element, "texelScale" )
				} );
			}

			return document;
		}
		catch ( Exception exception )
		{
			Log.Warning( $"Architecture: could not read {file}: {exception.Message}" );
			return null;
		}
	}

	static float Number( JsonElement element, string property )
	{
		return element.TryGetProperty( property, out var value ) && value.ValueKind == JsonValueKind.Number ? value.GetSingle() : 0f;
	}

	static string Text( JsonElement element, string property )
	{
		return element.TryGetProperty( property, out var value ) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
	}

	static string ProjectRoot()
	{
		return Project.Current?.GetRootPath() ?? System.Environment.CurrentDirectory;
	}
}