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.
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}";
}
}