Editor/Layers/ArchLayerGroups.cs

Utility class managing groups of architectural layers (ArchSiteAssembly) in an editor plan. It finds/creates/dissolves groups, manages membership (Join/Leave), flattens nested groups, computes scope/reach for affectors, and maintains links between assemblies.

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

namespace Sunless.Architecture;

// A group is an authored assembly, and an assembly is a SCOPE: an operation inside one may only
// reach the layers that share it. A walkway grouped with the two houses it joins keeps its mouths
// open through their walls; dragged out of the group it reaches nothing and they close.
public static class ArchLayerGroups
{
	public static ArchSiteAssembly Holding( ArchPlan plan, int itemId )
	{
		if ( itemId == 0 )
		{
			return null;
		}

		return ArchBuildMemo.Held( memo => memo.Holders, itemId,
			() => plan?.Assemblies.FirstOrDefault( group => group.Children.Contains( itemId ) ) );
	}

	public static ArchSiteAssembly Find( ArchPlan plan, int groupId )
	{
		return plan?.Assemblies.FirstOrDefault( group => group.Id == groupId );
	}

	// One namer, so a group a link formed and a group a flight formed read as the same kind of thing.
	public static string Named( ArchPlan plan ) => $"Housing Group {plan.Assemblies.Count + 1}";

	public static ArchSiteAssembly Create( ArchPlan plan, string name, ArchAssemblyKind kind, IEnumerable<int> members )
	{
		var group = new ArchSiteAssembly { Id = plan.AllocateId(), Name = name, Kind = kind };

		foreach ( var member in members.Where( id => id != 0 ).Distinct() )
		{
			Leave( plan, member );
			group.Children.Add( member );
		}

		plan.Assemblies.Add( group );

		return group;
	}

	// One membership at a time - a layer joining a group leaves the one it was in, or the tree would
	// show the same authored thing standing in two scopes.
	public static void Join( ArchPlan plan, ArchSiteAssembly group, int itemId )
	{
		if ( group is null || itemId == 0 || group.Id == itemId )
		{
			return;
		}

		Leave( plan, itemId );
		group.Children.Add( itemId );
	}

	public static void Leave( ArchPlan plan, int itemId )
	{
		foreach ( var group in plan?.Assemblies ?? Enumerable.Empty<ArchSiteAssembly>() )
		{
			group.Children.Remove( itemId );
		}
	}

	// The group goes, its members stay - they fall back to the typed ownership they never left.
	public static void Dissolve( ArchPlan plan, ArchSiteAssembly group )
	{
		if ( group is null )
		{
			return;
		}

		plan.Assemblies.Remove( group );
		plan.Links.RemoveAll( link => link.SourceId == group.Id || link.TargetId == group.Id );
		plan.Layers.RemoveAll( record => record.ItemId == group.Id );
	}

	// Every id inside a group, folders included: a group holding a group of houses reaches those
	// houses, or nesting what an affector affects would quietly put it out of its own scope.
	public static IReadOnlySet<int> Flatten( ArchPlan plan, ArchSiteAssembly group )
	{
		var members = new HashSet<int>();

		Gather( plan, group, members );

		return members;
	}

	static void Gather( ArchPlan plan, ArchSiteAssembly group, HashSet<int> into )
	{
		if ( group is null || !into.Add( group.Id ) )
		{
			return;
		}

		foreach ( var child in group.Children )
		{
			if ( Find( plan, child ) is { } nested )
			{
				Gather( plan, nested, into );

				continue;
			}

			into.Add( child );
		}
	}

	// Everything the layer is allowed to affect: its group's members and everything those members
	// own. An ungrouped layer answers null, meaning the whole plan - the way it worked before groups.
	public static IReadOnlySet<int> Scope( ArchPlan plan, ArchLayerTree tree, int itemId )
	{
		if ( Holding( plan, itemId ) is not { } group )
		{
			return null;
		}

		var scope = new HashSet<int>( Flatten( plan, group ) );

		foreach ( var member in scope.ToList() )
		{
			if ( tree?.Find( member ) is { Ref: { } layer } )
			{
				foreach ( var descendant in tree.DirtyClosure( layer.ItemId ) )
				{
					scope.Add( descendant );
				}
			}
		}

		foreach ( var link in plan.Links.Where( link => link.SourceId == group.Id ) )
		{
			scope.Add( link.TargetId );
		}

		return scope;
	}

	// Buildings the layer may cut into. Candidates are tried in order, so an affector's own membership
	// decides before the host it was filed in - which is what makes a shaft drawn inside a grouped
	// house open that group's slabs and nobody else's. Ungrouped answers null: every building, as before.
	public static IReadOnlySet<int> Reach( ArchPlan plan, params int[] candidates )
	{
		// The memo keys on the affector and its host, which is every caller; a longer candidate list would
		// collide on that key, so it resolves plainly instead of holding a wrong answer.
		if ( candidates.Length > 2 )
		{
			return Reached( plan, candidates );
		}

		var affector = candidates.Length > 0 ? candidates[0] : 0;
		var host = candidates.Length > 1 ? candidates[1] : 0;

		return ArchBuildMemo.Held( memo => memo.Reaches, (affector, host), () => Reached( plan, candidates ) );
	}

	static IReadOnlySet<int> Reached( ArchPlan plan, int[] candidates )
	{
		foreach ( var candidate in candidates )
		{
			if ( Holding( plan, candidate ) is not { } group )
			{
				continue;
			}

			var members = Flatten( plan, group );
			var reach = new HashSet<int>();

			foreach ( var building in plan.Buildings )
			{
				if ( members.Contains( building.Id ) || building.Rooms.Any( room => members.Contains( room.Id ) ) )
				{
					reach.Add( building.Id );
				}
			}

			return reach;
		}

		return null;
	}

	public static void Link( ArchPlan plan, int sourceId, string sourcePort, int targetId, string targetPort )
	{
		plan.Links.RemoveAll( link => link.SourceId == sourceId && link.SourcePort == sourcePort );

		if ( targetId == 0 )
		{
			return;
		}

		plan.Links.Add( new ArchLayerLink
		{
			SourceId = sourceId,
			SourcePort = sourcePort,
			TargetId = targetId,
			TargetPort = targetPort,
		} );
	}
}