Game/CityBuilder.cs

CityBuilder for a driving proving-grounds. Builds a deterministic 10x10 block city at runtime: ground, roads, perimeter wall, and district-specific blocks (suburbs, downtown, industrial, park). Places models when available or uses colored box/sphere fallbacks; creates colliders for walls, sidewalks, poles, planters, ramps.

File Access
namespace VehicleProto;

public enum District
{
	Suburbs,    // NW — pastel houses (vendored village building models)
	Downtown,   // NE — commercial core, mini high-rises
	Industrial, // SE — warehouses, ramps, containers
	Park        // SW — green, trees, lake
}

public class CityLayout
{
	public Vector3 SpawnPosition { get; set; }
	public Rotation SpawnFacing { get; set; } = Rotation.Identity;
}

/// <summary>
/// Builds a deterministic 470 m proving-grounds city block at runtime: a 10×10-block
/// grid, four quadrant districts. Fully deterministic (fixed hash of block/lot indices —
/// no runtime RNG). All building ART is custom, flat-shaded, low-poly: the vendored
/// village building set (<c>models/buildings/</c>) for the residential + industrial districts
/// and the generated mini high-rise set (<c>models/city/</c>, see tools/gen_buildings.py)
/// for the downtown commercial core. Colored dev-box fallbacks stand in if a .vmdl is
/// missing, so the map is always drivable. The drivable area is fully enclosed by solid
/// perimeter walls (no car can leave the world at any speed it reaches).
/// </summary>
public static class CityBuilder
{
	const float M = Units.MetersToUnits;

	public const int GridBlocks = 10;
	public const float BlockSize = 36f;  // m
	public const float RoadWidth = 10f;  // m
	public const float Cell = BlockSize + RoadWidth;
	public const float Total = GridBlocks * Cell + RoadWidth; // 470 m
	public const float Origin = -Total * 0.5f;                // map centered on world origin

	static readonly Color Asphalt = new( 0.30f, 0.30f, 0.33f );
	static readonly Color ParkPath = new( 0.72f, 0.66f, 0.52f );
	static readonly Color Sidewalk = new( 0.62f, 0.62f, 0.64f );
	static readonly Color Grass = new( 0.55f, 0.72f, 0.45f );

	static readonly Color[] SuburbPalette =
	{
		new( 0.93f, 0.87f, 0.78f ), new( 0.85f, 0.62f, 0.52f ), new( 0.72f, 0.80f, 0.68f ),
		new( 0.80f, 0.76f, 0.85f ), new( 0.95f, 0.80f, 0.62f ),
	};

	static readonly Color[] DowntownPalette =
	{
		new( 0.55f, 0.65f, 0.75f ), new( 0.75f, 0.72f, 0.68f ), new( 0.45f, 0.52f, 0.62f ),
		new( 0.82f, 0.78f, 0.72f ), new( 0.60f, 0.70f, 0.72f ),
	};

	static readonly Color[] IndustrialPalette =
	{
		new( 0.62f, 0.48f, 0.40f ), new( 0.55f, 0.55f, 0.52f ), new( 0.70f, 0.45f, 0.35f ),
		new( 0.50f, 0.56f, 0.58f ),
	};

	// Residential / village models — vendored village set (retires the Kenney suburban kit).
	// Placed at NATURAL scale (they are authored at real metres); box fallback if missing.
	static readonly string[] SuburbModels =
	{
		"models/buildings/house_small.vmdl",
		"models/buildings/house_medium.vmdl",
		"models/buildings/house_large.vmdl",
		"models/buildings/inn.vmdl",
		"models/buildings/shop.vmdl",
		"models/buildings/civic_hall.vmdl",
	};

	// Downtown commercial core — generated art (tools/gen_buildings.py), placed at natural
	// scale. Each block is PACKED with a couple of structures (pre-launch note: not one
	// lonely tower per block) — one tall anchor tower + 1-3 smaller commercial fill buildings,
	// arranged into block quadrants by deterministic hash. Box fallbacks if a vmdl is missing.
	//
	// Tower anchors: the pack subset that fits a quadrant with a comfortable gap (highrise_d
	// at 18 m wide is too broad for packing, so it is left out of downtown).
	static readonly string[] DowntownTowers =
	{
		"models/city/highrise_a.vmdl",
		"models/city/highrise_b.vmdl",
		"models/city/highrise_c.vmdl",
		"models/city/highrise_e.vmdl",
	};

	// Small commercial fill (1-3 storeys) — storefronts/offices that sit beside the towers.
	static readonly string[] CommercialModels =
	{
		"models/city/shop_a.vmdl",
		"models/city/shop_b.vmdl",
		"models/city/shop_c.vmdl",
		"models/city/shop_d.vmdl",
	};

	// Industrial district — vendored work buildings (natural scale).
	static readonly string[] IndustrialModels =
	{
		"models/buildings/warehouse.vmdl",
		"models/buildings/workshop.vmdl",
		"models/buildings/barn.vmdl",
	};

	static Scene _scene;
	static GameObject _cityRoot;

	public static CityLayout Build( Scene scene, int seed = 1971 )
	{
		_scene = scene;
		_cityRoot = scene.CreateObject();
		_cityRoot.Name = "City";

		var layout = new CityLayout();

		BuildGround();
		BuildRoads();
		BuildPerimeterWall();

		for ( int bx = 0; bx < GridBlocks; bx++ )
		for ( int by = 0; by < GridBlocks; by++ )
		{
			var district = DistrictOf( bx, by );
			var blockMin = new Vector2( Origin + RoadWidth + bx * Cell, Origin + RoadWidth + by * Cell );

			switch ( district )
			{
				case District.Suburbs: BuildSuburbBlock( blockMin, bx, by ); break;
				case District.Downtown: BuildDowntownBlock( blockMin, bx, by ); break;
				case District.Industrial: BuildIndustrialBlock( blockMin, bx, by ); break;
				case District.Park: BuildParkBlock( blockMin, bx, by ); break;
			}
		}

		// street dressing along the two main avenues (pre-launch "spruce-ups")
		BuildCrosswalks();
		BuildStreetlights();

		// spawn: the central intersection, facing east down the main avenue
		layout.SpawnPosition = new Vector3( 0f, 0f, 0f );
		layout.SpawnFacing = Rotation.Identity;

		Log.Info( $"[vp] city built: {GridBlocks}x{GridBlocks} blocks, {Total:F0}m span" );
		return layout;
	}

	static District DistrictOf( int bx, int by )
	{
		bool west = bx < GridBlocks / 2;
		bool north = by >= GridBlocks / 2;
		return north
			? west ? District.Suburbs : District.Downtown
			: west ? District.Park : District.Industrial;
	}

	// Deterministic per-block hash (FNV-1a + an avalanche finalizer). No System.Random —
	// the whole city (and, in the wider stack, the networking contract) requires that the
	// same block coords always produce the same layout. `salt` mints an independent stream
	// per decision (which quadrant is the tower, fill count, model pick, yaw), so no two
	// derived values correlate. Returns a well-mixed uint the callers reduce with % / bits.
	static uint Hash( int a, int b, uint salt )
	{
		unchecked
		{
			uint h = 2166136261u;
			h = (h ^ (uint)a) * 16777619u;
			h = (h ^ (uint)b) * 16777619u;
			h = (h ^ salt) * 16777619u;
			h ^= h >> 15;
			h *= 0x2c1b3c6du;
			h ^= h >> 12;
			return h;
		}
	}

	// ---------------------------------------------------------------- terrain & roads

	static void BuildGround()
	{
		// one thick slab under everything — hard landings tunnel through thin colliders
		var go = Child( "Ground" );

		var renderer = go.Components.Create<ModelRenderer>();
		renderer.MaterialOverride = Material.Load( "materials/default.vmat" );
		renderer.Model = Model.Load( "models/dev/plane.vmdl" );
		renderer.Tint = Grass;
		go.LocalScale = new Vector3( Total * 1.2f * M / 100f, Total * 1.2f * M / 100f, 1f );

		var collider = go.Components.Create<BoxCollider>();
		collider.Scale = new Vector3( 100f, 100f, 200f );
		collider.Center = Vector3.Down * 100f;
		collider.Static = true;

		go.Tags.Add( "road" );
	}

	static void BuildRoads()
	{
		// full-length strips both ways; park quadrant gets path-colored overlay strips
		for ( int i = 0; i <= GridBlocks; i++ )
		{
			float offset = Origin + i * Cell + RoadWidth * 0.5f;
			Block( new Vector3( offset, 0f, 0.01f ) * M, new Vector3( RoadWidth, Total, 0.02f ), Asphalt, collide: false, name: "Road NS" );
			Block( new Vector3( 0f, offset, 0.01f ) * M, new Vector3( Total, RoadWidth, 0.02f ), Asphalt, collide: false, name: "Road EW" );
		}

		// dashed center line on the two main avenues through the origin
		for ( int i = 0; i < 46; i++ )
		{
			float t = Origin + 4f + i * (Total / 46f);
			Block( new Vector3( t, 0f, 0.02f ) * M, new Vector3( 2.2f, 0.25f, 0.02f ), new Color( 0.9f, 0.85f, 0.55f ), collide: false, name: "Lane" );
			Block( new Vector3( 0f, t, 0.02f ) * M, new Vector3( 0.25f, 2.2f, 0.02f ), new Color( 0.9f, 0.85f, 0.55f ), collide: false, name: "Lane" );
		}
	}

	// Solid perimeter wall enclosing the whole drivable city (the world
	// must NOT be drivable-off). 8 m tall, 2 m thick, static colliders — well beyond any
	// jump the cars can make, so no vehicle leaves the map at any speed it reaches. Sits
	// just outside the outermost ring road; deterministic (crash-wall collider precedent).
	const float WallHeight = 8f;   // m
	const float WallThick = 2f;    // m

	static void BuildPerimeterWall()
	{
		float edge = -Origin + RoadWidth * 0.5f + 2f; // just outside the outermost road
		var wall = new Color( 0.52f, 0.52f, 0.55f );
		var cap = new Color( 0.40f, 0.40f, 0.44f );    // contrasting coping so the wall reads
		float len = Total + WallThick * 2f;

		foreach ( var (pos, size, capPos, capSize, nm) in new[]
		{
			( new Vector3( 0f, edge, WallHeight * 0.5f ), new Vector3( len, WallThick, WallHeight ),
			  new Vector3( 0f, edge, WallHeight + 0.15f ), new Vector3( len, WallThick + 0.5f, 0.3f ), "Perimeter Wall N" ),
			( new Vector3( 0f, -edge, WallHeight * 0.5f ), new Vector3( len, WallThick, WallHeight ),
			  new Vector3( 0f, -edge, WallHeight + 0.15f ), new Vector3( len, WallThick + 0.5f, 0.3f ), "Perimeter Wall S" ),
			( new Vector3( edge, 0f, WallHeight * 0.5f ), new Vector3( WallThick, len, WallHeight ),
			  new Vector3( edge, 0f, WallHeight + 0.15f ), new Vector3( WallThick + 0.5f, len, 0.3f ), "Perimeter Wall E" ),
			( new Vector3( -edge, 0f, WallHeight * 0.5f ), new Vector3( WallThick, len, WallHeight ),
			  new Vector3( -edge, 0f, WallHeight + 0.15f ), new Vector3( WallThick + 0.5f, len, 0.3f ), "Perimeter Wall W" ),
		} )
		{
			Block( pos * M, size, wall, collide: true, name: nm );
			Block( capPos * M, capSize, cap, collide: false, name: nm + " Cap" );
		}
	}

	// ---------------------------------------------------------------- district blocks

	static void BuildSuburbBlock( Vector2 min, int bx, int by )
	{
		SidewalkApron( min );

		// 2×2 house lots; vendored village models at natural scale, box fallback.
		for ( int lot = 0; lot < 4; lot++ )
		{
			float lotX = min.x + (lot % 2 == 0 ? 9.5f : BlockSize - 9.5f);
			float lotY = min.y + (lot < 2 ? 9.5f : BlockSize - 9.5f);
			int seed = bx * 7 + by * 13 + lot * 29;

			string model = SuburbModels[seed % SuburbModels.Length];
			float yaw = lot % 2 == 0 ? 90f : -90f; // face the nearest N-S street
			if ( !PlaceModel( model, new Vector2( lotX, lotY ), yaw, targetHeight: 0f ) )
			{
				Block( new Vector3( lotX, lotY, 3f ) * M, new Vector3( 10f, 9f, 6f ),
					SuburbPalette[seed % SuburbPalette.Length] );
			}
		}

		// a lawn tree on one corner
		if ( (bx + by) % 2 == 0 )
			Tree( min + new Vector2( BlockSize - 5f, 5f ) );
	}

	// Quadrant offset of the four building pads inside a block (block-local metres from the
	// block centre). qc keeps even the widest packed footprint (~7 m half-extent) inside the
	// block with a sidewalk margin, and adjacent pads 2*qc apart leave a 3-5 m gap between
	// buildings (no touching, no z-fight).
	const float QuadOffset = 8.5f;
	static readonly Vector2[] Quadrants =
	{
		new( -QuadOffset, -QuadOffset ), new( QuadOffset, -QuadOffset ),
		new( -QuadOffset, QuadOffset ),  new( QuadOffset, QuadOffset ),
	};

	static void BuildDowntownBlock( Vector2 min, int bx, int by )
	{
		SidewalkApron( min );

		// Pack the block: one tall anchor tower + 1-3 smaller commercial buildings, one per
		// quadrant, so a block reads as a cluster, not a lonely centred tower.
		// Everything is deterministic hash-of-block-coords, so adjacent blocks differ (tower
		// position, fill count, models, yaws) but a regen is identical.
		var centre = new Vector2( min.x + BlockSize * 0.5f, min.y + BlockSize * 0.5f );
		int towerQuad = (int)(Hash( bx, by, 0x01 ) % 4);
		int fillCount = 1 + (int)(Hash( bx, by, 0x02 ) % 3);      // 1-3 shops -> 2-4 structures
		int startQuad = (int)(Hash( bx, by, 0x03 ) % 4);          // rotate fill order so empties vary

		int filled = 0;
		for ( int i = 0; i < 4; i++ )
		{
			int q = (startQuad + i) % 4;
			var at = centre + Quadrants[q];

			if ( q == towerQuad )
			{
				PlaceTower( at, Hash( bx, by, 0x10u + (uint)q ) );
			}
			else if ( filled < fillCount )
			{
				filled++;
				PlaceShop( at, Hash( bx, by, 0x20u + (uint)q ) );
			}
			else
			{
				// empty quadrant — dress it as a small painted parking lot
				BuildParkingBay( at, Hash( bx, by, 0x30u + (uint)q ) );
			}
		}

		// two planters at opposite sidewalk mid-edges (clear of the quadrant pads), so a
		// downtown block has a little street furniture even when sparsely built
		uint hp = Hash( bx, by, 0x40 );
		Vector2[] edges = { new( 0f, 14f ), new( 0f, -14f ), new( 14f, 0f ), new( -14f, 0f ) };
		int e0 = (int)(hp & 3);
		int e1 = (e0 + 1 + (int)((hp >> 4) % 3)) & 3; // offset 1-3 -> always a different edge
		Planter( centre + edges[e0] );
		Planter( centre + edges[e1] );
	}

	static void PlaceTower( Vector2 at, uint h )
	{
		string model = DowntownTowers[h % (uint)DowntownTowers.Length];
		float yaw = 90f * ((h >> 3) & 3);
		if ( PlaceModel( model, at, yaw, targetHeight: 0f ) )
			return;
		float ht = 18f + (h % 4) * 4f; // 18-30 m box-tower fallback
		var c = DowntownPalette[h % (uint)DowntownPalette.Length];
		Block( new Vector3( at.x, at.y, ht * 0.5f ) * M, new Vector3( 12f, 12f, ht ), c, name: "Tower" );
		Block( new Vector3( at.x, at.y, ht + 0.4f ) * M, new Vector3( 9f, 9f, 0.8f ), c.Darken( 0.3f ) );
	}

	static void PlaceShop( Vector2 at, uint h )
	{
		string model = CommercialModels[h % (uint)CommercialModels.Length];
		float yaw = 90f * ((h >> 3) & 3);
		if ( PlaceModel( model, at, yaw, targetHeight: 0f ) )
			return;
		float ht = 3.5f + (h % 3) * 3.3f; // ~3.5-10 m box-shop fallback
		var c = DowntownPalette[(h >> 2) % (uint)DowntownPalette.Length];
		Block( new Vector3( at.x, at.y, ht * 0.5f ) * M, new Vector3( 10f, 9f, ht ), c, name: "Shop" );
		Block( new Vector3( at.x, at.y, ht + 0.3f ) * M, new Vector3( 8f, 7f, 0.6f ), c.Darken( 0.25f ) );
	}

	static void BuildIndustrialBlock( Vector2 min, int bx, int by )
	{
		SidewalkApron( min );
		int seed = bx * 5 + by * 19;

		if ( seed % 4 == 0 )
		{
			// open container yard with a ramp — the district's "jump here" invitation
			Ramp( min + new Vector2( BlockSize * 0.5f, BlockSize * 0.35f ), 14f );
			for ( int c = 0; c < 3; c++ )
			{
				var color = IndustrialPalette[(seed + c) % IndustrialPalette.Length];
				Block( new Vector3( min.x + 6f + c * 4.5f, min.y + BlockSize - 8f, 1.3f ) * M,
					new Vector3( 4f, 10f, 2.6f ), color, name: "Container" );
			}
		}
		else
		{
			// one work building (natural scale), box-warehouse fallback
			var centre = new Vector2( min.x + BlockSize * 0.5f, min.y + BlockSize * 0.5f );
			string model = IndustrialModels[seed % IndustrialModels.Length];
			float yaw = 90f * (seed % 4);
			if ( !PlaceModel( model, centre, yaw, targetHeight: 0f ) )
			{
				var color = IndustrialPalette[seed % IndustrialPalette.Length];
				float h = 8f + seed % 3 * 2f;
				Block( new Vector3( centre.x, centre.y, h * 0.5f ) * M,
					new Vector3( BlockSize - 10f, BlockSize - 14f, h ), color, name: "Warehouse" );
				Block( new Vector3( centre.x, centre.y, h + 0.6f ) * M,
					new Vector3( BlockSize - 16f, BlockSize - 20f, 1.2f ), color.Darken( 0.25f ) );
			}
		}
	}

	static void BuildParkBlock( Vector2 min, int bx, int by )
	{
		// no sidewalk — grass runs to the road; scattered trees + a diagonal path
		int seed = bx * 3 + by * 7;
		for ( int t = 0; t < 4; t++ )
		{
			float tx = min.x + 5f + (seed * 31 + t * 41) % (int)(BlockSize - 10f);
			float ty = min.y + 5f + (seed * 17 + t * 53) % (int)(BlockSize - 10f);
			Tree( new Vector2( tx, ty ) );
		}

		// drivable diagonal shortcut path
		if ( (bx + by) % 2 == 1 )
		{
			var go = Block( new Vector3( min.x + BlockSize * 0.5f, min.y + BlockSize * 0.5f, 0.015f ) * M,
				new Vector3( BlockSize * 1.3f, 4.5f, 0.02f ), ParkPath, collide: false, name: "Path" );
			go.WorldRotation = Rotation.FromYaw( 45f );
		}
	}

	// ---------------------------------------------------------------- pieces

	// Raised kerbed sidewalk (pre-launch note: "sidewalks with a subtle height
	// difference so people can get a sense for driving on different things"). The whole
	// block pad sits KerbHeight proud of the road with a STATIC collider, so driving off
	// a road onto any block gives a real-but-gentle kerb bump at any speed. Sharp box
	// steps are harder to climb than the height alone suggests (raycast wheels hit the
	// vertical face) — 0.12 m hung every class in free drive (2026-07-14 telemetry:
	// Sidewalk Fz spikes + miss wheels at ~(88,99)/(144,94); kart travel was only
	// 0.12 m and hatch GroundClearance 0.12 m). 0.06 m stays visibly proud, under
	// kart clearance, and well under street-car travel (roster +2 cm clearance pass).
	// The pad is thick (extends DOWN into the ground slab) so hard landings can't tunnel
	// it; only the top KerbHeight shows above the road. Buildings rest at ground z=0 and
	// visually emerge THROUGH the pad top (their sunk base is occluded by the pad), so no
	// lift/z-fight bookkeeping is needed. Park blocks skip this (grass runs to the road).
	const float KerbHeight = 0.06f; // m proud of the road (was 0.12 — hung cars)
	const float PadThick = 0.5f;    // m collider depth; only the top KerbHeight is visible

	static void SidewalkApron( Vector2 min )
	{
		float cz = KerbHeight - PadThick * 0.5f; // centre so the TOP lands at KerbHeight
		Block( new Vector3( min.x + BlockSize * 0.5f, min.y + BlockSize * 0.5f, cz ) * M,
			new Vector3( BlockSize, BlockSize, PadThick ), Sidewalk, collide: true, name: "Sidewalk" );
	}

	// ---------------------------------------------------------------- street dressing
	// Cheap, deterministic, in-style details that earn their keep at driving speed. All are
	// flat paint boxes (collide:false, just proud of their surface so no z-fight) EXCEPT the
	// streetlight poles and planters, which carry colliders so hitting one is a real event.

	static readonly Color CrosswalkPaint = new( 0.88f, 0.88f, 0.84f );

	// Zebra crossings on the two main avenues (through the origin), at every cross-street.
	// The player is on these avenues most, so the paint reads constantly.
	static void BuildCrosswalks()
	{
		for ( int i = 0; i <= GridBlocks; i++ )
		{
			float cross = Origin + i * Cell + RoadWidth * 0.5f; // a cross-road centreline
			Zebra( new Vector2( 0f, cross ), acrossX: true );   // across the NS avenue (x=0)
			Zebra( new Vector2( cross, 0f ), acrossX: false );  // across the EW avenue (y=0)
		}
	}

	// Five stripes over a ~3 m-deep crossing spanning most of the 10 m road width. acrossX =
	// stripes run along X (the crossing spans the NS avenue); else along Y.
	static void Zebra( Vector2 at, bool acrossX )
	{
		const int n = 5;
		const float span = 8f, depth = 3f, sw = depth / n * 0.55f; // stripe width
		for ( int s = 0; s < n; s++ )
		{
			float t = -depth * 0.5f + (s + 0.5f) / n * depth;
			var pos = acrossX ? new Vector3( at.x, at.y + t, 0.03f ) : new Vector3( at.x + t, at.y, 0.03f );
			var size = acrossX ? new Vector3( span, sw, 0.02f ) : new Vector3( sw, span, 0.02f );
			Block( pos * M, size, CrosswalkPaint, collide: false, name: "Crosswalk" );
		}
	}

	// Streetlight poles down both sides of each main avenue, one per block. The pole carries
	// a collider ("hitting a pole should be a thing"); the arm + lamp head reach
	// out over the road well above car height and are visual-only.
	static void BuildStreetlights()
	{
		float edge = RoadWidth * 0.5f + 1f; // 1 m onto the sidewalk from the road edge
		for ( int k = 0; k < GridBlocks; k++ )
		{
			float c = Origin + RoadWidth + k * Cell + BlockSize * 0.5f; // ~each block centre
			Streetlight( new Vector2( edge, c ), new Vector2( -2.4f, 0f ) );  // NS avenue, east side
			Streetlight( new Vector2( -edge, c ), new Vector2( 2.4f, 0f ) );  // NS avenue, west side
			Streetlight( new Vector2( c, edge ), new Vector2( 0f, -2.4f ) );  // EW avenue, north side
			Streetlight( new Vector2( c, -edge ), new Vector2( 0f, 2.4f ) );  // EW avenue, south side
		}
	}

	static void Streetlight( Vector2 at, Vector2 arm )
	{
		var poleColor = new Color( 0.24f, 0.25f, 0.27f );
		const float poleH = 6f;
		Block( new Vector3( at.x, at.y, poleH * 0.5f ) * M, new Vector3( 0.18f, 0.18f, poleH ),
			poleColor, collide: true, name: "Streetlight" );

		bool ax = MathF.Abs( arm.x ) > MathF.Abs( arm.y );
		float armLen = ax ? MathF.Abs( arm.x ) : MathF.Abs( arm.y );
		var mid = at + arm * 0.5f;
		var head = at + arm;
		var armSize = ax ? new Vector3( armLen, 0.14f, 0.14f ) : new Vector3( 0.14f, armLen, 0.14f );
		Block( new Vector3( mid.x, mid.y, poleH - 0.2f ) * M, armSize, poleColor, collide: false, name: "Lamp Arm" );
		Block( new Vector3( head.x, head.y, poleH - 0.35f ) * M, new Vector3( 0.7f, 0.4f, 0.25f ),
			new Color( 0.96f, 0.92f, 0.72f ), collide: false, name: "Lamp" );
	}

	// A small concrete planter with a shrub, on a downtown sidewalk. Collidable street
	// furniture — solid, so bumping one on the pavement is a real (small) obstacle.
	static void Planter( Vector2 at )
	{
		Block( new Vector3( at.x, at.y, KerbHeight + 0.25f ) * M, new Vector3( 1.6f, 1.6f, 0.5f ),
			new Color( 0.55f, 0.55f, 0.58f ), collide: true, name: "Planter" );
		Sphere( new Vector3( at.x, at.y, KerbHeight + 0.5f + 0.9f ), 1.9f, new Color( 0.38f, 0.6f, 0.38f ), collide: false );
	}

	// An empty downtown quadrant, dressed as a small painted parking lot: a dark tarmac
	// patch on the raised sidewalk with a few stall lines. Flat, non-collidable (drive over
	// it), sitting just proud of the kerb top so it reads without z-fighting the pad.
	static void BuildParkingBay( Vector2 at, uint h )
	{
		Block( new Vector3( at.x, at.y, KerbHeight + 0.004f ) * M, new Vector3( 11f, 11f, 0.01f ),
			new Color( 0.27f, 0.27f, 0.29f ), collide: false, name: "Parking Lot" );
		bool alongX = (h & 1) == 0; // stall orientation varies per bay
		var line = new Color( 0.82f, 0.82f, 0.78f );
		for ( int s = 0; s <= 3; s++ )
		{
			float t = -4.5f + s * 3f;
			var pos = alongX ? new Vector3( at.x + t, at.y, KerbHeight + 0.007f )
							 : new Vector3( at.x, at.y + t, KerbHeight + 0.007f );
			var size = alongX ? new Vector3( 0.15f, 9f, 0.01f ) : new Vector3( 9f, 0.15f, 0.01f );
			Block( pos * M, size, line, collide: false, name: "Bay Line" );
		}
	}

	static void Tree( Vector2 at )
	{
		Block( new Vector3( at.x, at.y, 0.9f ) * M, new Vector3( 0.28f, 0.28f, 1.8f ), new Color( 0.45f, 0.33f, 0.24f ), name: "Trunk" );
		Sphere( new Vector3( at.x, at.y, 2.6f ), 2.6f, new Color( 0.38f, 0.62f, 0.38f ), collide: false );
	}

	static void Ramp( Vector2 at, float pitchDegrees )
	{
		var size = new Vector3( 12f, 6f, 0.4f );
		var go = Child( "Ramp" );
		go.WorldPosition = new Vector3( at.x, at.y, MathF.Sin( pitchDegrees.DegreeToRadian() ) * size.x * 0.25f ) * M;
		go.WorldRotation = Rotation.FromPitch( -pitchDegrees );
		go.LocalScale = size * M / 50f;

		var renderer = go.Components.Create<ModelRenderer>();
		renderer.MaterialOverride = Material.Load( "materials/default.vmat" );
		renderer.Model = Model.Load( "models/dev/box.vmdl" );
		renderer.Tint = new Color( 0.55f, 0.55f, 0.6f );

		var collider = go.Components.Create<BoxCollider>();
		collider.Scale = new Vector3( 50f, 50f, 50f );
		collider.Static = true;
	}

	static void Sphere( Vector3 positionMeters, float diameterMeters, Color color, bool collide = false )
	{
		var go = Child( "Sphere" );
		go.WorldPosition = positionMeters * M;
		go.LocalScale = Vector3.One * (diameterMeters * M / 100f); // dev sphere ≈ 100 u diameter

		var renderer = go.Components.Create<ModelRenderer>();
		renderer.MaterialOverride = Material.Load( "materials/default.vmat" );
		renderer.Model = Model.Load( "models/dev/sphere.vmdl" );
		renderer.Tint = color;

		if ( collide )
		{
			var collider = go.Components.Create<SphereCollider>();
			collider.Radius = 50f;
			collider.Static = true;
		}
	}

	/// <summary>
	/// Place a real model resting on the ground, with a bounds-fit static box collider
	/// (collider dims are LOCAL — GO scale does the sizing). <paramref name="targetHeight"/>
	/// &gt; 0 auto-scales the model to that height in metres; &lt;= 0 uses the model's NATURAL
	/// scale (the building/high-rise vmdls are authored at real metres). Returns false if the vmdl
	/// isn't available so callers can fall back to boxes.
	/// </summary>
	static bool PlaceModel( string vmdlPath, Vector2 atMeters, float yaw, float targetHeight )
	{
		var model = Model.Load( vmdlPath );
		if ( model is null || model.IsError )
			return false;

		var size = model.Bounds.Size;
		if ( size.z <= 0.01f )
			return false;

		float scale = targetHeight > 0f ? targetHeight * M / size.z : 1f;

		var go = Child( vmdlPath[(vmdlPath.LastIndexOf( '/' ) + 1)..] );
		go.WorldPosition = new Vector3( atMeters.x * M, atMeters.y * M, -model.Bounds.Mins.z * scale );
		go.WorldRotation = Rotation.FromYaw( yaw );
		go.LocalScale = Vector3.One * scale;

		var renderer = go.Components.Create<ModelRenderer>();
		renderer.Model = model;

		var collider = go.Components.Create<BoxCollider>();
		collider.Scale = model.Bounds.Size;
		collider.Center = model.Bounds.Center;
		collider.Static = true;

		return true;
	}

	static GameObject Block( Vector3 position, Vector3 sizeMeters, Color color, bool collide = true, string name = "Block" )
	{
		var go = Child( name );
		go.WorldPosition = position;
		go.LocalScale = sizeMeters * M / 50f;

		var renderer = go.Components.Create<ModelRenderer>();
		renderer.MaterialOverride = Material.Load( "materials/default.vmat" );
		renderer.Model = Model.Load( "models/dev/box.vmdl" );
		renderer.Tint = color;

		if ( collide )
		{
			var collider = go.Components.Create<BoxCollider>();
			collider.Scale = new Vector3( 50f, 50f, 50f );
			collider.Static = true;
		}

		return go;
	}

	static GameObject Child( string name )
	{
		var go = _scene.CreateObject();
		go.Name = name;
		go.SetParent( _cityRoot, true );
		return go;
	}
}