Editor/Data/ArchPlan.cs

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.

File Access
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;
}