Editor/Stair/ArchStairAffector.cs

Editor-side utility that updates stair 'flights' (ArchStairPart) in an architectural plan. It recalculates pierced shafts and archways for flights that open into other buildings, regroups layer memberships for affected buildings and flights, and removes a flight cleaning up its cutouts and openings.

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

namespace Sunless.Architecture;

// A flight that walks out of the building it was filed in is an affector on the one it arrives in, so
// it obeys the same rules a link does: its shaft and its archways are re-cut from where it stands now
// on every commit, and it is grouped with the buildings it actually opened. The group is then the scope
// ArchStairShape.Pierce reads back, so a flight can no longer reach across the whole plan by accident.
public static class ArchStairAffector
{
	public static int Resolve( ArchPlan plan, ArchKit kit )
	{
		var recut = 0;
		var kinds = ArchKinds.Load();

		// Only flights that pierced something: one drawn on grade opens no slab and has no shaft to keep
		// in step, and standing one for it would put a hole under every ground-floor stair in the plan.
		var pierced = plan.Buildings
			.SelectMany( building => building.Cutouts )
			.Where( cutout => cutout.OwnerId != 0 )
			.Select( cutout => cutout.OwnerId )
			.ToHashSet();

		foreach ( var building in plan.Buildings.ToList() )
		{
			foreach ( var room in building.Rooms.ToList() )
			{
				foreach ( var stair in plan.Filed<ArchStairPart>( room, kinds ).Where( stair => pierced.Contains( stair.Id ) ).ToList() )
				{
					ArchStairShape.Pierce( plan, building, room, stair, kit );
					Regroup( plan, building, stair, kinds );

					recut++;
				}
			}
		}

		return recut;
	}

	// The buildings the flight actually opened, gathered with it and with the one it was filed in. A
	// flight that stays inside its own building is no relationship worth a folder, so it forms none -
	// and one that stops reaching out leaves the group, the way a link that reaches nothing does.
	static void Regroup( ArchPlan plan, ArchBuilding home, ArchStairPart stair, ArchKinds kinds )
	{
		var reached = plan.Buildings
			.Where( host => host != home && Opened( plan, host, stair, kinds ) )
			.ToList();

		if ( reached.Count == 0 )
		{
			ArchLayerGroups.Leave( plan, stair.Id );

			return;
		}

		var join = ArchLayerGroups.Holding( plan, stair.Id )
			?? ArchLayerGroups.Create( plan, ArchLayerGroups.Named( plan ), ArchAssemblyKind.SharedCore, System.Array.Empty<int>() );

		// The affector is filed last, because it acts on what sits above it in the stack.
		var members = reached.Select( host => host.Id ).Prepend( home.Id ).Append( stair.Id ).ToList();

		foreach ( var member in members )
		{
			ArchLayerGroups.Leave( plan, member );
		}

		join.Children.Clear();
		join.Children.AddRange( members );
	}

	static bool Opened( ArchPlan plan, ArchBuilding host, ArchStairPart stair, ArchKinds kinds )
	{
		return host.Cutouts.Any( cutout => cutout.OwnerId == stair.Id )
			|| host.Rooms
				.SelectMany( room => plan.Filed( ArchKind.Wall, room, kinds ) )
				.SelectMany( wall => plan.Filed( ArchKind.Opening, wall, kinds ) )
				.Any( opening => opening is ArchOpening archway && archway.OwnerId == stair.Id );
	}

	// Deleting the flight takes its shaft, its archways and its own membership - never the group.
	public static void Remove( ArchPlan plan, ArchStairPart stair )
	{
		var kinds = ArchKinds.Load();

		foreach ( var building in plan.Buildings )
		{
			building.Cutouts.RemoveAll( cutout => cutout.OwnerId == stair.Id );
		}

		foreach ( var wall in plan.AllRooms().SelectMany( room => plan.Filed( ArchKind.Wall, room, kinds ) ) )
		{
			plan.RemoveAll( ArchKind.Opening, wall, opening => opening is ArchOpening archway && archway.OwnerId == stair.Id, kinds );
		}

		ArchLayerGroups.Leave( plan, stair.Id );
	}
}