Editor/Wall/ArchOpeningRunAffector.cs

Editor utility that computes and applies a run of architectural openings (doors/windows) along a wall based on a kit and a preset. It determines what openings should stand, compares against current owned openings, closes outdated ones, and spawns new openings with resolved sizes and offsets.

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

namespace Sunless.Architecture;

// A bay rhythm is one setting on the wall, not forty records - but the units it stands ARE records, because a
// wall's hole is an authored ArchOpening and nothing else wears a frame, a sash and a sill.
//
// So they are an effect, and effects obey the contract: re-derived from the rhythm as it stands NOW on every
// commit, and closed the moment it stops standing. The wall owns them, so retyping the preset, dragging an end
// or changing the bay all hand back exactly what the rhythm currently describes.
public static class ArchOpeningRunAffector
{
	// The masonry between two units, and between a unit and something placed by hand. Below this the rhythm
	// is a strip window, which is a preset rather than a rhythm.
	const float LeastPier = 8f;

	public static int Resolve( ArchPlan plan, ArchKit kit )
	{
		var kinds = ArchKinds.Load();
		var stood = 0;

		foreach ( var room in plan.AllRooms().ToList() )
		{
			foreach ( var wall in plan.Filed<ArchWall>( room, kinds ).ToList() )
			{
				if ( Restand( plan, kit, room, wall ) )
				{
					stood++;
				}
			}
		}

		return stood;
	}

	// Compared by where each unit SITS, not by how many there are - a bay nudged along the same wall wants the
	// same count and different offsets, and a count check would skip the re-stand.
	static bool Restand( ArchPlan plan, ArchKit kit, ArchRoom room, ArchWall wall )
	{
		var preset = Named( kit, wall.OpeningRun );
		var wanted = Wanted( kit, room, wall, preset );
		var fitted = preset?.Furniture ?? OpeningFurniture.None;

		if ( Standing( wall ).SetEquals( wanted.Select( unit => Signature( wall.OpeningRun.Preset, fitted, unit ) ) ) )
		{
			return false;
		}

		Close( wall );

		foreach ( var unit in wanted )
		{
			var opening = preset.Spawn( plan.AllocateId(), unit.Offset );

			opening.OwnerId = wall.Id;
			opening.Width = unit.Width;
			opening.Height = unit.Height;
			opening.SillHeight = unit.SillHeight;

			wall.Openings.Add( opening );
		}

		return true;
	}

	// Exact, never FindOpening's fallback: a rhythm naming a preset the kit has lost would quietly stand
	// twelve of whatever happened to be first.
	static ArchOpeningPreset Named( ArchKit kit, ArchOpeningRun run )
	{
		return run.Stands
			? kit.Openings.FirstOrDefault( preset => string.Equals( preset.Name, run.Preset, StringComparison.OrdinalIgnoreCase ) )
			: null;
	}

	// Stations come off ArchDivide over the wall's USABLE run, so the end units sit inside the margin instead of
	// stacking on it, and each one resolves through the placement service a drag uses.
	static List<ArchOpeningShape> Wanted( ArchKit kit, ArchRoom room, ArchWall wall, ArchOpeningPreset preset )
	{
		var wanted = new List<ArchOpeningShape>();

		if ( preset is null || wall.Length < 1f )
		{
			return wanted;
		}

		var run = wall.OpeningRun;

		ArchOpeningSeats.Usable( wall, preset, kit, true, ArchGridService.FinestSize, out var runFrom, out var runTo );

		var usable = runTo - runFrom;
		var width = ArchGridService.Fine( run.Width > 1f ? run.Width : preset.Width );
		var fits = (int)MathF.Floor( usable / MathF.Max( 1f, width + LeastPier ) );

		// Asking for more than the elevation holds is answered with as many as it holds - openings that
		// overlap are one broken panel, not a tighter rhythm.
		if ( fits < 1 )
		{
			return wanted;
		}

		var asked = run.Count > 0 ? run.Count : ArchDivide.AtMost( usable, run.Bay ).Count;
		var division = ArchDivide.Into( usable, Math.Min( asked, fits ) );
		var wallHeight = ArchWallSection.Height( wall, room, kit );

		foreach ( var centre in division.Centres )
		{
			var unit = ArchOpeningSeats.Stationed( wall, preset, kit, run, runFrom + centre, wallHeight );

			if ( Clear( wall, unit ) )
			{
				wanted.Add( unit );
			}
		}

		return wanted;
	}

	// A hand-placed unit keeps its place and the rhythm parts around it. That is what an individual override is
	// here: drag the door you want and the windows make room for it, rather than editing one of twelve clones.
	static bool Clear( ArchWall wall, ArchOpeningShape unit )
	{
		var left = unit.Offset - unit.Width * 0.5f;
		var right = unit.Offset + unit.Width * 0.5f;

		return !wall.Openings
			.Where( opening => opening.OwnerId != wall.Id )
			.Any( opening => right > opening.Left - LeastPier && left < opening.Right + LeastPier );
	}

	static HashSet<string> Standing( ArchWall wall )
	{
		return wall.Openings
			.Where( opening => opening.OwnerId == wall.Id )
			.Select( opening => Signature( opening.Preset, opening.Furniture, opening.Offset, opening.Width, opening.SillHeight, opening.Height ) )
			.ToHashSet();
	}

	static string Signature( string preset, OpeningFurniture furniture, ArchOpeningShape unit )
	{
		return Signature( preset, furniture, unit.Offset, unit.Width, unit.SillHeight, unit.Height );
	}

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

	// The other half of being able to re-stand. Only the rhythm's own units - a hand-placed one is nobody
	// else's to close.
	public static void Close( ArchWall wall )
	{
		wall.Openings.RemoveAll( opening => opening.OwnerId == wall.Id );
	}
}