Editor/Data/ArchPlan.cs
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";
public List<ArchUnit> Units { get; set; } = new();
// Migration — folded into Units by Normalize.
[JsonPropertyName( "Buildings" )]
public List<ArchBuilding> LegacyBuildings { get; set; }
[JsonPropertyName( "Roads" )]
public List<ArchRoadPart> LegacyRoads { get; set; }
[JsonIgnore]
public IReadOnlyList<ArchBuilding> Buildings => Units.OfType<ArchBuilding>().ToList();
public List<ArchSiteAssembly> Assemblies { get; set; } = new();
public List<ArchAssetInstance> Instances { get; set; } = new();
public List<ArchLayerRecord> Layers { get; set; } = new();
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 ) );
void Adopt() {
if ( LegacyBuildings is { Count: > 0 } ) {
Units.AddRange( LegacyBuildings );
}
if ( LegacyRoads is { Count: > 0 } ) {
Units.AddRange( LegacyRoads );
}
LegacyBuildings = null;
LegacyRoads = null;
}
[JsonIgnore]
public bool HasContent => this.Roads().Count > 0 || Units.Any( unit => unit.HasContent );
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();
var kinds = ArchKinds.Load();
Reserve();
Renumber( kinds );
foreach ( var building in Buildings ) {
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>();
}
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 );
}
foreach ( var fixup in kinds.All.OfType<IArchNormalizes>() ) {
fixup.Normalize( this );
}
}
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.DeriveLandings( 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 );
}
}
}
// Never lowered — deleted ids must not be reused
void Reserve() {
NextId = System.Math.Max( System.Math.Max( 1, NextId ), HighestId() + 1 );
}
void Renumber( ArchKinds kinds ) {
var seen = new HashSet<int>();
var moved = new List<string>();
foreach ( var part in this.Everything( kinds ) ) {
var held = ArchPlanStore.IdOf( part );
if ( held == 0 || seen.Add( held ) ) {
continue;
}
var fresh = AllocateId();
if ( !ArchPlanStore.SetId( part, fresh ) ) {
continue;
}
seen.Add( fresh );
moved.Add( $"{part.GetType().Name} {held} to {fresh}" );
}
if ( moved.Count > 0 ) {
Log.Warning( $"Architecture moved {moved.Count} part(s) off an id another part already held: {string.Join( ", ", moved )}." );
}
}
// Reads the serialized JSON so all id-bearing parts are found regardless of type
int HighestId() {
using var document = JsonDocument.Parse( ArchStorage.Keyed( this ) );
return ArchPlanStore.HighestIdIn( document.RootElement );
}
}
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 );
}
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 );
}
}
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();
}
[JsonConverter( typeof( ArchUnitConverter ) )]
public abstract class ArchUnit : IArchCollides, IArchNamed {
public ArchCollisionMode? Collision { get; set; }
public ArchKind Kind { get; set; }
public int Id { get; set; }
public string Name { get; set; } = "Unit";
public ArchPalette Palette { get; set; } = new();
public List<ArchCutPart> Cuts { get; set; } = new();
// Round-trips unknown module data so plans don't lose fields from uninstalled libraries
[JsonExtensionData]
public Dictionary<string, JsonElement> Payloads { get; set; } = new();
[JsonIgnore]
public abstract bool HasContent { get; }
}
public sealed class ArchOpaqueUnit : ArchUnit {
public override bool HasContent => true;
}
public sealed class ArchBuilding : ArchUnit, IArchPainted {
public ArchBuilding() {
Name = "Building";
Kind = ArchKind.Building;
}
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();
public List<ArchPipePart> Pipes { get; set; } = new();
public List<ArchPipeBracketPart> Brackets { get; set; } = new();
public List<ArchFencePart> Fences { get; set; } = new();
public List<ArchPlatformPart> Platforms { get; set; } = new();
public List<ArchLadderPart> Ladders { get; set; } = new();
public List<ArchBalconyPart> Balconies { get; set; } = new();
public List<ArchExteriorStairPart> ExteriorStairs { get; set; } = new();
public bool GuttersEnabled { get; set; } = true;
public float Facing { get; set; }
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 {
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 bool Facade { get; set; }
public float CeilingDepth { get; set; }
public bool FloorBoards { get; set; }
public float FloorBoardYaw { get; set; }
public bool RaisedFoundation { get; set; } = true;
public bool Spans { get; set; }
public bool OverhangPosts { get; set; }
public ArchPillarPart OverhangPost { get; set; } = new();
public float WalkwayTop { get; set; }
public WalkwayInterior Interior { get; set; }
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();
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<ArchCabinetPart> Cabinets { 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
|| Cabinets.Count > 0;
}