Editor utility for walkway connections. Computes where a walkway link should cut openings in room walls, opens/closes those openings during rebuilds, groups linked buildings into a site assembly, and manages the walkway deck layer.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// A walkway is a site connection, not a room one of the two buildings happens to own. Its mouths
// are an EFFECT it owns, re-derived from its current footprint on every rebuild - so dragging a
// link re-thinks the join exactly as placing it did, and the group it forms with the houses it
// reaches is the scope those mouths are allowed to cut into.
public static class ArchWalkwayConnection
{
public const string AnchorA = "AnchorA";
public const string AnchorB = "AnchorB";
public static ArchSiteAssembly Form( ArchPlan plan, ArchWalkway.Link link )
{
if ( link?.Room is null )
{
return null;
}
var hosts = link.Endpoints
.Select( endpoint => endpoint.Building )
.Where( building => building is not null )
.Distinct()
.ToList();
return Regroup( plan, link.Room, link.Roof, hosts, link.Endpoints.Select( endpoint => endpoint.Room ).ToList() );
}
// Every link, every rebuild: close what it opened, cut it again where it stands now, and file it
// with whatever it currently reaches. A link that reaches nothing keeps nothing open.
public static int Resolve( ArchPlan plan, ArchKit kit )
{
var resolved = 0;
foreach ( var link in plan.AllRooms().Where( room => room.Spans ).ToList() )
{
if ( Recut( plan, kit, link ) )
{
resolved++;
}
}
return resolved;
}
static bool Recut( ArchPlan plan, ArchKit kit, ArchRoom link )
{
var mouths = Mouths( link, kit ).ToList();
if ( mouths.Count == 0 )
{
return false;
}
var wanted = Wanted( plan, kit, link, mouths );
// Compared by where the hole SITS, not just which wall carries it - a link slid along the
// same wall wants the same wall and a different mouth, and an id check would skip the re-cut.
var settled = Standing( plan, link ).SetEquals( wanted.Select( Signature ) );
if ( !settled )
{
Close( plan, link );
foreach ( var cut in wanted )
{
cut.Wall.Openings.Add( new ArchOpening
{
Id = plan.AllocateId(),
OwnerId = link.Id,
Preset = "doorway",
Kind = OpeningKind.Archway,
Offset = cut.Offset,
Width = cut.Width,
Height = cut.Height,
Cased = false,
Leaf = false
} );
}
}
var rooms = new List<ArchRoom>();
var hosts = new List<ArchBuilding>();
foreach ( var cut in wanted )
{
if ( !rooms.Contains( cut.Room ) )
{
rooms.Add( cut.Room );
}
if ( plan.OwnerOf( cut.Room ) is { } host && !hosts.Contains( host ) )
{
hosts.Add( host );
}
}
// Always: a link whose cuts are settled can still be sitting in the wrong shape of group,
// which is what an older plan loaded into the layer editor looks like.
Regroup( plan, link, ArchWalkway.Deck( plan, link ), hosts, rooms );
return !settled;
}
// The walls each mouth would cut, worked out the same way the placement drag worked them out -
// one resolution, so a re-cut can never disagree with the cut it replaces.
static List<Cut> Wanted( ArchPlan plan, ArchKit kit, ArchRoom link, IReadOnlyList<(Vector2 From, Vector2 To)> mouths )
{
var merge = ArchLayerGroups.Holding( plan, link.Id )?.Merge ?? new ArchMergeSpec();
var cuts = new List<Cut>();
if ( !merge.OpenWalls )
{
return cuts;
}
var clear = merge.Clearance > 0f ? merge.Clearance : Clear( plan, link, kit );
var reach = ArchLayerGroups.Reach( plan, link.Id );
foreach ( var storey in Storeys( plan, link ) )
{
foreach ( var room in plan.AllRooms().Where( room => room.Floor == storey && !ReferenceEquals( room, link ) && !room.Spans ) )
{
// A group is the scope: outside it the link has nothing it is allowed to open.
if ( reach is not null && plan.OwnerOf( room ) is { } owner && !reach.Contains( owner.Id ) )
{
continue;
}
for ( var end = 0; end < mouths.Count; end++ )
{
if ( end == 0 ? !merge.NearEnd : !merge.FarEnd )
{
continue;
}
Meeting( room, kit, mouths[end].From, mouths[end].To, clear, cuts );
}
}
}
return cuts;
}
static void Meeting( ArchRoom room, ArchKit kit, Vector2 start, Vector2 end, float clear, List<Cut> into )
{
var span = end - start;
var length = span.Length;
if ( length < 1f )
{
return;
}
var direction = span / length;
foreach ( var wall in room.Walls )
{
if ( wall.Length < 1f || !ArchWallJoins.Aligned( direction, start, wall, kit.WallThickness ) )
{
continue;
}
var a = Vector2.Dot( start - wall.Start, wall.Direction );
var b = Vector2.Dot( end - wall.Start, wall.Direction );
var from = MathF.Max( MathF.Min( a, b ), 0f );
var to = MathF.Min( MathF.Max( a, b ), wall.Length );
if ( to - from < 4f )
{
continue;
}
var height = ArchWallSection.Height( wall, room, kit );
into.Add( new Cut
{
Room = room,
Wall = wall,
Offset = (from + to) * 0.5f,
Width = to - from,
// Never the full storey: a hole that took the whole wall would need the wall gone,
// and a mouth that follows its owner has to leave something to close.
Height = MathF.Min( clear, height - 8f )
} );
}
}
// The mouths, read back off the footprint the link stands on now rather than off the drag that
// first placed it - which is what makes a dragged link re-join whatever it now reaches.
static IEnumerable<(Vector2 From, Vector2 To)> Mouths( ArchRoom link, ArchKit kit )
{
var loop = ArchFloorGen.Footprint( link );
if ( loop.Count < 3 )
{
yield break;
}
ArchFootprint.Bounds( loop, out var min, out var max );
var alongX = max.x - min.x >= max.y - min.y;
var half = kit.WallThickness * 0.5f;
if ( alongX )
{
yield return (new Vector2( min.x, min.y + half ), new Vector2( min.x, max.y - half ));
yield return (new Vector2( max.x, max.y - half ), new Vector2( max.x, min.y + half ));
}
else
{
yield return (new Vector2( max.x - half, min.y ), new Vector2( min.x + half, min.y ));
yield return (new Vector2( min.x + half, max.y ), new Vector2( max.x - half, max.y ));
}
}
// A flat link meets one storey; a rising one meets the storey it climbs to as well.
static IEnumerable<int> Storeys( ArchPlan plan, ArchRoom link )
{
yield return link.Floor;
if ( link.WalkwayTop <= link.BaseHeight + 1f )
{
yield break;
}
var climbed = plan.AllRooms()
.FirstOrDefault( room => !room.Spans && MathF.Abs( room.BaseHeight - link.WalkwayTop ) < 8f );
if ( climbed is not null && climbed.Floor != link.Floor )
{
yield return climbed.Floor;
}
}
static float Clear( ArchPlan plan, ArchRoom link, ArchKit kit )
{
if ( ArchWalkway.Deck( plan, link ) is { } deck && deck.BaseHeight > link.BaseHeight )
{
return deck.BaseHeight - link.BaseHeight;
}
return ArchFloorGen.WallHeight( link, kit );
}
static HashSet<string> Standing( ArchPlan plan, ArchRoom link )
{
return plan.AllRooms()
.SelectMany( room => room.Walls )
.SelectMany( wall => wall.Openings
.Where( opening => opening.OwnerId == link.Id )
.Select( opening => Signature( wall.Id, opening.Offset, opening.Width, opening.Height ) ) )
.ToHashSet();
}
static string Signature( Cut cut ) => Signature( cut.Wall.Id, cut.Offset, cut.Width, cut.Height );
static string Signature( int wallId, float offset, float width, float height )
{
return $"{wallId}:{offset:0.#}:{width:0.#}:{height:0.#}";
}
// One group: the houses the link merges, and the link filed last because it is the AFFECTOR - it
// acts on what sits above it. Its deck rides under the link itself rather than beside it.
// Reaching nothing dissolves the group, because there is no longer a join to describe.
static ArchSiteAssembly Regroup( ArchPlan plan, ArchRoom link, ArchRoofPart deck, IReadOnlyList<ArchBuilding> hosts, IReadOnlyList<ArchRoom> anchors )
{
var join = ArchLayerGroups.Holding( plan, link.Id );
// Grouping is automatic, ungrouping is the author's call. A link that stops reaching leaves
// the group; the group stays, holding the houses, because deleting what someone arranged is
// not ours to do - the stack has an Ungroup for that.
if ( hosts.Count == 0 )
{
ArchLayerGroups.Leave( plan, link.Id );
return null;
}
join ??= ArchLayerGroups.Create( plan, ArchLayerGroups.Named( plan ), ArchAssemblyKind.Walkway, Array.Empty<int>() );
var members = hosts.Select( host => host.Id ).Append( link.Id ).ToList();
foreach ( var member in members )
{
ArchLayerGroups.Leave( plan, member );
}
join.Children.Clear();
join.Children.AddRange( members );
Nest( plan, deck, link );
ArchLayerGroups.Link( plan, join.Id, AnchorA, anchors.ElementAtOrDefault( 0 )?.Id ?? 0, "Boundary" );
ArchLayerGroups.Link( plan, join.Id, AnchorB, anchors.ElementAtOrDefault( 1 )?.Id ?? 0, "Boundary" );
return join;
}
// The deck is a roof filed on the host building, which is where the generator wants it. A layer
// record moves only its ROW, so the stack shows it as part of the link it covers.
static void Nest( ArchPlan plan, ArchRoofPart deck, ArchRoom link )
{
if ( deck is null )
{
return;
}
ArchLayerGroups.Leave( plan, deck.Id );
var record = plan.Layers.FirstOrDefault( entry => entry.ItemId == deck.Id );
if ( record is null )
{
record = new ArchLayerRecord { ItemId = deck.Id, Kind = ArchKind.Roof, Stage = ArchLayerStage.Structure };
plan.Layers.Add( record );
}
record.ParentId = link.Id;
}
// Whatever a link opened, wherever it stands - closing is the other half of being able to re-cut.
public static void Close( ArchPlan plan, ArchRoom link )
{
foreach ( var wall in plan.AllRooms().SelectMany( room => room.Walls ) )
{
wall.Openings.RemoveAll( opening => opening.OwnerId == link.Id );
}
}
// Deleting the link takes its mouths, its deck and its own membership - never the group. Losing an
// affector is not a reason to throw away the arrangement of what it was affecting.
public static void Remove( ArchPlan plan, ArchRoom link )
{
Close( plan, link );
Strip( plan, link );
ArchLayerGroups.Leave( plan, link.Id );
}
// The deck is filed on a HOST building, so removing the link from its own room list never reaches it: it
// stayed standing as a flat roof over the gap with no link under it, orphaned at the top of the stack.
static void Strip( ArchPlan plan, ArchRoom link )
{
if ( ArchWalkway.Deck( plan, link ) is not { } deck )
{
return;
}
plan.Unfile( deck );
plan.Layers.RemoveAll( record => record.ItemId == deck.Id );
ArchLayerGroups.Leave( plan, deck.Id );
}
sealed class Cut
{
public ArchRoom Room;
public ArchWall Wall;
public float Offset;
public float Width;
public float Height;
}
}