Editor/Carve/ArchCutAffector.cs

Editor-side utility that computes and applies wall openings created by architectural cuts. It resolves which walls a given cut intersects, creates/openings, marks openings as swallowed by cuts for windows, groups layer membership, and removes or closes cut-generated openings.

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

namespace Sunless.Architecture;

// A cut is a boolean on everything it passes through, and a wall is one of those things. Slabs, ceilings,
// roof decks and platforms already answer it by asking ArchCut what covers them - they are DERIVED and need
// no record. A wall cannot: its holes are authored openings, so the cut has to own them.
//
// So they are an effect, and effects obey the contract: re-derived from where the cut stands NOW on every
// commit, scoped to the group the cut was placed in, and closed plan-wide whatever the scope. Drag the cut
// and the holes follow it; disable it and ArchLayerGate stops them being read without deleting anything.
public static class ArchCutAffector
{
	// The narrowest bite worth taking out of a wall - below this the cut is only clipping the skin's corner.
	const float LeastBite = 4f;

	public static int Resolve( ArchPlan plan, ArchKit kit )
	{
		var opened = 0;

		foreach ( var building in plan.Buildings.ToList() )
		{
			foreach ( var cut in building.Cuts.ToList() )
			{
				if ( Recut( plan, kit, building, cut ) )
				{
					opened++;
				}
			}
		}

		return opened;
	}

	static bool Recut( ArchPlan plan, ArchKit kit, ArchBuilding home, ArchCutPart cut )
	{
		var wanted = Wanted( plan, kit, home, cut );
		var settled = Standing( plan, cut ).SetEquals( wanted.Select( Signature ) );

		// Compared by where the hole SITS, not just which wall carries it - a cut slid along the same wall
		// wants the same wall and a different hole, and an id check would skip the re-cut.
		if ( !settled )
		{
			Close( plan, cut );

			foreach ( var bite in wanted )
			{
				bite.Wall.Openings.Add( new ArchOpening
				{
					Id = plan.AllocateId(),
					OwnerId = cut.Id,
					Preset = "doorway",
					Kind = OpeningKind.Archway,
					Offset = bite.Offset,
					Width = bite.Width,
					SillHeight = bite.Sill,
					Height = bite.Height,
					Cased = cut.Edge,
					HasSill = false,
					CasingWidth = cut.EdgeWidth,
					Leaf = false,
					Glazed = false,
					AutoLayout = false
				} );
			}
		}

		Swallowed( plan, kit, home, cut );
		Regroup( plan, kit, home, cut, wanted );

		return !settled;
	}

	// A unit standing where the hole now is. It is not clipped: half a window is a broken window, and the
	// author who ticked this asked for the wall to be open there. Disabled rather than deleted, so unticking
	// the box or dragging the cut off it gives the same window back - the layer gate is the whole mechanism.
	static void Swallowed( ArchPlan plan, ArchKit kit, ArchBuilding home, ArchCutPart cut )
	{
		var taking = !cut.IsDamage && ArchCut.Affects( cut, ArchCutAffects.Windows ) && cut.HasContent && ArchLayerGate.On( cut );
		var reach = ArchLayerGroups.Reach( plan, cut.Id, home.Id );

		foreach ( var room in plan.AllRooms() )
		{
			if ( reach is not null && plan.OwnerOf( room ) is { } owner && !reach.Contains( owner.Id ) )
			{
				continue;
			}

			foreach ( var wall in room.Walls )
			{
				// Hand-placed, or stood by the wall's own rhythm. Never a hole another part punched - that
				// archway IS the way through, so swallowing it would close what opened the wall.
				foreach ( var opening in wall.Openings.Where( opening => opening.OwnerId == 0 || opening.OwnerId == wall.Id ) )
				{
					var buried = taking && Buried( room, wall, opening, cut );

					if ( buried )
					{
						opening.SwallowedBy = cut.Id;
					}
					else if ( opening.SwallowedBy == cut.Id )
					{
						opening.SwallowedBy = 0;
					}
				}
			}
		}
	}

	// Buried when the hole covers the unit's whole run and its whole height - a cut clipping one jamb leaves
	// the window where it is, because a window is a thing and not a length of wall.
	static bool Buried( ArchRoom room, ArchWall wall, ArchOpening opening, ArchCutPart cut )
	{
		foreach ( var leg in cut.Legs() )
		{
			if ( leg.BaseHeight - room.BaseHeight > opening.SillHeight + 0.5f || leg.TopHeight - room.BaseHeight < opening.Top - 0.5f )
			{
				continue;
			}

			foreach ( var (from, to) in ArchFootprint.Inside( leg.Outline(), wall.Start, wall.End ) )
			{
				if ( from * wall.Length <= opening.Left + 0.5f && to * wall.Length >= opening.Right - 0.5f )
				{
					return true;
				}
			}
		}

		return false;
	}

	static void Regroup( ArchPlan plan, ArchKit kit, ArchBuilding home, ArchCutPart cut, IReadOnlyList<Bite> bites )
	{
		var kinds = ArchKinds.Load();
		var reached = plan.Buildings
			.Where( building => building != home
				&& (bites.Any( bite => building.Rooms.Any( room => plan.Filed( ArchKind.Wall, room, kinds ).Contains( bite.Wall ) ) )
					|| Touches( building, cut, kit )) )
			.ToList();
		var homeGroup = ArchLayerGroups.Holding( plan, home.Id );

		if ( reached.Count == 0 )
		{
			if ( homeGroup is { Kind: not ArchAssemblyKind.SharedCore } )
			{
				ArchLayerGroups.Join( plan, homeGroup, cut.Id );
			}
			else
			{
				ArchLayerGroups.Leave( plan, cut.Id );
			}

			return;
		}

		var join = ArchLayerGroups.Holding( plan, cut.Id )
			?? homeGroup
			?? ArchLayerGroups.Create( plan, ArchLayerGroups.Named( plan ), ArchAssemblyKind.SharedCore, Array.Empty<int>() );
		var buildings = join.Children.Where( id => plan.FindBuilding( id ) is not null )
			.Concat( reached.Select( building => building.Id ) )
			.Append( home.Id )
			.Distinct()
			.ToList();

		foreach ( var member in buildings.Append( cut.Id ) )
		{
			ArchLayerGroups.Leave( plan, member );
		}

		join.Children.Clear();
		join.Children.AddRange( buildings );
		join.Children.Add( cut.Id );
	}

	static bool Touches( ArchBuilding building, ArchCutPart cut, ArchKit kit )
	{
		foreach ( var volume in ArchCut.Resolve( cut, kit ) )
		{
			foreach ( var room in building.Rooms.Where( room => room.HasFootprint ) )
			{
				var wallHeight = ArchFloorGen.WallHeight( room, kit );

				if ( ArchCut.Reaches( volume, room.BaseHeight - kit.FloorThickness, room.BaseHeight + wallHeight )
					&& ArchFootprint.Overlaps( ArchFloorGen.Footprint( room ), volume.Footprint ) )
				{
					return true;
				}
			}

			foreach ( var platform in building.Platforms )
			{
				if ( ArchCut.Reaches( volume, platform.GradeHeight, platform.TopHeight )
					&& ArchFootprint.Overlaps( platform.Outline(), volume.Footprint ) )
				{
					return true;
				}
			}
		}

		return false;
	}

	// Every wall standing in the cut's footprint, on every storey its band reaches, inside the scope the
	// cut was placed in. One resolution: the loops are the cut's own, so the hole in a wall and the hole in
	// the slab beside it come from the same shape.
	static List<Bite> Wanted( ArchPlan plan, ArchKit kit, ArchBuilding home, ArchCutPart cut )
	{
		var bites = new List<Bite>();

		if ( cut.IsDamage || !cut.HasContent || !ArchLayerGate.On( cut ) || !ArchCut.Affects( cut, ArchCutAffects.Walls ) )
		{
			return bites;
		}

		var reach = ArchLayerGroups.Reach( plan, cut.Id, home.Id );

		// Spanning rooms included: a walkway's side walls are ordinary walls and a shaft driven through one
		// has to open them like any other. ArchWalkwayConnection re-derives the link before this pass runs,
		// so the walls being bitten are the ones the link finally stands on.
		foreach ( var room in plan.AllRooms() )
		{
			if ( reach is not null && plan.OwnerOf( room ) is { } owner && !reach.Contains( owner.Id ) )
			{
				continue;
			}

			var wallHeight = ArchFloorGen.WallHeight( room, kit );

			// A wall raised AFTER the cut was drawn is not something the cut ever passed through - the stack
			// is ordered, so a hole is only owed to what was standing when the hole was made.
			foreach ( var wall in room.Walls.Where( wall => wall.Length >= 1f && ArchLayerOrder.Applies( plan, cut.Id, wall.Id ) ) )
			{
				foreach ( var leg in cut.Legs() )
				{
					Bitten( room, wall, wallHeight, leg, bites );
				}
			}
		}

		return bites;
	}

	// The wall's own run clipped against one leg's loop, and the leg's band clipped against the wall's
	// height. Both have to survive for there to be a hole at all.
	static void Bitten( ArchRoom room, ArchWall wall, float wallHeight, ArchCutSegment leg, List<Bite> into )
	{
		var sill = MathF.Max( 0f, leg.BaseHeight - room.BaseHeight );
		var head = MathF.Min( wallHeight, leg.TopHeight - room.BaseHeight );

		if ( head - sill < 1f )
		{
			return;
		}

		foreach ( var (from, to) in ArchFootprint.Inside( leg.Outline(), wall.Start, wall.End ) )
		{
			var start = from * wall.Length;
			var finish = to * wall.Length;

			if ( finish - start < LeastBite )
			{
				continue;
			}

			into.Add( new Bite
			{
				Wall = wall,
				Offset = (start + finish) * 0.5f,
				Width = finish - start,
				Sill = sill,
				Height = head - sill
			} );
		}
	}

	static HashSet<string> Standing( ArchPlan plan, ArchCutPart cut )
	{
		return plan.AllRooms()
			.SelectMany( room => room.Walls )
			.SelectMany( wall => wall.Openings
				.Where( opening => opening.OwnerId == cut.Id )
				.Select( opening => Signature( wall.Id, opening.Offset, opening.Width, opening.SillHeight, opening.Height ) ) )
			.ToHashSet();
	}

	static string Signature( Bite bite ) => Signature( bite.Wall.Id, bite.Offset, bite.Width, bite.Sill, bite.Height );

	static string Signature( int wallId, float offset, float width, float sill, float height )
	{
		return $"{wallId}:{offset:0.#}:{width:0.#}:{sill:0.#}:{height:0.#}";
	}

	// Whatever the cut opened, wherever it stands - closing is the other half of being able to re-cut, and
	// it stays plan-wide whatever the scope or a regrouped cut leaves holes behind it.
	public static void Close( ArchPlan plan, ArchCutPart cut )
	{
		foreach ( var wall in plan.AllRooms().SelectMany( room => room.Walls ) )
		{
			wall.Openings.RemoveAll( opening => opening.OwnerId == cut.Id );
		}
	}

	// Deleting the cut takes its holes and its own membership - never the group.
	public static void Remove( ArchPlan plan, ArchCutPart cut )
	{
		Close( plan, cut );
		ArchLayerGroups.Leave( plan, cut.Id );
	}

	sealed class Bite
	{
		public ArchWall Wall;
		public float Offset;
		public float Width;
		public float Sill;
		public float Height;
	}
}