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