Editor/Layers/ArchLayerAssets.cs

Editor utility types and asset helpers for architectural layers. Defines enums and data classes for assemblies, assets, instances and references, and provides functions to generate filesystem paths, serialize/save building/network/assembly assets, compute origins, shift road node positions, and create slugs.

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

namespace Sunless.Architecture;

public enum ArchAssemblyKind
{
	Group,
	Walkway,
	Passage,
	SharedCore,
}

public enum ArchAssetKind
{
	Building,
	Network,
	Assembly,
}

// A site-level relationship: a walkway between two buildings, a shared core, any group that must
// be authored as one thing without belonging to one owner. Children reference layers by id - the
// referenced layers keep their ownership homes, the assembly is the explicit relationship.
public sealed class ArchSiteAssembly : IArchCollides, IArchNamed
{
	// A group is a scope for this too: a folder of far-off houses set to None takes every part in them with it, and a
	// member with its own answer still keeps it.
	public ArchCollisionMode? Collision { get; set; }

	public int Id { get; set; }
	public string Name { get; set; } = "Assembly";
	public ArchAssemblyKind Kind { get; set; }
	public List<int> Children { get; set; } = new();
	public ArchPalette Palette { get; set; } = new();
	// How the affector in this group merges with what it reaches. The group is the relationship,
	// so the terms of the join are stated on it once rather than at each end.
	public ArchMergeSpec Merge { get; set; } = new();
}

// The terms of a merge, edited from the connector that owns them.
public sealed class ArchMergeSpec
{
	public bool OpenWalls { get; set; } = true;
	// Zero takes the connector's own clear height.
	public float Clearance { get; set; }
	public bool NearEnd { get; set; } = true;
	public bool FarEnd { get; set; } = true;
}

// A placed copy of a reusable authored asset: the asset is the source, this record is where it
// stands. Make Local replaces it with the expanded subtree.
public sealed class ArchAssetInstance : IArchNamed
{
	public int Id { get; set; }
	public string Name { get; set; } = "Instance";
	public ArchAssetKind Kind { get; set; }
	public string AssetPath { get; set; } = "";
	public Vector2 Position { get; set; }
}

// A cross-layer anchor shown in the tree: the assembly's link to a named port on another layer.
public sealed class ArchLayerReference
{
	public string SourcePort { get; set; } = "";
	public int TargetId { get; set; }
	public string TargetPort { get; set; } = "";
}

// Reusable authored-plan assets: a finished building, road network or assembly saved to JSON and
// placed again with fresh ids. The archetype stays a rule template; this is an exact authored plan.
public static class ArchLayerAssets
{
	public const string BuildingDirectory = "arch/buildings";
	public const string NetworkDirectory = "arch/infrastructure";
	public const string AssemblyDirectory = "arch/assemblies";

	public static string BuildingPathFor( string name ) => $"{BuildingDirectory}/{Slug( name, "building" )}.archbuilding.json";

	public static string NetworkPathFor( string name ) => $"{NetworkDirectory}/{Slug( name, "network" )}.archnetwork.json";

	public static string AssemblyPathFor( string name ) => $"{AssemblyDirectory}/{Slug( name, "assembly" )}.archassembly.json";

	public static string SaveBuilding( ArchBuilding building, string name )
	{
		if ( building is null || string.IsNullOrWhiteSpace( name ) )
		{
			return null;
		}

		var origin = Origin( building );
		var copy = ArchStorage.Deserialize<ArchBuilding>( ArchStorage.Serialize( building ) );
		ArchCarry.Shift( copy, -origin );

		var path = BuildingPathFor( name );

		return ArchStorage.WriteAsset( path, ArchStorage.Serialize( copy ) ) ? path : null;
	}

	public static string SaveNetwork( IReadOnlyList<ArchRoadPart> roads, string name )
	{
		if ( roads is null || roads.Count == 0 || string.IsNullOrWhiteSpace( name ) )
		{
			return null;
		}

		var origin = roads[0].Nodes.Count > 0 ? new Vector2( roads[0].Nodes[0].Position.x, roads[0].Nodes[0].Position.y ) : Vector2.Zero;
		var copy = new List<ArchRoadPart>();

		foreach ( var road in roads )
		{
			var cloned = ArchStorage.Deserialize<ArchRoadPart>( ArchStorage.Serialize( road ) );
			Shift( cloned, -origin );
			copy.Add( cloned );
		}

		var path = NetworkPathFor( name );

		return ArchStorage.WriteAsset( path, ArchStorage.Serialize( copy ) ) ? path : null;
	}

	public static string SaveAssembly( ArchSiteAssembly assembly, string name )
	{
		if ( assembly is null || string.IsNullOrWhiteSpace( name ) )
		{
			return null;
		}

		var path = AssemblyPathFor( name );

		return ArchStorage.WriteAsset( path, ArchStorage.Serialize( assembly ) ) ? path : null;
	}

	static Vector2 Origin( ArchBuilding building )
	{
		return ArchHandles.Bounds( building, out var min, out _ ) ? min : Vector2.Zero;
	}

	static void Shift( ArchRoadPart road, Vector2 shift )
	{
		foreach ( var node in road.Nodes )
		{
			node.Position += new Vector3( shift.x, shift.y, 0f );
		}
	}

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