Editor-side utility that computes and applies wall openings created by architectural cuts. It resolves which walls a given cut intersects, creates/openings, marks openings as swallowed by cuts for windows, groups layer membership, and removes or closes cut-generated openings.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// A cut is a boolean on everything it passes through, and a wall is one of those things. Slabs, ceilings,
// roof decks and platforms already answer it by asking ArchCut what covers them - they are DERIVED and need
// no record. A wall cannot: its holes are authored openings, so the cut has to own them.
//
// So they are an effect, and effects obey the contract: re-derived from where the cut stands NOW on every
// commit, scoped to the group the cut was placed in, and closed plan-wide whatever the scope. Drag the cut
// and the holes follow it; disable it and ArchLayerGate stops them being read without deleting anything.
public static class ArchCutAffector
{
// The narrowest bite worth taking out of a wall - below this the cut is only clipping the skin's corner.
const float LeastBite = 4f;
public static int Resolve( ArchPlan plan, ArchKit kit )
{
var opened = 0;
foreach ( var building in plan.Buildings.ToList() )
{
foreach ( var cut in building.Cuts.ToList() )
{
if ( Recut( plan, kit, building, cut ) )
{
opened++;
}
}
}
return opened;
}
static bool Recut( ArchPlan plan, ArchKit kit, ArchBuilding home, ArchCutPart cut )
{
var wanted = Wanted( plan, kit, home, cut );
var settled = Standing( plan, cut ).SetEquals( wanted.Select( Signature ) );
// Compared by where the hole SITS, not just which wall carries it - a cut slid along the same wall
// wants the same wall and a different hole, and an id check would skip the re-cut.
if ( !settled )
{
Close( plan, cut );
foreach ( var bite in wanted )
{
bite.Wall.Openings.Add( new ArchOpening
{
Id = plan.AllocateId(),
OwnerId = cut.Id,
Preset = "doorway",
Kind = OpeningKind.Archway,
Offset = bite.Offset,
Width = bite.Width,
SillHeight = bite.Sill,
Height = bite.Height,
Cased = cut.Edge,
HasSill = false,
CasingWidth = cut.EdgeWidth,
Leaf = false,
Glazed = false,
AutoLayout = false
} );
}
}
Swallowed( plan, kit, home, cut );
Regroup( plan, kit, home, cut, wanted );
return !settled;
}
// A unit standing where the hole now is. It is not clipped: half a window is a broken window, and the
// author who ticked this asked for the wall to be open there. Disabled rather than deleted, so unticking
// the box or dragging the cut off it gives the same window back - the layer gate is the whole mechanism.
static void Swallowed( ArchPlan plan, ArchKit kit, ArchBuilding home, ArchCutPart cut )
{
var taking = !cut.IsDamage && ArchCut.Affects( cut, ArchCutAffects.Windows ) && cut.HasContent && ArchLayerGate.On( cut );
var reach = ArchLayerGroups.Reach( plan, cut.Id, home.Id );
foreach ( var room in plan.AllRooms() )
{
if ( reach is not null && plan.OwnerOf( room ) is { } owner && !reach.Contains( owner.Id ) )
{
continue;
}
foreach ( var wall in room.Walls )
{
// Hand-placed, or stood by the wall's own rhythm. Never a hole another part punched - that
// archway IS the way through, so swallowing it would close what opened the wall.
foreach ( var opening in wall.Openings.Where( opening => opening.OwnerId == 0 || opening.OwnerId == wall.Id ) )
{
var buried = taking && Buried( room, wall, opening, cut );
if ( buried )
{
opening.SwallowedBy = cut.Id;
}
else if ( opening.SwallowedBy == cut.Id )
{
opening.SwallowedBy = 0;
}
}
}
}
}
// Buried when the hole covers the unit's whole run and its whole height - a cut clipping one jamb leaves
// the window where it is, because a window is a thing and not a length of wall.
static bool Buried( ArchRoom room, ArchWall wall, ArchOpening opening, ArchCutPart cut )
{
foreach ( var leg in cut.Legs() )
{
if ( leg.BaseHeight - room.BaseHeight > opening.SillHeight + 0.5f || leg.TopHeight - room.BaseHeight < opening.Top - 0.5f )
{
continue;
}
foreach ( var (from, to) in ArchFootprint.Inside( leg.Outline(), wall.Start, wall.End ) )
{
if ( from * wall.Length <= opening.Left + 0.5f && to * wall.Length >= opening.Right - 0.5f )
{
return true;
}
}
}
return false;
}
static void Regroup( ArchPlan plan, ArchKit kit, ArchBuilding home, ArchCutPart cut, IReadOnlyList<Bite> bites )
{
var kinds = ArchKinds.Load();
var reached = plan.Buildings
.Where( building => building != home
&& (bites.Any( bite => building.Rooms.Any( room => plan.Filed( ArchKind.Wall, room, kinds ).Contains( bite.Wall ) ) )
|| Touches( building, cut, kit )) )
.ToList();
var homeGroup = ArchLayerGroups.Holding( plan, home.Id );
if ( reached.Count == 0 )
{
if ( homeGroup is { Kind: not ArchAssemblyKind.SharedCore } )
{
ArchLayerGroups.Join( plan, homeGroup, cut.Id );
}
else
{
ArchLayerGroups.Leave( plan, cut.Id );
}
return;
}
var join = ArchLayerGroups.Holding( plan, cut.Id )
?? homeGroup
?? ArchLayerGroups.Create( plan, ArchLayerGroups.Named( plan ), ArchAssemblyKind.SharedCore, Array.Empty<int>() );
var buildings = join.Children.Where( id => plan.FindBuilding( id ) is not null )
.Concat( reached.Select( building => building.Id ) )
.Append( home.Id )
.Distinct()
.ToList();
foreach ( var member in buildings.Append( cut.Id ) )
{
ArchLayerGroups.Leave( plan, member );
}
join.Children.Clear();
join.Children.AddRange( buildings );
join.Children.Add( cut.Id );
}
static bool Touches( ArchBuilding building, ArchCutPart cut, ArchKit kit )
{
foreach ( var volume in ArchCut.Resolve( cut, kit ) )
{
foreach ( var room in building.Rooms.Where( room => room.HasFootprint ) )
{
var wallHeight = ArchFloorGen.WallHeight( room, kit );
if ( ArchCut.Reaches( volume, room.BaseHeight - kit.FloorThickness, room.BaseHeight + wallHeight )
&& ArchFootprint.Overlaps( ArchFloorGen.Footprint( room ), volume.Footprint ) )
{
return true;
}
}
foreach ( var platform in building.Platforms )
{
if ( ArchCut.Reaches( volume, platform.GradeHeight, platform.TopHeight )
&& ArchFootprint.Overlaps( platform.Outline(), volume.Footprint ) )
{
return true;
}
}
}
return false;
}
// Every wall standing in the cut's footprint, on every storey its band reaches, inside the scope the
// cut was placed in. One resolution: the loops are the cut's own, so the hole in a wall and the hole in
// the slab beside it come from the same shape.
static List<Bite> Wanted( ArchPlan plan, ArchKit kit, ArchBuilding home, ArchCutPart cut )
{
var bites = new List<Bite>();
if ( cut.IsDamage || !cut.HasContent || !ArchLayerGate.On( cut ) || !ArchCut.Affects( cut, ArchCutAffects.Walls ) )
{
return bites;
}
var reach = ArchLayerGroups.Reach( plan, cut.Id, home.Id );
// Spanning rooms included: a walkway's side walls are ordinary walls and a shaft driven through one
// has to open them like any other. ArchWalkwayConnection re-derives the link before this pass runs,
// so the walls being bitten are the ones the link finally stands on.
foreach ( var room in plan.AllRooms() )
{
if ( reach is not null && plan.OwnerOf( room ) is { } owner && !reach.Contains( owner.Id ) )
{
continue;
}
var wallHeight = ArchFloorGen.WallHeight( room, kit );
// A wall raised AFTER the cut was drawn is not something the cut ever passed through - the stack
// is ordered, so a hole is only owed to what was standing when the hole was made.
foreach ( var wall in room.Walls.Where( wall => wall.Length >= 1f && ArchLayerOrder.Applies( plan, cut.Id, wall.Id ) ) )
{
foreach ( var leg in cut.Legs() )
{
Bitten( room, wall, wallHeight, leg, bites );
}
}
}
return bites;
}
// The wall's own run clipped against one leg's loop, and the leg's band clipped against the wall's
// height. Both have to survive for there to be a hole at all.
static void Bitten( ArchRoom room, ArchWall wall, float wallHeight, ArchCutSegment leg, List<Bite> into )
{
var sill = MathF.Max( 0f, leg.BaseHeight - room.BaseHeight );
var head = MathF.Min( wallHeight, leg.TopHeight - room.BaseHeight );
if ( head - sill < 1f )
{
return;
}
foreach ( var (from, to) in ArchFootprint.Inside( leg.Outline(), wall.Start, wall.End ) )
{
var start = from * wall.Length;
var finish = to * wall.Length;
if ( finish - start < LeastBite )
{
continue;
}
into.Add( new Bite
{
Wall = wall,
Offset = (start + finish) * 0.5f,
Width = finish - start,
Sill = sill,
Height = head - sill
} );
}
}
static HashSet<string> Standing( ArchPlan plan, ArchCutPart cut )
{
return plan.AllRooms()
.SelectMany( room => room.Walls )
.SelectMany( wall => wall.Openings
.Where( opening => opening.OwnerId == cut.Id )
.Select( opening => Signature( wall.Id, opening.Offset, opening.Width, opening.SillHeight, opening.Height ) ) )
.ToHashSet();
}
static string Signature( Bite bite ) => Signature( bite.Wall.Id, bite.Offset, bite.Width, bite.Sill, bite.Height );
static string Signature( int wallId, float offset, float width, float sill, float height )
{
return $"{wallId}:{offset:0.#}:{width:0.#}:{sill:0.#}:{height:0.#}";
}
// Whatever the cut opened, wherever it stands - closing is the other half of being able to re-cut, and
// it stays plan-wide whatever the scope or a regrouped cut leaves holes behind it.
public static void Close( ArchPlan plan, ArchCutPart cut )
{
foreach ( var wall in plan.AllRooms().SelectMany( room => room.Walls ) )
{
wall.Openings.RemoveAll( opening => opening.OwnerId == cut.Id );
}
}
// Deleting the cut takes its holes and its own membership - never the group.
public static void Remove( ArchPlan plan, ArchCutPart cut )
{
Close( plan, cut );
ArchLayerGroups.Leave( plan, cut.Id );
}
sealed class Bite
{
public ArchWall Wall;
public float Offset;
public float Width;
public float Sill;
public float Height;
}
}