Editor/Walkway/ArchWalkwayConnection.cs
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: settle what it has open against 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 );
// Slotted by the wall and the ordinal on it, so a link slid along the same wall re-dresses the
// mouth it already owns there rather than closing it and allocating another id.
var moved = ArchOwned.Settle( plan, ArchOwned.Openings, link.Id,
wanted.Select( Mouth ).ToList(), () => new ArchOpening() );
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 moved;
}
static ArchWant<ArchWall, ArchOpening> Mouth( Cut cut ) {
return new ArchWant<ArchWall, ArchOpening>( cut.Wall, opening => {
opening.Preset = "doorway";
opening.Kind = OpeningKind.Archway;
opening.Offset = cut.Offset;
opening.Width = cut.Width;
opening.Height = cut.Height;
opening.Cased = false;
opening.Leaf = false;
} );
}
// 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 );
}
// 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 ) {
ArchOwned.Close( plan, ArchOwned.Openings, 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;
}
}