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