editor/AdvancedMappingTools/Procedural/District/DistrictContent.cs

Editor-side data model and planner for procedural district generation. Holds the building/bridge/road recipe records, picks building kinds and part settings per plot, splits streets and land around a canal, and either previews the plan into a MeshKit or spawns the resulting GameObjects.

namespace AdvancedMappingTools;
public sealed record DistrictBuildingRecipe( string Id, string Kind, IPartGenerator Generator, GeneratorSettings Settings, Transform World, float Width, float Depth, float Height );
public sealed record DistrictBridgeRecipe( string Id, DistrictBridgeSettings Settings, Transform World );
public sealed class DistrictContentPlan
{
	public List<DistrictBuildingRecipe> Buildings { get; } = [];
	public List<DistrictBridgeRecipe> Bridges { get; } = [];
	public List<RoadSettings> Roads { get; } = [];
	public int PartBudget { get; set; } = 16000;
	public DistrictCanalSettings Canal { get; set; }
}

/// <summary>Pure district recipes. Generation never reads or changes the editor scene.</summary>
public static class DistrictContent
{
	static float Dot( Vector2 a, Vector2 b ) => a.x * b.x + a.y * b.y;
	static Vector3 V3( Vector2 p, float z ) => new( p.x, p.y, z );
	public static List<Vector2> Strip( Vector2 center, Vector2 u, Vector2 v, float lo, float hi, float reach ) => [center - u * reach + v * lo, center + u * reach + v * lo, center + u * reach + v * hi, center - u * reach + v * hi];
	public static (Vector2 Center, Vector2 U, Vector2 V, float Reach) CanalFrame( DistrictEditor e, IReadOnlyList<Vector2> outline )
	{
		var a = (e.GridAngle + e.CanalAngle).DegreeToRadian();
		var u = new Vector2( MathF.Cos( a ), MathF.Sin( a ) );
		var v = new Vector2( -u.y, u.x );
		var center = outline.Aggregate( Vector2.Zero, ( s, p ) => s + p ) / outline.Count + v * e.CanalOffset;
		return (center, u, v, outline.Max( p => (p - center).Length ) + MathF.Max( e.CanalWidth, 16 ) + 64);
	}

	public static List<IReadOnlyList<Vector2>> Land( DistrictEditor e, IReadOnlyList<Vector2> ring, IReadOnlyList<Vector2> outline )
	{
		if ( !e.AddCanal )
			return[ring];
		var f = CanalFrame( e, outline );
		var half = MathF.Max( e.CanalWidth, 16 ) * .5f;
		return new[]
		{
			DistrictLayout.Clip( ring, Strip( f.Center, f.U, f.V, -f.Reach, -half, f.Reach ) ),
			DistrictLayout.Clip( ring, Strip( f.Center, f.U, f.V, half, f.Reach, f.Reach ) )
		}.Where( p => p.Count >= 3 && DistrictLayout.Area( p ) > 1 ).Cast<IReadOnlyList<Vector2>>().ToList();
	}

	static (float From, float To)? WaterInterval( Vector2 a, Vector2 b, Vector2 center, Vector2 normal, float half )
	{
		var start = Dot( a - center, normal );
		var d = Dot( b - a, normal );
		if ( MathF.Abs( d ) < .0001f )
			return MathF.Abs( start ) < half ? (0f, 1f) : null;
		var x = (-half - start) / d;
		var y = (half - start) / d;
		var lo = MathF.Max( 0, MathF.Min( x, y ) );
		var hi = MathF.Min( 1, MathF.Max( x, y ) );
		return hi > lo + .00001f ? (lo, hi) : null;
	}

	static float Roll( int seed, string id ) => new SeedStream( StableSeed.Of( seed, id ) ).Next();
	static int Floors( int seed, string id, int min, int max, int limit )
	{
		var lo = Math.Clamp( Math.Min( min, max ), 1, limit );
		var hi = Math.Clamp( Math.Max( min, max ), lo, limit );
		return Math.Min( hi, lo + (int)(Roll( seed, id ) * (hi - lo + 1)) );
	}

	static string Key( Vector2 p ) => FormattableString.Invariant( $"{p.x:0.00}/{p.y:0.00}" );
	static string Kind( DistrictEditor e, DistrictPlot plot, string id )
	{
		if ( e.BuildingMix == WorldBuildingMix.NewYork )
		{
			var tower = Math.Clamp( e.NewYorkTowerShare, 0, 1 );
			var brown = Math.Clamp( e.NewYorkBrownstoneShare, 0, 1 );
			var mid = MathF.Max( 0, 1 - tower - brown );
			var nycPick = Roll( e.Seed, id + "/nyc" ) * (tower + brown + mid);
			return nycPick < tower ? "NYC Tower" : nycPick < tower + brown ? "Brownstone" : "NYC Midrise";
		}

		if ( e.BuildingMix == WorldBuildingMix.Houses )
			return "House";
		if ( e.BuildingMix == WorldBuildingMix.HighRises )
			return "HighRise";
		if ( e.BuildingMix == WorldBuildingMix.DistrictStyle )
			return plot.Use == PlotUse.Shop ? "House" : plot.Use.ToString();
		var weights = e.BuildingMix == WorldBuildingMix.Custom ? new[]
		{
			e.HouseWeight,
			e.ApartmentWeight,
			e.TowerWeight,
			e.WarehouseWeight
		}

		: new[]
		{
			40f,
			25f,
			15f,
			5f
		};
		var total = weights.Sum( w => MathF.Max( w, 0 ) );
		if ( total <= 0 )
			return "Green";
		var pick = Roll( e.Seed, id + "/kind" ) * total;
		var names = new[]
		{
			"House",
			"Apartment",
			"HighRise",
			"Warehouse"
		};
		for ( var i = 0; i < weights.Length; i++ )
		{
			pick -= MathF.Max( weights[i], 0 );
			if ( pick < 0 )
				return names[i];
		}

		return "House";
	}

	public static DistrictContentPlan Plan( DistrictEditor e, DistrictPlan layout, IReadOnlyList<Vector2> outline, float z )
	{
		var result = new DistrictContentPlan
		{
			PartBudget = Math.Clamp( e.BuildingPartBudget, 1000, 100000 )
		};
		if ( outline.Count < 3 )
			return result;
		var f = CanalFrame( e, outline );
		var half = MathF.Max( e.CanalWidth, 16 ) * .5f;
		if ( e.AddCanal )
			result.Canal = new()
			{
				Outline = outline.ToList(),
				Center = f.Center,
				Width = MathF.Max( e.CanalWidth, 16 ),
				Depth = MathF.Max( e.CanalDepth, 16 ),
				Angle = e.GridAngle + e.CanalAngle,
				BaseHeight = z,
				WaterMaterial = e.WaterMaterial?.ResourcePath,
				BankMaterial = e.Trim?.ResourcePath ?? e.ContentSurface.ResourcePath
			};
		var overpassPlan = DistrictOverpass.Plan( e, layout, outline, z );
		var overpass = overpassPlan?.Bridge;
		if ( overpass != null )
			result.Bridges.Add( overpass );
		foreach ( var street in layout.Streets.OrderBy( s => Key( s.A ) ).ThenBy( s => Key( s.B ) ) )
		{
			var id = Key( street.A ) + "-" + Key( street.B );
			var axis = new Vector2( -MathF.Sin( e.GridAngle.DegreeToRadian() ), MathF.Cos( e.GridAngle.DegreeToRadian() ) );
			var main = !e.VaryStreets || (e.BuildingMix == WorldBuildingMix.NewYork ? MathF.Abs( Dot( (street.B - street.A).Normal, axis ) ) > .7f : Roll( e.Seed, "road/" + id ) < Math.Clamp( e.MainStreetShare, 0, 1 ));
			var width = MathF.Max( 64, e.StreetWidth * (main ? 1 : Math.Clamp( e.SideStreetWidth, .4f, 1 )) );
			var type = main ? e.RoadType : e.SideStreetType;
			var bend = Math.Clamp( e.StreetBend, 0, .08f ) * e.StreetWidth * (Roll( e.Seed, "bend/" + id ) * 2 - 1);
			width = MathF.Max( 48, width - MathF.Abs( bend ) * 2 );
			void Road( float lo, float hi )
			{
				if ( (hi - lo) * (street.B - street.A).Length < 16 )
					return;
				var a = street.A + (street.B - street.A) * lo;
				var b = street.A + (street.B - street.A) * hi;
				var road = DistrictEditor.RoadFor( type, width );
				// A narrower side street keeps its narrower carriageway, but a verge on both
				// sides fills the street up to the blocks, so no gap opens beside it.
				var spare = e.StreetWidth - MathF.Abs( bend ) * 2 - DistrictEditor.FullWidth( road );
				if ( spare > 0.5f )
					road.VergeWidth += spare * 0.5f;
				var d = (b - a).Normal;
				var side = new Vector2( -d.y, d.x );
				var bow = (hi - lo) * bend;
				road.Path = MathF.Abs( bow ) < .01f ? PathCurve.FromPoints( [V3( a, z ), V3( b, z )] ) : PathCurve.FromPoints( [V3( a, z ), V3( (a + b) * .5f + side * bow, z ), V3( b, z )], PathInterpolation.CatmullRom );
				if ( e.AddCanal && road.Path.Sample( 32, 180 ).Any( p => MathF.Abs( Dot( new Vector2( p.Position.x, p.Position.y ) - f.Center, f.V ) ) < half - .01f ) )
					road.Path = PathCurve.FromPoints( [V3( a, z ), V3( b, z )] );
				road.Seeds.Master = StableSeed.Of( e.Seed, "road/" + id );
				result.Roads.Add( road );
			}

			void Segment( float lo, float hi )
			{
				if ( hi <= lo + .0001f )
					return;
				var wet = e.AddCanal ? WaterInterval( street.A, street.B, f.Center, f.V, half ) : null;
				if ( wet is { } interval )
				{
					var start = MathF.Max( lo, interval.From );
					var end = MathF.Min( hi, interval.To );
					if ( end <= start )
					{
						Road( lo, hi );
						return;
					}

					Road( lo, start );
					Road( end, hi );
					if ( MathF.Abs( Dot( (street.B - street.A).Normal, f.V ) ) > .1f )
					{
						var a = street.A + (street.B - street.A) * start;
						var b = street.A + (street.B - street.A) * end;
						var d = b - a;
						if ( d.Length >= 4 )
						{
							var bs = new DistrictBridgeSettings
							{
								Length = d.Length,
								Width = width,
								Rise = MathF.Max( e.CanalBridgeRise, 0 ),
								Style = e.BridgeStyle,
								RailingHeight = e.BridgeRailingHeight,
								WalkwayWidth = e.BridgeWalkwayWidth,
								PostSpacing = e.BridgePostSpacing,
								Thickness = e.BridgeThickness,
								BankDepth = e.CanalDepth,
								Material = e.BridgeMaterial?.ResourcePath ?? e.Trim?.ResourcePath,
								SurfaceMaterial = e.BridgeRoadMaterial?.ResourcePath
							};
							result.Bridges.Add( new( "canal/" + id, bs, new Transform( V3( a, z ), Rotation.FromYaw( MathF.Atan2( d.y, d.x ).RadianToDegree() ) ) ) );
						}
					}
				}
				else
					Road( lo, hi );
			}

			if ( overpassPlan != null && overpassPlan.Removed.TryGetValue( DistrictOverpass.Key( street ), out var removed ) )
			{
				Segment( 0, removed.From );
				Segment( removed.To, 1 );
			}
			else
				Segment( 0, 1 );
		}

		if ( overpass != null )
			DistrictOverpass.ProtectSupports( overpass, result.Roads, result.Canal );
		if ( !e.PlaceBuildings )
			return result;
		foreach ( var plot in layout.Plots.OrderBy( p => StableSeed.Of( e.Seed, "plot/" + Key( p.Front ) ) ) )
		{
			if ( result.Buildings.Count >= Math.Clamp( e.BuildingLimit, 1, 256 ) )
				break;
			var id = "plot/" + Key( plot.Front );
			if ( Roll( e.Seed, id + "/density" ) >= Math.Clamp( e.BuildingDensity, 0, 1 ) )
				continue;
			var kind = Kind( e, plot, id );
			if ( kind is "Green" or "Parking" )
				continue;
			var setback = MathF.Max( e.Setback, e.BuildingBalconies ? 48 : 4 );
			var w = (plot.Frontage - setback * 2) * Math.Clamp( e.PlotCoverage, .2f, .99f );
			var d = (plot.Depth - setback * 2) * Math.Clamp( e.PlotCoverage, .2f, .99f );
			if ( w < 96 || d < 96 )
				continue;
			var inward = -plot.Facing;
			var right = new Vector2( -plot.Facing.y, plot.Facing.x );
			var front = plot.Front + inward * (plot.Depth - d) * .5f;
			var corner = front - right * w * .5f;
			List<Vector2> Foot() => [corner, corner + right * w, corner + right * w + inward * d, corner + inward * d];
			for ( var attempt = 0; attempt < 12 && Foot().Any( p => !DistrictLayout.Contains( plot.Ring, p ) ); attempt++ )
			{
				w *= .9f;
				d *= .9f;
				front = plot.Front + inward * (plot.Depth - d) * .5f;
				corner = front - right * w * .5f;
			}

			var footprint = Foot();
			if ( w < 96 || d < 96 || footprint.Any( p => !DistrictLayout.Contains( plot.Ring, p ) ) )
				continue;
			if ( e.AddCanal && footprint.Min( p => Dot( p - f.Center, f.V ) ) < half + (e.BuildingBalconies ? 48 : 24) && footprint.Max( p => Dot( p - f.Center, f.V ) ) > -half - (e.BuildingBalconies ? 48 : 24) )
				continue;
			if ( overpass != null && DistrictOverpass.NearBuilding( overpass, footprint.Aggregate( Vector2.Zero, ( s, p ) => s + p ) / 4, MathF.Sqrt( w * w + d * d ) * .5f ) )
				continue;
			var storey = Math.Clamp( e.BuildingStoreyHeight, 80, 256 );
			var wall = e.BuildingWallMaterial?.ResourcePath ?? e.ContentSurface.ResourcePath;
			var trim = e.Trim?.ResourcePath ?? wall;
			var roof = e.BuildingRoofMaterial?.ResourcePath ?? trim;
			var glass = e.BuildingGlassMaterial?.ResourcePath;
			var seed = StableSeed.Of( e.Seed, id );
			IPartGenerator generator;
			GeneratorSettings settings;
			float height;
			if ( e.BuildingMix == WorldBuildingMix.NewYork )
			{
				var brown = kind == "Brownstone";
				var tower = kind == "NYC Tower";
				var floors = tower ? Floors( e.Seed, id, e.TowerFloorsMin, e.TowerFloorsMax, 64 ) : brown ? Floors( e.Seed, id, e.HouseFloorsMin, e.HouseFloorsMax, 16 ) : Floors( e.Seed, id, e.ApartmentFloorsMin, e.ApartmentFloorsMax, 32 );
				Color[] palette = brown ? [new( .38f, .18f, .11f ), new( .48f, .25f, .15f ), new( .51f, .34f, .23f )] : tower ? [new( .45f, .51f, .56f ), new( .58f, .55f, .47f ), new( .3f, .39f, .45f )] : [new( .58f, .49f, .36f ), new( .46f, .24f, .16f ), new( .65f, .61f, .52f ), new( .46f, .43f, .38f )];
				var color = palette[Math.Min( palette.Length - 1, (int)(Roll( e.Seed, id + "/color" ) * palette.Length) )];
				var ny = new NewYorkBuildingSettings
				{
					Style = brown ? NewYorkBuildingStyle.Brownstone : tower ? NewYorkBuildingStyle.SetbackTower : NewYorkBuildingStyle.Midrise,
					Width = w,
					Depth = d,
					Storeys = floors,
					StoreyHeight = storey,
					Setbacks = e.NewYorkSetbacks,
					WindowFrames = e.NewYorkWindowFrames,
					FireEscape = e.NewYorkFireEscapes && !tower && (brown || Roll( e.Seed, id + "/escape" ) < .4f),
					WaterTank = e.NewYorkWaterTanks && !tower && Roll( e.Seed, id + "/tank" ) < .65f,
					Spire = tower && Roll( e.Seed, id + "/spire" ) < .3f,
					WallMaterial = wall,
					RoofMaterial = roof,
					GlassMaterial = glass,
					WallColor = e.BuildingWallMaterial is null ? color : Color.White
				};
				settings = ny;
				generator = new NewYorkBuildingGenerator();
				height = floors * storey + (ny.Spire ? 240 : ny.WaterTank ? 160 : 52);
			}
			else if ( kind == "House" )
			{
				var floors = Floors( e.Seed, id, e.HouseFloorsMin, e.HouseFloorsMax, 16 );
				var s = new HouseSettings
				{
					Width = w,
					Depth = d,
					Storeys = floors,
					StoreyHeight = storey,
					Foundation = 0,
					Stairs = e.BuildingInteriors,
					Ceilings = e.BuildingInteriors,
					GroundFloor = e.BuildingInteriors,
					RoofShape = e.HouseRoofStyle,
					Overhang = MathF.Min( 16, setback * .5f ),
					WallMaterial = wall,
					TrimMaterial = trim,
					FloorMaterial = trim,
					RoofMaterial = roof,
					GlassMaterial = glass
				};
				settings = s;
				generator = new HouseGenerator();
				height = floors * (storey + s.SlabThickness) + (s.RoofShape == HouseRoof.Flat ? 0 : s.RidgeHeight);
			}
			else if ( kind is "Apartment" or "HighRise" )
			{
				var floors = kind == "HighRise" ? Floors( e.Seed, id, e.TowerFloorsMin, e.TowerFloorsMax, 64 ) : Floors( e.Seed, id, e.ApartmentFloorsMin, e.ApartmentFloorsMax, 32 );
				var s = new ApartmentSettings
				{
					Width = w,
					Depth = d,
					Storeys = floors,
					StoreyHeight = storey,
					Foundation = 0,
					Balconies = e.BuildingBalconies,
					Canopy = false,
					RoofUnits = 0,
					WaterTank = false,
					WallMaterial = wall,
					RoofMaterial = roof,
					TrimMaterial = trim,
					FloorMaterial = trim,
					GlassMaterial = glass
				};
				settings = s;
				generator = new ApartmentGenerator();
				height = floors * (storey + s.SlabThickness) + s.ParapetHeight;
			}
			else
			{
				settings = new WarehouseSettings
				{
					Width = w,
					Depth = d,
					Height = storey * 2,
					LoadingDock = false,
					WallMaterial = wall,
					SteelMaterial = trim,
					RoofMaterial = roof,
					ConcreteMaterial = trim
				};
				generator = new WarehouseGenerator();
				height = storey * 2 + 64;
			}

			settings.Seeds.Master = seed;
			settings.Variation = Math.Clamp( e.BuildingVariation, 0, 1 );
			var pad = DistrictEditor.PadHeight( plot.Use );
			var baseZ = z + MathF.Max( e.CurbHeight, 0 ) + .2f + MathF.Max( pad, 0 );
			result.Buildings.Add( new( id, kind, generator, settings, new Transform( V3( corner, baseZ ), Rotation.FromYaw( MathF.Atan2( right.y, right.x ).RadianToDegree() ) ), w, d, height ) );
		}

		return result;
	}

	public static void Preview( MeshKit kit, DistrictContentPlan plan )
	{
		var frame = kit.Frame;
		var material = kit.Material;
		foreach ( var building in plan.Buildings )
		{
			kit.Frame = building.World;
			kit.Box( Vector3.Zero, new Vector3( building.Width, building.Depth, building.Height ) );
		}

		kit.Frame = Transform.Zero;
		foreach ( var road in plan.Roads )
		{
			var points = road.Path.Sample( 64, 180 ).Select( p => p.Position ).ToArray();
			for ( var i = 0; i + 1 < points.Length; i++ )
				kit.Beam( points[i], points[i + 1], DistrictEditor.FullWidth( road ), 4 );
		}

		foreach ( var bridge in plan.Bridges )
			kit.Mesh.MergeMesh( PartService.Plan( new DistrictBridgeGenerator(), bridge.Settings ).Flatten( bridge.World ), Transform.Zero, out _, out _, out _ );
		if ( plan.Canal != null )
			kit.Mesh.MergeMesh( PartService.Plan( new DistrictCanalGenerator(), plan.Canal ).Flatten( Transform.Zero ), Transform.Zero, out _, out _, out _ );
		kit.Frame = frame;
		kit.Material = material;
	}

	public static List<GameObject> Place( DistrictContentPlan plan )
	{
		var created = new List<GameObject>();
		var parts = 0;
		var skipped = 0;
		foreach ( var b in plan.Buildings )
		{
			var count = PartService.Plan( b.Generator, b.Settings ).All().Count();
			if ( parts + count > plan.PartBudget )
			{
				skipped++;
				continue;
			}

			var root = PartService.Create( b.Generator, b.Settings, b.World );
			root.Name = $"World {b.Kind} {created.Count + 1}";
			created.Add( root );
			parts += count;
		}

		if ( skipped > 0 )
			Log.Warning( $"World Builder: {skipped} buildings skipped at the {plan.PartBudget} part budget. Lower floor counts or raise the budget to fill more plots." );
		foreach ( var bridge in plan.Bridges )
			created.Add( PartService.Create( new DistrictBridgeGenerator(), bridge.Settings, bridge.World ) );
		if ( plan.Canal != null )
		{
			var root = PartService.Create( new DistrictCanalGenerator(), plan.Canal, Transform.Zero );
			foreach ( var p in PartReconciler.Collect( root ).Where( p => p.Kind == "Water" ) )
			{
				var component = p.Components.Get<MeshComponent>();
				if ( component.IsValid() )
					component.Collision = MeshComponent.CollisionType.None;
			}

			created.Add( root );
		}

		return created;
	}
}