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.
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;
}
}