Editor-side classes for the architecture plan and palette. Defines ArchPlan (holds units, assemblies, instances, id allocation, migrations and normalization), ArchPalette and related UV/material data, and concrete unit types like ArchBuilding and ArchRoom with their content lists and simple behaviors.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Sandbox;
namespace Sunless.Architecture;
public sealed class ArchPlan
{
public int Version { get; set; } = 3;
public int NextId { get; set; } = 1;
public string KitName { get; set; } = "default";
// Every top-level thing standing on the map, in stack order, whatever kind it is.
public List<ArchUnit> Units { get; set; } = new();
// Version 2 filed the two kinds in lists of their own. Read back under those names and folded into Units by
// Normalize, so an old plan opens whole and is written back carrying one list.
[JsonPropertyName( "Buildings" )]
public List<ArchBuilding> LegacyBuildings { get; set; }
[JsonPropertyName( "Roads" )]
public List<ArchRoadPart> LegacyRoads { get; set; }
// A view over the one list, so a pass that only cares about buildings still reads naturally. Filed through
// Units - adding to a view would go nowhere, which is why it is not a List. Every other kind's view is shipped
// by the module that owns it, so core names one type here and no more.
[JsonIgnore]
public IReadOnlyList<ArchBuilding> Buildings => Units.OfType<ArchBuilding>().ToList();
// Cross-building relationships and placed reusable assets.
public List<ArchSiteAssembly> Assemblies { get; set; } = new();
public List<ArchAssetInstance> Instances { get; set; } = new();
// Explicit layer metadata - written only when a legacy item is reparented, disabled or locked.
public List<ArchLayerRecord> Layers { get; set; } = new();
// What an author said about one named PIECE of a layer - a roof's fascia, a porch's balustrade.
public List<ArchPartRecord> Parts { get; set; } = new();
public List<ArchLayerLink> Links { get; set; } = new();
public int AllocateId() => NextId++;
public IEnumerable<ArchRoom> AllRooms() => Buildings.SelectMany( building => building.Rooms );
public ArchBuilding FindBuilding( int id ) => Buildings.FirstOrDefault( building => building.Id == id );
public ArchRoom FindRoom( int id ) => AllRooms().FirstOrDefault( room => room.Id == id );
public ArchBuilding OwnerOf( ArchRoom room ) => Buildings.FirstOrDefault( building => building.Rooms.Contains( room ) );
// Buildings first, then roads, the order version 2 wrote them in. Run before anything counts ids, and it
// clears the legacy lists so a plan opened and saved again carries Units alone.
void Adopt()
{
if ( LegacyBuildings is { Count: > 0 } )
{
Units.AddRange( LegacyBuildings );
}
if ( LegacyRoads is { Count: > 0 } )
{
Units.AddRange( LegacyRoads );
}
LegacyBuildings = null;
LegacyRoads = null;
}
// Empty can never be a count - a placement seeds a target before it knows whether it will fill it. A road being
// drawn holds one node and no content yet, and blanking the file under it is how an authored plan is lost.
[JsonIgnore]
public bool HasContent => this.Roads().Count > 0 || Units.Any( unit => unit.HasContent );
// A placement target that was seeded and never filled is not authored content, so it must not survive
// the commit - it would stand in the layer stack as an empty Building/Level/Room nobody drew.
public int DiscardEmptyTargets()
{
var dropped = 0;
foreach ( var building in Buildings )
{
dropped += building.Rooms.RemoveAll( room => !room.HasContent );
}
return dropped + Units.RemoveAll( unit => !unit.HasContent );
}
public void Normalize()
{
Adopt();
NextId = System.Math.Max( 1, HighestId() + 1 );
foreach ( var building in Buildings )
{
// Plans saved before fences carried a curve - lift the old path onto nodes once.
foreach ( var fence in building.Fences.Where( entry => entry.Path.Count >= 2 && entry.Nodes.Count == 0 ) )
{
fence.Nodes = fence.Path.Select( ArchCurveNode.At ).ToList();
fence.Path = new List<Vector3>();
}
// Plans saved before a stair was a shaft: the chain of overlapping boxes is read once into the core
// that bounds them and the flights that stood in it, then let go of. Everything downstream has only
// ever seen the resolve, so a converted stair builds exactly what it built before.
foreach ( var room in building.Rooms )
{
foreach ( var stair in room.Stairs )
{
Reshaft( stair );
}
}
foreach ( var platform in building.Platforms )
{
foreach ( var stair in platform.Stairs )
{
Reshaft( stair );
}
}
foreach ( var porch in building.Rooms.SelectMany( room => room.Porches ) )
{
foreach ( var stair in porch.Stairs )
{
Reshaft( stair );
}
}
}
foreach ( var building in Buildings )
{
foreach ( var room in building.Rooms )
{
room.Walls.RemoveAll( wall => wall.Length < 1f );
}
foreach ( var roof in building.Roofs )
{
roof.Walls.RemoveAll( wall => wall.Length < 1f );
}
foreach ( var roof in building.Roofs.Where( roof => roof.HasFootprint ) )
{
roof.Reshape( roof.Footprint );
}
SeparateSingleSpanRoofs( building );
}
}
// Two migrations, each of which runs exactly once per plan however many times Normalize is called, because
// each lets go of what it read.
//
// A stair authored as a chain of overlapping boxes is read into the shaft that bounds them - a stair saved
// before there were legs at all still gets a shaft, because a core of nothing builds nothing. Then a stair
// authored when a landing was DERIVED has that derive run one last time and left behind as real landing
// steps, so nothing downstream ever works a pad out again.
void Reshaft( ArchStairPart stair )
{
if ( stair.Legs is { Count: > 0 } legs )
{
var (core, lanes) = ArchStairLanes.FromLegs( legs );
core.Rise = MathF.Max( 4f, stair.StoredRise > 1f ? stair.StoredRise : stair.Core?.Rise ?? 0f );
stair.Core = core;
stair.Lanes = lanes;
stair.Legs = null;
}
stair.StoredRise = 0f;
ArchStairLanes.Settle( this, stair );
ArchStairLanes.Number( this, stair );
}
void SeparateSingleSpanRoofs( ArchBuilding building )
{
for ( var roofIndex = building.Roofs.Count - 1; roofIndex >= 0; roofIndex-- )
{
var roof = building.Roofs[roofIndex];
var outline = roof.Outline();
if ( roof.Style is not (RoofStyle.Gable or RoofStyle.Shed or RoofStyle.Sawtooth) ||
!roof.HasFootprint ||
outline.Count == 4 )
{
continue;
}
var rooms = building.Rooms
.Where( room => room.Floor == roof.Level )
.Select( room => (Room: room, Footprint: ArchFloorGen.Footprint( room )) )
.Where( candidate => candidate.Footprint.Count == 4 )
.Where( candidate => ArchRegion.Covers( new[] { outline }, candidate.Footprint ) )
.ToList();
rooms.RemoveAll( candidate => rooms.Any( other =>
!ReferenceEquals( candidate.Room, other.Room ) &&
MathF.Abs( ArchFootprint.SignedArea( other.Footprint ) ) > MathF.Abs( ArchFootprint.SignedArea( candidate.Footprint ) ) &&
ArchRegion.Covers( new[] { other.Footprint }, candidate.Footprint ) ) );
var occupied = ArchFootprint.Union( rooms.Select( candidate => candidate.Footprint ).ToList() );
if ( rooms.Count < 2 ||
!ArchRegion.Covers( occupied, outline ) ||
occupied.Any( loop => !ArchRegion.Covers( new[] { outline }, loop ) ) )
{
continue;
}
building.Roofs.RemoveAt( roofIndex );
for ( var roomIndex = rooms.Count - 1; roomIndex >= 0; roomIndex-- )
{
var section = roof.Duplicate( roomIndex == 0 ? roof.Id : AllocateId(), rooms[roomIndex].Footprint );
building.Roofs.Insert( roofIndex, section );
}
}
}
int HighestId()
{
var ids = new List<int> { 0 };
var roads = this.Roads();
ids.AddRange( Assemblies.Select( assembly => assembly.Id ) );
ids.AddRange( Instances.Select( instance => instance.Id ) );
ids.AddRange( roads.Select( road => road.Id ) );
ids.AddRange( roads.SelectMany( road => road.Crossings ).Select( crossing => crossing.Id ) );
ids.AddRange( roads.SelectMany( road => road.Bridges ).Select( bridge => bridge.Id ) );
ids.AddRange( roads.SelectMany( road => road.Tunnels ).Select( tunnel => tunnel.Id ) );
ids.AddRange( roads.SelectMany( road => road.Cuts ).Select( cut => cut.Id ) );
foreach ( var building in Buildings )
{
ids.Add( building.Id );
ids.AddRange( building.Rooms.Select( room => room.Id ) );
ids.AddRange( building.Rooms.SelectMany( room => room.Stairs ).Select( stair => stair.Id ) );
ids.AddRange( building.Rooms.SelectMany( room => room.Trims ).Select( trim => trim.Id ) );
ids.AddRange( building.Rooms.SelectMany( room => room.Pillars ).Select( pillar => pillar.Id ) );
ids.AddRange( building.Rooms.SelectMany( room => room.PierSpans ).Select( span => span.Id ) );
ids.AddRange( building.Rooms.SelectMany( room => room.Beams ).Select( beam => beam.Id ) );
// The porch is a host, so its own children are in here too - miss them and an id is handed out
// twice, which in the scene is one object standing where two were meant to.
var porches = building.Rooms.SelectMany( room => room.Porches ).ToList();
ids.AddRange( porches.Select( porch => porch.Id ) );
ids.AddRange( porches.SelectMany( porch => porch.Stairs ).Select( stair => stair.Id ) );
ids.AddRange( porches.SelectMany( porch => porch.Pillars ).Select( pillar => pillar.Id ) );
ids.AddRange( porches.SelectMany( porch => porch.Trims ).Select( trim => trim.Id ) );
ids.AddRange( building.Rooms.SelectMany( room => room.Approaches ).Select( approach => approach.Id ) );
ids.AddRange( building.Roofs.Select( roof => roof.Id ) );
ids.AddRange( building.Roofs.SelectMany( roof => roof.Lights ).Select( light => light.Id ) );
ids.AddRange( building.Fences.Select( fence => fence.Id ) );
ids.AddRange( building.Downpipes.Select( pipe => pipe.Id ) );
ids.AddRange( building.Pipes.Select( run => run.Id ) );
ids.AddRange( building.Pipes.SelectMany( run => run.Nodes ).Select( node => node.Id ) );
ids.AddRange( building.Brackets.Select( bracket => bracket.Id ) );
ids.AddRange( building.Ladders.Select( ladder => ladder.Id ) );
ids.AddRange( building.Balconies.Select( balcony => balcony.Id ) );
ids.AddRange( building.ExteriorStairs.Select( flight => flight.Id ) );
ids.AddRange( building.Cutouts.Select( cutout => cutout.Id ) );
ids.AddRange( building.Cuts.Select( cut => cut.Id ) );
ids.AddRange( building.Platforms.Select( platform => platform.Id ) );
ids.AddRange( building.Platforms.SelectMany( platform => platform.Stairs ).Select( stair => stair.Id ) );
}
// Through AllWalls, or a parapet's id is handed out again the next time a build normalizes, and two
// walls sharing an id are one object in the scene - the second stands where the first stood.
var walls = this.AllWalls().ToList();
ids.AddRange( walls.Select( wall => wall.Id ) );
ids.AddRange( walls.SelectMany( wall => wall.Openings ).Select( opening => opening.Id ) );
ids.AddRange( walls.SelectMany( wall => wall.Modifiers ).Select( modifier => modifier.Id ) );
// Every step of a climb carries an id so a railing can name the one it guards, exactly as a pipe's nodes
// do - and through Parts, because a stair stands in a room, on a porch and on a platform, and the three
// lists that hold them are three chances to forget one.
var steps = this.Parts<ArchStairPart>().SelectMany( stair => stair.Lanes ).ToList();
ids.AddRange( steps.Select( lane => lane.Id ) );
ids.AddRange( steps.SelectMany( lane => lane.Guards ).Select( guard => guard.Id ) );
// And through the bytes no type here claims, or the parts of a kind this build cannot read are invisible
// to the allocator and the next id handed out is one something already holds.
ids.AddRange( Units.Select( unit => unit.Id ) );
ids.AddRange( Units.Select( unit => ArchPlanStore.HighestIdIn( unit.Payloads ) ) );
ids.AddRange( AllRooms().Select( room => ArchPlanStore.HighestIdIn( room.Payloads ) ) );
return ids.Max();
}
}
public sealed class ArchPalette
{
public Dictionary<string, string> Materials { get; set; } = new();
public Dictionary<string, float> TexelScales { get; set; } = new();
public Dictionary<string, Vector2> TextureOffsets { get; set; } = new();
public Dictionary<string, ArchFaceUvSet> FaceMappings { get; set; } = new();
public bool TryGet( ArchSurface surface, out string path )
{
return Materials.TryGetValue( surface.ToString(), out path ) && !string.IsNullOrWhiteSpace( path );
}
// The density belongs to the material, so re-pointing a role drops it.
public void Set( ArchSurface surface, string path )
{
TexelScales.Remove( surface.ToString() );
if ( string.IsNullOrWhiteSpace( path ) )
{
Materials.Remove( surface.ToString() );
TextureOffsets?.Remove( surface.ToString() );
return;
}
Materials[surface.ToString()] = path;
}
public void Set( ArchSurface surface, string path, float texelScale )
{
Set( surface, path );
if ( !string.IsNullOrWhiteSpace( path ) )
{
SetScale( surface, texelScale );
}
}
// Zero means "no override" - the generator falls back to ArchMesh.TexelScale.
public void SetScale( ArchSurface surface, float texelScale )
{
if ( texelScale <= 0f )
{
TexelScales.Remove( surface.ToString() );
return;
}
TexelScales[surface.ToString()] = texelScale;
}
public float ScaleFor( ArchSurface surface )
{
return TexelScales.TryGetValue( surface.ToString(), out var scale ) ? scale : 0f;
}
public void SetOffset( ArchSurface surface, Vector2 offset )
{
if ( offset.IsNearZeroLength )
{
TextureOffsets?.Remove( surface.ToString() );
return;
}
TextureOffsets ??= new();
TextureOffsets[surface.ToString()] = offset;
}
public Vector2 OffsetFor( ArchSurface surface )
{
return TextureOffsets is not null && TextureOffsets.TryGetValue( surface.ToString(), out var offset ) ? offset : Vector2.Zero;
}
}
public interface IArchPainted
{
ArchPalette Palette { get; set; }
}
public sealed class ArchFaceUvSet
{
public List<ArchFaceUvVariation> Variations { get; set; } = new();
}
public sealed class ArchFaceUvVariation
{
public string Signature { get; set; }
public List<ArchFaceUvFace> Faces { get; set; } = new();
}
public sealed class ArchFaceUvFace
{
public List<Vector2> Coordinates { get; set; } = new();
}
// One top-level thing standing on the map. A house and a street are not the same shape and never will be, but
// everything the STACK does to one it does to the other - name it, disable it, group it, order it, carve it - so
// they share a base and the plan holds one list. Walking two lists is how a road quietly stopped being reached
// by half the passes that reach a building.
[JsonConverter( typeof( ArchUnitConverter ) )]
public abstract class ArchUnit : IArchCollides, IArchNamed
{
// The whole unit at once - what a far-off silhouette is set to None from.
public ArchCollisionMode? Collision { get; set; }
// Which module's unit this is. Written as an ordinary field rather than a polymorphic discriminator, because
// System.Text.Json throws on a discriminator it does not recognise and an unknown kind is the not-installed case.
public ArchKind Kind { get; set; }
public int Id { get; set; }
// The author's, not the kind's: a unit is renamed in the Plan Layers stack like any other layer.
public string Name { get; set; } = "Unit";
public ArchPalette Palette { get; set; } = new();
// One list - a stairwell, a passage and a service bay are the same part with a different profile.
public List<ArchCutPart> Cuts { get; set; } = new();
// Whatever a module filed here that this editor has no type for. Written back exactly as it was read, so a plan
// opened without the library that authored it saves whole instead of losing that library's work.
[JsonExtensionData]
public Dictionary<string, JsonElement> Payloads { get; set; } = new();
[JsonIgnore]
public abstract bool HasContent { get; }
}
// A unit whose kind no installed module claims. It carries nothing this editor can read and everything the file
// gave it, so it round-trips byte for byte - and it always reports content, or DiscardEmptyTargets would delete
// the one thing in the plan nobody here is able to see.
public sealed class ArchOpaqueUnit : ArchUnit
{
public override bool HasContent => true;
}
public sealed class ArchBuilding : ArchUnit, IArchPainted
{
public ArchBuilding()
{
Name = "Building";
Kind = ArchKind.Building;
}
// A wing extended later still comes out the same type, not kit defaults.
public string Archetype { get; set; } = "";
public List<ArchRoom> Rooms { get; set; } = new();
public List<ArchRoofPart> Roofs { get; set; } = new();
public List<ArchDownpipePart> Downpipes { get; set; } = new();
// Service corridors and the hangers under them. Filed on the unit rather than a room, because a run
// crosses partitions the way a fence crosses a yard - the volume is world-space and answers to no floor.
public List<ArchPipePart> Pipes { get; set; } = new();
public List<ArchPipeBracketPart> Brackets { get; set; } = new();
public List<ArchFencePart> Fences { get; set; } = new();
// Platforms stand in the yard, so they hang off the building like a fence.
public List<ArchPlatformPart> Platforms { get; set; } = new();
public List<ArchLadderPart> Ladders { get; set; } = new();
// Standing outside a room, so they hang off the building for the same reason a ladder does.
public List<ArchBalconyPart> Balconies { get; set; } = new();
public List<ArchExteriorStairPart> ExteriorStairs { get; set; } = new();
public bool GuttersEnabled { get; set; } = true;
public float StoreyHeight { get; set; }
public List<ArchFloorCutout> Cutouts { get; set; } = new();
[JsonIgnore]
public override bool HasContent => Roofs.Count > 0 || Downpipes.Count > 0 || Fences.Count > 0 || Platforms.Count > 0
|| Ladders.Count > 0 || Cuts.Count > 0 || Balconies.Count > 0 || ExteriorStairs.Count > 0
|| Pipes.Count > 0 || Brackets.Count > 0
|| Rooms.Any( room => room.HasContent );
}
public sealed class ArchRoom : IArchCollides, IArchPainted, IArchNamed
{
// Overrides the kit for the room's own shell, slab and ceiling. A part standing IN it carries its own.
public ArchCollisionMode? Collision { get; set; }
public int Id { get; set; }
public string Name { get; set; } = "Room";
public int Floor { get; set; }
public float BaseHeight { get; set; }
public float WallHeight { get; set; }
public bool HasFloor { get; set; } = true;
public bool HasCeiling { get; set; } = true;
public float CeilingDepth { get; set; }
public bool FloorBoards { get; set; }
public float FloorBoardYaw { get; set; }
public bool RaisedFoundation { get; set; } = true;
// A link across a gap, carried by its own piers - not an overhang.
public bool Spans { get; set; }
// A rising walkway: the far end's floor height. Equal to BaseHeight on a flat link.
public float WalkwayTop { get; set; }
public WalkwayInterior Interior { get; set; }
// The link draws its own boards so they die square at the mouth corners.
public bool WalkwaySkirting { get; set; } = true;
public ArchPalette Palette { get; set; } = new();
public List<ArchWall> Walls { get; set; } = new();
public List<ArchStairPart> Stairs { get; set; } = new();
public List<ArchTrimPart> Trims { get; set; } = new();
public List<ArchPillarPart> Pillars { get; set; } = new();
// Not "Spans" - that is already the walkway's own flag, and a slot name is a json property.
public List<ArchSpanPart> PierSpans { get; set; } = new();
public List<ArchBeamPart> Beams { get; set; } = new();
public List<ArchPorchPart> Porches { get; set; } = new();
public List<ArchApproachPart> Approaches { get; set; } = new();
public List<Vector2> Footprint { get; set; } = new();
[JsonExtensionData]
public Dictionary<string, JsonElement> Payloads { get; set; } = new();
[JsonIgnore]
public bool HasFootprint => Footprint.Count >= 3;
[JsonIgnore]
public bool HasContent => HasFootprint || Walls.Count > 0 || Stairs.Count > 0 || Pillars.Count > 0
|| Trims.Count > 0 || Porches.Count > 0 || Approaches.Count > 0 || Beams.Count > 0 || PierSpans.Count > 0;
}