Editor/Wall/ArchOpeningCatalog.cs

Editor-side catalog for architectural openings. Provides reading, saving and shipped presets for opening presets, and utilities for reading catalog files and creating filesystem-safe slugs.

File Access
using System.Collections.Generic;
using System.Linq;

namespace Sunless.Architecture;

public sealed class ArchOpeningCatalog : IArchCatalog
{
	public const string Directory = "arch/openings";

	public ArchShelf Shelf => ArchShelf.Openings;

	public string Named( object entry ) => entry is ArchOpeningPreset preset ? preset.Name : null;

	public IEnumerable<object> Authored()
	{
		return ArchCatalogFiles.Read<ArchOpeningPreset>( Directory, ".opening.json", preset => preset.Name );
	}

	public bool Save( object entry )
	{
		return entry is ArchOpeningPreset preset
			&& !string.IsNullOrWhiteSpace( preset.Name )
			&& ArchStorage.WriteDocument( PathFor( preset.Name ), preset );
	}

	public static string PathFor( string name ) => $"{Directory}/{ArchCatalogFiles.Slug( name, "opening" )}.opening.json";

	public IEnumerable<object> Shipped() => Presets();

	public static List<ArchOpeningPreset> Presets()
	{
		return new List<ArchOpeningPreset>
		{
			new() { Name = "doorway", Kind = OpeningKind.Archway, Width = 48f, Height = 96f, Leaf = false },
			new() { Name = "door", Kind = OpeningKind.Door, Width = 48f, Height = 96f },
			new() { Name = "door_double", Kind = OpeningKind.DoubleDoor, Width = 88f, Height = 100f },
			new() { Name = "garage_single", Kind = OpeningKind.Garage, Width = 128f, Height = 112f, CasingWidth = 6f },
			new() { Name = "garage_double", Kind = OpeningKind.Garage, Width = 216f, Height = 112f, CasingWidth = 6f },
			// Kit presets so they show in the Opening grid and a recipe can name one.
			new() { Name = "roller_shutter", Kind = OpeningKind.Garage, Width = 168f, Height = 168f, CasingWidth = 8f },
			new() { Name = "loading_dock", Kind = OpeningKind.Garage, Width = 240f, Height = 192f, CasingWidth = 8f },
			new() { Name = "loading_dock_open", Kind = OpeningKind.Garage, Width = 240f, Height = 192f, CasingWidth = 8f, StartOpen = true },
			new() { Name = "hangar_door", Kind = OpeningKind.Garage, Width = 480f, Height = 264f, CasingWidth = 10f },
			new() { Name = "hangar_door_open", Kind = OpeningKind.Garage, Width = 480f, Height = 264f, CasingWidth = 10f, StartOpen = true },
			new() { Name = "bay_mouth", Kind = OpeningKind.Archway, Width = 240f, Height = 192f, Leaf = false, Cased = false },
			new() { Name = "window", Kind = OpeningKind.Window, Width = 48f, Height = 48f, SillHeight = 40f, HasSill = true },
			new() { Name = "window_wide", Kind = OpeningKind.Window, Width = 112f, Height = 48f, SillHeight = 40f, HasSill = true },
			new() { Name = "window_tall", Kind = OpeningKind.Window, Width = 48f, Height = 80f, SillHeight = 24f, HasSill = true },
			new() { Name = "window_strip", Kind = OpeningKind.Window, Width = 192f, Height = 24f, SillHeight = 88f, Cased = false },
			// Named like a window: 6-over-6 is three across by two down, twice.
			new()
			{
				Name = "sash_1over1", Kind = OpeningKind.Window, Width = 36f, Height = 60f, SillHeight = 36f,
				HasSill = true, Glazed = true, Sashes = 2, PanesAcross = 1, PanesDown = 1
			},
			new()
			{
				Name = "sash_2over2", Kind = OpeningKind.Window, Width = 40f, Height = 66f, SillHeight = 34f,
				HasSill = true, Glazed = true, Sashes = 2, PanesAcross = 2, PanesDown = 1
			},
			new()
			{
				Name = "sash_4over4", Kind = OpeningKind.Window, Width = 40f, Height = 66f, SillHeight = 34f,
				HasSill = true, Glazed = true, Sashes = 2, PanesAcross = 2, PanesDown = 2
			},
			new()
			{
				Name = "sash_6over6", Kind = OpeningKind.Window, Width = 44f, Height = 84f, SillHeight = 26f,
				HasSill = true, Glazed = true, Sashes = 2, PanesAcross = 3, PanesDown = 2
			},
			new()
			{
				Name = "sash_small", Kind = OpeningKind.Window, Width = 30f, Height = 30f, SillHeight = 52f,
				HasSill = true, Glazed = true, Sashes = 1, PanesAcross = 2, PanesDown = 2
			},
			new()
			{
				Name = "sash_twin", Kind = OpeningKind.Window, Width = 76f, Height = 66f, SillHeight = 34f,
				HasSill = true, Glazed = true, Lights = 2, Sashes = 2, PanesAcross = 2, PanesDown = 2
			},
			new()
			{
				Name = "sash_twin_tall", Kind = OpeningKind.Window, Width = 84f, Height = 84f, SillHeight = 26f,
				HasSill = true, Glazed = true, Lights = 2, Sashes = 2, PanesAcross = 3, PanesDown = 2
			},
			new() { Name = "hatch", Kind = OpeningKind.Hatch, Width = 32f, Height = 32f, SillHeight = 56f }
		};
	}
}

// Both wall catalogs read a folder of named documents the same way, and the slug is what keeps a preset called
// "Sash 6/6" from being written to a path no filesystem will take.
public static class ArchCatalogFiles
{
	public static List<T> Read<T>( string directory, string suffix, System.Func<T, string> named ) where T : class
	{
		var loaded = new List<T>();

		foreach ( var file in ArchStorage.DocumentFiles( directory, suffix ) )
		{
			if ( ArchStorage.ReadDocument<T>( $"{directory}/{file}" ) is { } entry && !string.IsNullOrWhiteSpace( named( entry ) ) )
			{
				loaded.Add( entry );
			}
		}

		return loaded.OrderBy( named ).ToList();
	}

	public static string Slug( string name, string fallback )
	{
		var slug = new string( (name ?? "")
			.Trim()
			.ToLowerInvariant()
			.Select( character => char.IsLetterOrDigit( character ) ? character : '_' )
			.ToArray() );

		while ( slug.Contains( "__" ) )
		{
			slug = slug.Replace( "__", "_" );
		}

		slug = slug.Trim( '_' );

		return string.IsNullOrWhiteSpace( slug ) ? fallback : slug;
	}
}