World/TestTrack.cs

Scene builder for a vehicle proving-grounds test track. It creates a root GameObject and places ground, skidpad, drag strip, slalom, ramps, banked curve, washboard, hill-grade ladder, low-grip patch, J-turn pad and a reserved crash wall using primitive placement helpers; it also records named station spawn points in meters.

File AccessNative Interop
namespace VehicleProto;

/// <summary>
/// Builds the proving-grounds test-track zone (see docs/proving-grounds.md): skidpad, drag strip + brake
/// zone, slalom, ramp set, banked curve, washboard, hill grade ladder, low-grip patch,
/// J-turn pad, and a reserved crash-wall plot. Built from a test-track spawner idiom and
/// extended with the additional proving-ground stations, using primitive-helper
/// idioms (Block/Ramp/PlaceModel, static box colliders, dev-box models, deterministic
/// layout — no runtime RNG).
///
/// All station math is authored in SI METERS relative to a caller-supplied origin (the
/// track zone sits ~600 m east of world origin, but that offset is a
/// parameter here, never hardcoded). Every GameObject placement funnels through the
/// <see cref="Slab"/>/<see cref="Cone"/> helpers, which are the ONLY two places that
/// apply <c>Units.MetersToUnits</c> — that is the single audited engine-boundary
/// conversion point (the units convention is"Units, axes, and frames": audit EVERY
/// consumer for the missed `* M` trap). <see cref="Stations"/> itself stores SI meters,
/// NOT engine units — the eventual vp_spawn consumer converts at ITS own placement call.
///
/// UNVERIFIED APIs used here (this file was written without a compiler — the csproj and
/// VehicleProto namespace landed concurrently). See the "Uncertain
/// APIs" list at the top of docs/proving-grounds.md for the full rundown and what to check
/// at integration.
/// </summary>
public static class TestTrack
{
	const float M = Units.MetersToUnits;

	// rough triangle-count estimates for the census log — NOT exact, just order-of-magnitude
	// (dev box = 12 tris/cube face pairs; dev plane = 2 tris; custom cone ~90 tris)
	const int BoxTrisEstimate = 12;
	const int PlaneTrisEstimate = 2;
	const int ConeTrisEstimate = 90;

	static Scene _scene;
	static GameObject _root;
	static Vector3 _origin; // meters — the world-relative offset this Build() call was given

	static int _boxCount;
	static int _planeCount;
	static int _modelCount;

	/// <summary>
	/// Named spawn points, keyed by station name (see docs/proving-grounds.md for the full
	/// table). Positions are SI METERS in world space (origin already applied); facing is a
	/// ready-to-use world Rotation. Populated by <see cref="Build"/>; empty until then.
	/// </summary>
	public static IReadOnlyDictionary<string, (Vector3 posMeters, Rotation facing)> Stations { get; private set; }
		= new Dictionary<string, (Vector3, Rotation)>();

	/// <summary>
	/// Builds the whole test-track zone under a fresh root GameObject, offset by
	/// <paramref name="originMeters"/> from world origin (SI meters). Deterministic — no
	/// runtime RNG, safe to call once per session boot. Does not touch Vehicle/UI/Game code;
	/// the bootstrap calls this from GameBootstrap after CityBuilder.Build (see doc).
	/// </summary>
	public static void Build( Scene scene, Vector3 originMeters )
	{
		_scene = scene;
		_origin = originMeters;
		_root = scene.CreateObject();
		_root.Name = "Proving Grounds";

		_boxCount = 0;
		_planeCount = 0;
		_modelCount = 0;

		var stations = new Dictionary<string, (Vector3 posMeters, Rotation facing)>();

		BuildGround();
		BuildSkidpad( stations );
		BuildDragStripAndBrakeZone( stations );
		BuildSlalom( stations );
		BuildRamps( stations );
		BuildBankedCurve( stations );
		BuildWashboard( stations );
		BuildHillLadder( stations );
		BuildLowGripPatch( stations );
		BuildJTurnPad( stations );
		BuildCrashWallReserve( stations );

		Stations = stations;

		long trisEstimate = (long)_boxCount * BoxTrisEstimate
			+ (long)_planeCount * PlaneTrisEstimate
			+ (long)_modelCount * ConeTrisEstimate;

		Log.Info( $"[vp] world track stations={stations.Count} tris~={trisEstimate}" );
	}

	// ---------------------------------------------------------------- ground

	static void BuildGround()
	{
		// station footprints span local X[35,815] Y[-170,220] (see docs/proving-grounds.md
		// ASCII map) — center + span chosen with generous margin, the same "oversized ground
		// BoxCollider" idiom
		var center = new Vector3( 400f, 20f, 0f );
		const float spanXMeters = 1000f;
		const float spanYMeters = 600f;

		var go = Child( "Track Ground" );
		go.WorldPosition = ( _origin + center ) * M;

		var renderer = go.Components.Create<ModelRenderer>();
		renderer.MaterialOverride = Material.Load( "materials/default.vmat" );
		renderer.Model = Model.Load( "models/dev/plane.vmdl" );
		renderer.Tint = new Color( 0.45f, 0.44f, 0.40f ); // dry proving-ground hardpack, distinct from the city's grass
		// dev plane is a 100x100-unit quad; LocalScale is a raw multiplier, not meters —
		// scale = desiredSpanMeters * M / 100
		go.LocalScale = new Vector3( spanXMeters * M / 100f, spanYMeters * M / 100f, 1f );
		_planeCount++;

		// thick slab: hard landings (this zone HAS ramps + a jump station) must not tunnel
		// through a thin ground collider; BoxCollider follows WorldScale so this stretches correctly with the
		// non-square span above
		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" );
	}

	// ---------------------------------------------------------------- skidpad

	static void BuildSkidpad( Dictionary<string, (Vector3, Rotation)> stations )
	{
		var center = new Vector3( 60f, 150f, 0.02f );
		const float radius = 20f;
		const int ringMarkers = 32;
		var ringColor = new Color( 0.92f, 0.90f, 0.85f );

		for ( int i = 0; i < ringMarkers; i++ )
		{
			float a = i * MathF.Tau / ringMarkers;
			var offset = new Vector3( MathF.Cos( a ), MathF.Sin( a ), 0f ) * radius;
			Block( center + offset, new Vector3( 0.8f, 0.8f, 0.05f ), ringColor, collide: false, name: "Skidpad Ring Marker" );
		}

		// spawn on the west edge of the ring, facing tangent (+Y) so the car curls onto the circle
		stations["skidpad"] = ( StationPos( new Vector3( center.x - radius, center.y, 0f ) ), Rotation.FromYaw( 90f ) );
	}

	// ---------------------------------------------------------------- drag strip + brake zone

	static void BuildDragStripAndBrakeZone( Dictionary<string, (Vector3, Rotation)> stations )
	{
		const float startX = 150f, laneY = 40f, laneWidth = 14f, dragLength = 400f, brakeZoneLength = 100f;
		var asphalt = new Color( 0.30f, 0.30f, 0.33f );
		var lineColor = new Color( 0.95f, 0.85f, 0.30f );

		float padLength = dragLength + brakeZoneLength;
		Block( new Vector3( startX + padLength * 0.5f, laneY, 0.01f ), new Vector3( padLength, laneWidth, 0.02f ),
			asphalt, collide: false, name: "Drag Strip + Brake Zone Pad" );

		Block( new Vector3( startX, laneY, 0.02f ), new Vector3( 0.4f, laneWidth, 0.03f ), lineColor, collide: false, name: "Start Line" );

		// distance boards at 100/200/300/400 m — painted line across the lane plus a standing
		// marker board OFF to the side (never in the driving line)
		for ( int i = 1; i <= 4; i++ )
		{
			float bx = startX + i * 100f;
			Block( new Vector3( bx, laneY, 0.02f ), new Vector3( 0.4f, laneWidth, 0.03f ), lineColor, collide: false, name: $"{i * 100}m Line" );
			Block( new Vector3( bx, laneY + laneWidth * 0.5f + 1.5f, 1f ), new Vector3( 0.3f, 0.3f, 2f ),
				new Color( 0.85f, 0.15f, 0.10f ), collide: false, name: $"{i * 100}m Board" );
		}

		float brakeEntryX = startX + dragLength;

		// brake-zone entry gate: side pillars only, non-colliding — must never block the lane
		Block( new Vector3( brakeEntryX, laneY - laneWidth * 0.5f - 1f, 1.2f ), new Vector3( 0.6f, 0.6f, 2.4f ),
			new Color( 0.85f, 0.2f, 0.15f ), collide: false, name: "Brake Entry Marker" );
		Block( new Vector3( brakeEntryX, laneY + laneWidth * 0.5f + 1f, 1.2f ), new Vector3( 0.6f, 0.6f, 2.4f ),
			new Color( 0.85f, 0.2f, 0.15f ), collide: false, name: "Brake Entry Marker" );

		foreach ( var d in new[] { 20f, 50f, 80f } )
		{
			float bx = brakeEntryX + d;
			Block( new Vector3( bx, laneY + laneWidth * 0.5f + 1.5f, 1f ), new Vector3( 0.3f, 0.3f, 1.6f ),
				new Color( 0.2f, 0.35f, 0.85f ), collide: false, name: $"Brake {d:0}m Board" );
		}

		stations["dragstrip"] = ( StationPos( new Vector3( startX - 10f, laneY, 0f ) ), Rotation.Identity );
		stations["brakezone"] = ( StationPos( new Vector3( brakeEntryX - 30f, laneY, 0f ) ), Rotation.Identity );
	}

	// ---------------------------------------------------------------- slalom

	static void BuildSlalom( Dictionary<string, (Vector3, Rotation)> stations )
	{
		const float startX = 150f, laneY = -40f, spacing = 18f, lateral = 2.5f;
		const int coneCount = 8;
		var coneColor = new Color( 0.95f, 0.45f, 0.05f );

		for ( int i = 0; i < coneCount; i++ )
		{
			float x = startX + i * spacing;
			float y = laneY + ( i % 2 == 0 ? lateral : -lateral );
			ConeOrBlock( new Vector3( x, y, 0f ), coneColor, $"Slalom Cone {i + 1}" );
		}

		stations["slalom"] = ( StationPos( new Vector3( startX - spacing, laneY, 0f ) ), Rotation.Identity );
	}

	// ---------------------------------------------------------------- ramp set

	static void BuildRamps( Dictionary<string, (Vector3, Rotation)> stations )
	{
		const float laneY = -150f;
		var rampColor = new Color( 0.55f, 0.55f, 0.6f );
		var apronColor = new Color( 0.35f, 0.35f, 0.38f );

		var ramps = new (float startX, float pitchDeg, float length, float width)[]
		{
			( 60f, 10f, 10f, 6f ),   // small
			( 140f, 16f, 12f, 6f ),  // medium
			( 220f, 22f, 14f, 7f ),  // large
		};

		foreach ( var r in ramps )
		{
			// Curved SOLID kicker (RampKicker): base tangent to grade (no lip), collision following the
			// curved face, sealed underside (no drive-under gap). Base sits at startX, runs +X to the lip
			// at startX+length — same footprint as the old straight box wedge; height ~= the old crest.
			// NOTE: the hill-grade ladder (BuildHillLadder) deliberately KEEPS the straight Ramp() — a
			// grade test is a constant-slope surface and HillClimbManeuver measures the crest off it.
			float heightM = MathF.Sin( r.pitchDeg.DegreeToRadian() ) * r.length * 0.25f;
			RampKicker.Build( _scene, _root, _origin + new Vector3( r.startX, laneY, 0f ), 0f,
				r.length, r.width, heightM, rampColor );

			float apronStartX = r.startX + r.length + 2f;
			Block( new Vector3( apronStartX + 5f, laneY, 0.01f ), new Vector3( 10f, r.width + 2f, 0.05f ),
				apronColor, collide: false, name: "Landing Apron" );
		}

		stations["ramps"] = ( StationPos( new Vector3( 30f, laneY, 0f ) ), Rotation.Identity );
	}

	// ---------------------------------------------------------------- banked curve

	static void BuildBankedCurve( Dictionary<string, (Vector3, Rotation)> stations )
	{
		var arcCenter = new Vector2( 700f, 220f );
		const float radius = 45f;
		const float startAngleDeg = 180f, sweepDeg = 90f;
		const int segments = 9;
		const float bankDeg = 18f;
		const float laneWidth = 12f;
		var color = new Color( 0.42f, 0.42f, 0.46f );

		float segAngleDeg = sweepDeg / segments;
		float segLength = radius * segAngleDeg.DegreeToRadian() * 1.15f; // slight overlap so segments don't gap

		for ( int i = 0; i < segments; i++ )
		{
			float midAngleDeg = startAngleDeg + ( i + 0.5f ) * segAngleDeg;
			float midAngleRad = midAngleDeg.DegreeToRadian();
			var pos = arcCenter + new Vector2( MathF.Cos( midAngleRad ), MathF.Sin( midAngleRad ) ) * radius;

			// tangent heading for increasing angle (counterclockwise travel around the arc
			// center) — sign/direction not verified against actual steering convention, see
			// docs/proving-grounds.md uncertainty list
			float yawDeg = midAngleDeg + 90f;

			BankedSegment( new Vector3( pos.x, pos.y, 0f ), new Vector3( segLength, laneWidth, 0.4f ), yawDeg, bankDeg, color,
				name: $"Banked Segment {i + 1}" );
		}

		float entryAngleRad = startAngleDeg.DegreeToRadian();
		var entryPos = arcCenter + new Vector2( MathF.Cos( entryAngleRad ), MathF.Sin( entryAngleRad ) ) * ( radius + 8f );
		stations["bankedcurve"] = ( StationPos( new Vector3( entryPos.x, entryPos.y, 0f ) ), Rotation.FromYaw( startAngleDeg + 90f ) );
	}

	// ---------------------------------------------------------------- washboard

	static void BuildWashboard( Dictionary<string, (Vector3, Rotation)> stations )
	{
		const float laneY = -150f, startX = 320f, spacing = 1.5f, laneWidth = 12f;
		const int ridgeCount = 20;
		var ridgeColor = new Color( 0.40f, 0.36f, 0.30f );

		Block( new Vector3( startX + ridgeCount * spacing * 0.5f, laneY, 0.005f ),
			new Vector3( ridgeCount * spacing + 4f, laneWidth, 0.02f ), new Color( 0.30f, 0.30f, 0.33f ),
			collide: false, name: "Washboard Pad" );

		for ( int i = 0; i < ridgeCount; i++ )
		{
			float x = startX + i * spacing;
			// low transverse ridge: thin along travel (X), full lane width along Y, no rotation needed
			Block( new Vector3( x, laneY, 0.06f ), new Vector3( 0.25f, laneWidth, 0.12f ), ridgeColor, collide: true, name: "Washboard Ridge" );
		}

		stations["washboard"] = ( StationPos( new Vector3( startX - 15f, laneY, 0f ) ), Rotation.Identity );
	}

	// ---------------------------------------------------------------- hill grade ladder

	const float HillLaneYMetersConst = -150f;  // spawn row (the 5% ramp's row)
	const float HillSpawnXMeters = 385f;       // origin-relative X of the hillclimb spawn (the climb datum)
	const float HillBaseXMetersConst = 430f;   // origin-relative X where every ramp's base sits
	const float HillRampLengthMeters = 20f;
	const float HillRowPitchMeters = 14f;      // Y distance between adjacent grade rows (10 m ramps + 4 m gap)

	// NINE DISCRETE graded ramps in PARALLEL ROWS — one grade per row, every base at the same X, rows
	// fanning south from the spawn row. Each ramp starts at ground level so the verified CityBuilder
	// Ramp lift idiom (center rises by sin(pitch)*length*0.25 off a ground-level 'at') applies cleanly
	// per-segment. Extended 2026-07-13 (wave-2) from the old SERIAL 5/10/15/20% ladder for two reasons:
	// (1) 3 of the 4 hillclimb bands (Hatch 25-30, Coupe 35-40, Pickup 40-45) exceeded the 20% ceiling;
	// (2) the serial layout made every car drive OVER all lower ramps en route — each ramp's east edge
	// is an elevated cliff (the 40% crest is ~7 m up), so the first live battery measured an obstacle
	// course, not grade-holding (pickup flipped off a crest drop; wheelspin spiked on landings). The
	// parallel fan gives each grade its own flat approach. SINGLE SOURCE OF TRUTH: exposed via
	// HillLadder so HillClimbManeuver routes to its rated row + detects the crest WITHOUT re-hardcoding
	// the layout (no drift between geometry and the maneuver that measures it).
	static readonly (float laneYMeters, float gradePct)[] _hillGradeRows =
	{
		( -150f, 5f ), ( -164f, 10f ), ( -178f, 15f ), ( -192f, 20f ),
		( -206f, 25f ), ( -220f, 30f ), ( -234f, 35f ), ( -248f, 40f ), ( -262f, 45f ),
	};

	/// <summary>The hill-grade ladder as (origin-relative row Y in SI meters, grade %), grade-ascending.
	/// <see cref="HillClimbManeuver"/> reads this + the spawn/base datums below to route to the rated
	/// grade's row and decide when the car has crested its ramp, so the layout lives in exactly one
	/// place (geometry and the measuring maneuver can't drift apart).</summary>
	public static IReadOnlyList<(float laneYMeters, float gradePct)> HillLadder => _hillGradeRows;

	/// <summary>Origin-relative X (SI meters) of the hillclimb spawn — the datum forward progress is
	/// measured from (paired with <see cref="HillLadder"/>).</summary>
	public static float HillLadderSpawnXMeters => HillSpawnXMeters;

	/// <summary>Origin-relative Y (SI meters) of the spawn row (the 5% ramp's row).</summary>
	public static float HillLaneYMeters => HillLaneYMetersConst;

	/// <summary>Origin-relative X (SI meters) of every ramp's base edge.</summary>
	public static float HillBaseXMeters => HillBaseXMetersConst;

	/// <summary>Along-slope length (SI meters) of each ladder ramp.</summary>
	public static float HillRampLength => HillRampLengthMeters;

	static void BuildHillLadder( Dictionary<string, (Vector3, Rotation)> stations )
	{
		var color = new Color( 0.50f, 0.45f, 0.38f );

		foreach ( var g in _hillGradeRows )
		{
			float pitchDeg = MathF.Atan( g.gradePct / 100f ) * ( 180f / MathF.PI );
			var size = new Vector3( HillRampLengthMeters, 10f, 0.4f );
			var center = new Vector3( HillBaseXMetersConst + size.x * 0.5f, g.laneYMeters, 0f );
			Ramp( center, size, pitchDeg, color, name: $"Hill Grade {g.gradePct:0}%" );
		}

		stations["hillclimb"] = ( StationPos( new Vector3( HillSpawnXMeters, HillLaneYMetersConst, 0f ) ), Rotation.Identity );
	}

	// ---------------------------------------------------------------- low-grip patch

	static void BuildLowGripPatch( Dictionary<string, (Vector3, Rotation)> stations )
	{
		var center = new Vector3( 600f, -40f, 0.02f );
		var size = new Vector3( 25f, 25f, 0.03f );
		var iceBlue = new Color( 0.55f, 0.70f, 0.80f );

		// TODO(surface-friction): visual patch ONLY. No per-surface friction override
		// mechanism exists in the physics stack yet.
		// Hook point for whoever builds it: this GameObject is tagged "low_grip_todo"; either
		// have VehicleWheel's tire-curve sample check for an overlapping tagged volume at
		// contact time, or expose a per-material friction scalar read off the trace surface.
		var go = Block( center, size, iceBlue, collide: false, name: "Low-Grip Patch" );
		go.Tags.Add( "low_grip_todo" );

		stations["lowgrip"] = ( StationPos( new Vector3( center.x - 12f, center.y, 0f ) ), Rotation.Identity );
	}

	// ---------------------------------------------------------------- J-turn pad

	static void BuildJTurnPad( Dictionary<string, (Vector3, Rotation)> stations )
	{
		var center = new Vector3( 780f, 0f, 0.02f );
		const float padSize = 70f;
		var asphalt = new Color( 0.30f, 0.30f, 0.33f );
		var stripe = new Color( 0.95f, 0.85f, 0.30f );

		Block( center, new Vector3( padSize, padSize, 0.03f ), asphalt, collide: false, name: "J-Turn Pad" );

		float half = padSize * 0.5f - 0.5f;
		Block( center + new Vector3( 0f, half, 0.03f ), new Vector3( padSize, 1f, 0.02f ), stripe, collide: false, name: "J-Turn Pad Border" );
		Block( center + new Vector3( 0f, -half, 0.03f ), new Vector3( padSize, 1f, 0.02f ), stripe, collide: false, name: "J-Turn Pad Border" );
		Block( center + new Vector3( half, 0f, 0.03f ), new Vector3( 1f, padSize, 0.02f ), stripe, collide: false, name: "J-Turn Pad Border" );
		Block( center + new Vector3( -half, 0f, 0.03f ), new Vector3( 1f, padSize, 0.02f ), stripe, collide: false, name: "J-Turn Pad Border" );

		stations["jturnpad"] = ( StationPos( center ), Rotation.Identity );
	}

	// ---------------------------------------------------------------- crash wall (reserved plot)

	static void BuildCrashWallReserve( Dictionary<string, (Vector3, Rotation)> stations )
	{
		// REFERENCE-ONLY reserved crash plot. Full crash/destruction simulation (an impact router and
		// runtime deformation) is out of scope for this prototyping kit, so no maneuver drives this plot.
		// It is still built as a reserved spawn point (`crashwall_reserved`): a solid static wall
		// perpendicular to +X travel with a straight west run-up, kept so the station reads clearly and
		// remains a hook point for anyone extending the kit. Deterministic, verified Slab idiom.
		const float wallFaceX = 798f;      // west face of the wall = the frontal contact plane
		const float laneY = -160f;         // clear lane (10 m south of the y=-150 ramp/hill lane)
		const float spawnX = 708f;         // ~90 m run-up west of the wall face
		var hazard = new Color( 0.85f, 0.65f, 0.10f );
		var postColor = new Color( 0.55f, 0.15f, 0.10f );

		// wall: 4 m thick (x), 20 m wide (y), 6 m tall (z), centred just east of the contact face.
		Block( new Vector3( wallFaceX + 2f, laneY, 3f ), new Vector3( 4f, 20f, 6f ), hazard, collide: true, name: "Crash Wall" );

		// hazard posts flanking the wall so the station reads clearly in screenshots
		foreach ( float signY in new[] { 1f, -1f } )
			Block( new Vector3( wallFaceX, laneY + 11f * signY, 1.5f ), new Vector3( 0.5f, 0.5f, 3f ),
				postColor, collide: true, name: "Crash Wall Post" );

		// painted approach centreline so the run-up lane is visible
		Block( new Vector3( (spawnX + wallFaceX) * 0.5f, laneY, 0.02f ), new Vector3( wallFaceX - spawnX, 0.3f, 0.02f ),
			new Color( 0.95f, 0.85f, 0.30f ), collide: false, name: "Crash Approach Line" );

		// spawn ~90 m west of the wall face, nose down +X (Rotation.Identity) straight at the wall.
		stations["crashwall_reserved"] = ( StationPos( new Vector3( spawnX, laneY, 0f ) ), Rotation.Identity );
	}

	// ---------------------------------------------------------------- primitive helpers

	/// <summary>SI meters (local, relative to this Build's origin) → world SI meters.</summary>
	static Vector3 StationPos( Vector3 localMeters ) => _origin + localMeters;

	static GameObject Block( Vector3 posMeters, Vector3 sizeMeters, Color color, bool collide = true, string name = "Block" )
		=> Slab( posMeters, sizeMeters, color, Rotation.Identity, collide, name );

	/// <summary>
	/// Pitched ramp along local +X. The box's near edge rests close to ground while its
	/// CENTER rises by sin(pitch)*length*0.25 off the ground-level position passed in.
	/// </summary>
	static GameObject Ramp( Vector3 posMeters, Vector3 sizeMeters, float pitchDeg, Color color, bool collide = true, string name = "Ramp" )
	{
		float liftMeters = MathF.Sin( pitchDeg.DegreeToRadian() ) * sizeMeters.x * 0.25f;
		var lifted = posMeters + Vector3.Up * liftMeters;
		return Slab( lifted, sizeMeters, color, Rotation.FromPitch( -pitchDeg ), collide, name );
	}

	static GameObject BankedSegment( Vector3 posMeters, Vector3 sizeMeters, float yawDeg, float bankDeg, Color color,
		bool collide = true, string name = "Banked Segment" )
		=> Slab( posMeters, sizeMeters, color, Rotation.From( 0f, yawDeg, bankDeg ), collide, name );

	/// <summary>
	/// The one shared dev-box placement primitive — every Block/Ramp/BankedSegment call
	/// funnels through here, so this plus <see cref="Cone"/> are the ONLY two `* M`
	/// conversion sites in the whole file (audited against the missed-conversion trap).
	/// </summary>
	static GameObject Slab( Vector3 posMeters, Vector3 sizeMeters, Color color, Rotation rotation, bool collide, string name )
	{
		var go = Child( name );
		go.WorldPosition = ( _origin + posMeters ) * M;
		go.WorldRotation = rotation;
		go.LocalScale = sizeMeters * M / 50f; // dev box is a 50x50x50-unit cube

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

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

		return go;
	}

	static void ConeOrBlock( Vector3 posMeters, Color fallbackColor, string name )
	{
		if ( !Cone( posMeters, name ) )
			Block( posMeters + Vector3.Up * 0.6f, new Vector3( 0.6f, 0.6f, 1.2f ), fallbackColor, collide: true, name: name );
	}

	/// <summary>
	/// Places the custom flat-shaded traffic cone (models/city/cone.vmdl, generated by
	/// tools/gen_buildings.py — retires the Kenney cone), bounds-scaled and rested on the
	/// ground (graceful false-on-missing-model fallback; collider fit to model bounds, NOT
	/// pre-multiplied by scale, since BoxCollider follows the GameObject's WorldScale).
	/// Cones are rotationally symmetric so no yaw parameter is needed.
	/// </summary>
	static bool Cone( Vector3 posMeters, string name )
	{
		var model = Model.Load( "models/city/cone.vmdl" );
		if ( model is null || model.IsError )
			return false;

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

		const float coneHeightMeters = 0.7f;
		float scale = coneHeightMeters * M / size.z;
		var world = _origin + posMeters;

		var go = Child( name );
		go.WorldPosition = new Vector3( world.x * M, world.y * M, world.z * M - model.Bounds.Mins.z * scale );
		go.LocalScale = Vector3.One * scale;

		var renderer = go.Components.Create<ModelRenderer>();
		renderer.Model = model; // cone vmdl carries its own orange/white flat vmats — no Tint/MaterialOverride
		_modelCount++;

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

		return true;
	}

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