Editor/Wall/ArchBalconyAffector.cs

Editor utility that computes and maintains recessed balcony cuts on buildings in an architectural plan. It finds orphaned cuts to remove, creates or updates a single ArchCutPart per ArchBalconyPart based on resolved balcony shape, and generates cut segments and signatures for comparisons.

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

namespace Sunless.Architecture;

// A recessed balcony is a bite out of the elevation it hangs on, so it is an effect on a host it does not
// own and obeys the affector contract: exactly ONE cut, re-derived from where the balcony stands NOW on
// every commit, and taken away with the balcony. The cut is then an ArchCutAffector client like any other,
// which is what turns the bite into the archway the wall actually wears - nothing here opens a wall.
public static class ArchBalconyAffector
{
	public static int Resolve( ArchPlan plan, ArchKit kit )
	{
		var kinds = ArchKinds.Load();
		var recut = 0;

		foreach ( var building in plan.Buildings.ToList() )
		{
			recut += Sweep( plan, kinds, building );

			foreach ( var balcony in plan.Filed<ArchBalconyPart>( building, kinds ).ToList() )
			{
				if ( Recut( plan, kinds, kit, building, balcony ) )
				{
					recut++;
				}
			}
		}

		return recut;
	}

	// A cut whose balcony has gone takes its holes with it - deleting the part is not deleting its effect,
	// and the selection's own remove cannot reach every path a part leaves the plan by.
	static int Sweep( ArchPlan plan, ArchKinds kinds, ArchBuilding building )
	{
		var standing = plan.Filed<ArchBalconyPart>( building, kinds ).Select( entry => entry.Id ).ToHashSet();
		var orphaned = plan.Filed( ArchKind.Cut, building, kinds )
			.OfType<ArchCutPart>()
			.Where( entry => entry.OwnerId != 0 && !standing.Contains( entry.OwnerId ) )
			.ToList();

		foreach ( var orphan in orphaned )
		{
			plan.Unfile( orphan, kinds );
			ArchCutAffector.Remove( plan, orphan );
		}

		return orphaned.Count;
	}

	static bool Recut( ArchPlan plan, ArchKinds kinds, ArchKit kit, ArchBuilding building, ArchBalconyPart balcony )
	{
		var shape = ArchBalcony.Resolve( balcony, building, kit );
		var standing = plan.Filed( ArchKind.Cut, building, kinds ).OfType<ArchCutPart>().FirstOrDefault( cut => cut.OwnerId == balcony.Id );

		if ( !shape.IsRecessed )
		{
			if ( standing is null )
			{
				return false;
			}

			plan.Unfile( standing, kinds );
			ArchCutAffector.Remove( plan, standing );

			return true;
		}

		var bite = Bitten( shape, balcony );

		if ( standing is null )
		{
			plan.File( ArchKind.Cut, building, Loggia( plan, balcony, shape, bite ), kinds );

			return true;
		}

		// Compared by where the bite SITS: a balcony slid along the same wall wants the same cut in a
		// different place, and an owner check alone would leave the recess behind where it used to stand.
		if ( Signature( standing ) == Signature( bite, shape.Level ) )
		{
			return false;
		}

		standing.Level = shape.Level;
		standing.Segments.Clear();
		standing.Segments.Add( bite );

		return true;
	}

	static ArchCutPart Loggia( ArchPlan plan, ArchBalconyPart balcony, ArchBalconyShape shape, ArchCutSegment bite )
	{
		return new ArchCutPart
		{
			Id = plan.AllocateId(),
			OwnerId = balcony.Id,
			Name = $"Loggia{balcony.Id}",
			Level = shape.Level,
			Profile = CutProfile.Poly,
			// The floor it stands on and the slab over its head are the storey's, so only the elevation is bitten.
			Affects = ArchCutAffects.Walls | ArchCutAffects.WallFittings | ArchCutAffects.Windows | ArchCutAffects.Trims,
			SnapAngle = false,
			Segments = { bite }
		};
	}

	static ArchCutSegment Bitten( ArchBalconyShape shape, ArchBalconyPart balcony )
	{
		var segment = new ArchCutSegment
		{
			Loop = shape.Recess(),
			Width = MathF.Max( 1f, balcony.Width ),
			BaseHeight = shape.Deck,
			TopHeight = shape.Head
		};

		ArchCut.Fit( segment, MathF.Atan2( shape.Along.y, shape.Along.x ).RadianToDegree() );

		return segment;
	}

	static string Signature( ArchCutPart cut )
	{
		return cut.Segments.Count == 1 ? Signature( cut.Segments[0], cut.Level ) : "";
	}

	static string Signature( ArchCutSegment bite, int level )
	{
		var corners = string.Join( ";", bite.Loop?.Select( point => $"{point.x:0.#},{point.y:0.#}" ) ?? Enumerable.Empty<string>() );

		return $"{level}:{bite.BaseHeight:0.#}:{bite.TopHeight:0.#}:{corners}";
	}
}