1
0

feat: a true-metre corridor next to the atlas glyph, and a camera that can see the car

Withdrawing the 4.7 km freeway close in was the honest half-measure:
a missing road reads as unfinished, a slab across San Francisco reads
as broken. The other half is a second drawing of the same cross-section
whose berm is road.widthM — 44 m on US-101, 47 m on I-5 — with oaks
and signs placed from setbackM rather than from the slab.

Atlas far, metre close, including drive. The car follows: a World
that has metresPerUnit gets a 5 m Model X and a chase camera in
metres; the test stub without one keeps the 0.18 diorama. Drive's
near plane is 0.5 m instead of 0.1 scene units (192 m on this board).
This commit is contained in:
2026-08-24 22:14:31 -07:00
parent 1c1148739c
commit 019ff33148
5 changed files with 223 additions and 65 deletions
+28 -10
View File
@@ -95,7 +95,15 @@ export function createRoadTrafficLayer(
): RoadTrafficLayer {
const group = new THREE.Group();
group.name = "road-traffic";
const heroScale = options.scale ?? 0.18;
/*
* A real `World` has `metresPerUnit`. The tests stub one that does not, and
* they pin the glyph scale of 0.18 — a diorama car on the atlas corridor.
* On the merged board a 0.18-unit car is 345 m long on a 44 m road, so the
* default follows the board: one asset-metre per true metre.
*/
const mpu = world.metresPerUnit;
const trueMetre = Number.isFinite(mpu) && mpu > 0;
const heroScale = options.scale ?? (trueMetre ? 1 / mpu : 0.18);
// Background traffic is deliberately quieter. One oversized convoy reads as
// map symbols; one detailed hero against smaller traffic reads as a camera.
const backgroundScale = heroScale * 0.62;
@@ -174,14 +182,15 @@ export function createRoadTrafficLayer(
const heading = (pose.headingDeg * Math.PI) / 180;
// Lane 0 hugs the median. Southbound traffic's right side naturally moves
// to the other carriageway because its heading is reversed.
const laneOffset =
0.29 + (pose.lane ?? 0) * 0.34 + (pose.lateralOffsetM ?? 0) * 0.03;
const laneOffset = trueMetre
? (2.2 + (pose.lane ?? 0) * 3.7 + (pose.lateralOffsetM ?? 0)) / mpu
: 0.29 + (pose.lane ?? 0) * 0.34 + (pose.lateralOffsetM ?? 0) * 0.03;
out.set(
x + Math.cos(heading) * laneOffset,
// The asset origin is on the tyre contact plane. Keep only a tiny lift
// above the generated road to avoid z-fighting; `0.22` here made the
// vehicle hover more than its own rendered height at corridor scale.
world.groundAt(pose.lat, pose.lng) + 0.155,
world.groundAt(pose.lat, pose.lng) + (trueMetre ? 0.06 / mpu : 0.155),
z + Math.sin(heading) * laneOffset,
);
return out;
@@ -220,25 +229,34 @@ export function createRoadTrafficLayer(
// A hood/driver-height view. The procedural corridor asset has no cabin
// texture to hide, so the camera sits just above its glass and looks far
// enough ahead that route curvature reads before the car reaches it.
const up = trueMetre ? 1.2 / mpu : 0.32;
const look = trueMetre ? 12 / mpu : 2.2;
const lookUp = trueMetre ? 1.1 / mpu : 0.14;
const nose = trueMetre ? 0.6 / mpu : 0.03;
followTarget
.copy(heroRig.root.position)
.add(followOffset.set(forwardX * 2.2, 0.14, forwardZ * 2.2));
.add(followOffset.set(forwardX * look, lookUp, forwardZ * look));
followPosition
.copy(heroRig.root.position)
.add(followOffset.set(forwardX * 0.03, 0.32, forwardZ * 0.03));
.add(followOffset.set(forwardX * nose, up, forwardZ * nose));
} else {
const rightX = Math.cos(heading);
const rightZ = Math.sin(heading);
const back = trueMetre ? 14 / mpu : 0.72;
const up = trueMetre ? 8 / mpu : 0.92;
const side = trueMetre ? 3.5 / mpu : 0.2;
const look = trueMetre ? 6 / mpu : 0.32;
const lookUp = trueMetre ? 1.6 / mpu : 0.1;
followTarget
.copy(heroRig.root.position)
.add(followOffset.set(forwardX * 0.32, 0.1, forwardZ * 0.32));
.add(followOffset.set(forwardX * look, lookUp, forwardZ * look));
followPosition
.copy(heroRig.root.position)
.add(
followOffset.set(
-forwardX * 0.72 - rightX * 0.2,
0.92,
-forwardZ * 0.72 - rightZ * 0.2,
-forwardX * back - rightX * side,
up,
-forwardZ * back - rightZ * side,
),
);
}
+47 -23
View File
@@ -909,10 +909,17 @@ export async function createScene(
ground.add(createWater(world));
ground.add(createShorePlates(world));
ground.add(createTerrain(world));
const corridorGroup = options.roadTraffic
? createFreewayWorld(world, options.roadTraffic.pack)
const atlasCorridor = options.roadTraffic
? createFreewayWorld(world, options.roadTraffic.pack, "atlas")
: createRoads(world);
ground.add(corridorGroup);
const metreCorridor = options.roadTraffic
? createFreewayWorld(world, options.roadTraffic.pack, "metre")
: null;
ground.add(atlasCorridor);
if (metreCorridor) {
metreCorridor.visible = false;
ground.add(metreCorridor);
}
const buildingReservations: BuildingReservation[] = [];
for (const marker of options.markers ?? []) {
const glyph = marker.glyph;
@@ -1121,18 +1128,11 @@ export async function createScene(
* the Golden Gate. It is the single most dominant thing in every close frame
* of the merged board.
*
* **This withdraws the glyph rather than replacing it**, and that is a
* deliberate half-measure with the honest half named. A true-width corridor is
* the real answer and it is not one commit: the roadside props are laid out
* against the slab (`structures.ts` sets their setback from it), so a 44 m
* ribbon with today's props puts 600 m oaks beside a two-lane road; and drive
* mode's camera cannot see a true-scale car at all — the near plane is 0.1
* units, which is 192 m on this board, against a car 0.0026 units long. Until
* both are done, a missing road reads as "not modelled yet" and a 4.7 km slab
* reads as broken, and the first is the better of the two.
*
* Drive mode is exempt, for the obvious reason: it is the mode that needs a
* road to be on.
* **Atlas far, metre close.** The atlas glyph is withdrawn inside
* `DETAIL_STANDOFF_M`; the true-width corridor (`widthM`, 44 m on US-101)
* is shown instead, including in drive, which is the mode that needs a
* road a car can be on. Roadside props on the metre corridor are placed
* from `setbackM`, not from the slab.
*/
const overviewLayers: THREE.Object3D[] = [];
let detailShown = true;
@@ -1198,14 +1198,18 @@ export async function createScene(
cull ? detailFrustum : undefined,
standoff / world.metresPerUnit,
);
const driving = controlMode === "drive";
if (metreCorridor) {
atlasCorridor.visible = !want && !driving;
metreCorridor.visible = want || driving;
}
if (want === detailShown) return;
detailShown = want;
for (const child of detailLandmarks) child.visible = want;
for (const layer of detailLayers) layer.visible = want;
// Withdrawn close in, except when the mode being used is the one that needs
// a road under it.
const driving = kit.controls.enabled === false || controlMode === "drive";
for (const layer of overviewLayers) layer.visible = !want || driving;
if (!metreCorridor) {
for (const layer of overviewLayers) layer.visible = !want || driving;
}
}
// Applied once up front so the opening frame is already correct rather than
// correct one tick later, which is a frame the capture harness can catch.
@@ -1276,7 +1280,7 @@ export async function createScene(
*/
if (hasDetail) {
detailLayers.push(bridgeGroup, airportGroup);
overviewLayers.push(corridorGroup);
overviewLayers.push(atlasCorridor);
if (portLayer) detailLayers.push(portLayer.group);
if (vesselLayer) detailLayers.push(vesselLayer.group);
detailShown = true;
@@ -1431,11 +1435,31 @@ export async function createScene(
sceneAircraft?.setActive(ownership.aircraft);
roadTraffic?.setFollowing(ownership.drive);
kit.controls.enabled = ownership.orbit;
kit.camera.near = next === "actor" ? 0.001 : next === "aircraft" ? 0.01 : 0.1;
/*
* Clip planes in metres for the modes that look at metre-scale bodies.
* 0.1 scene units is 192 m on the merged board — farther than the car
* is long, which is why drive could not see a true-scale Model X.
* Overview keeps the authored 0.1: a 0.4 m near against a 2,200-unit
* far plane is a depth ratio the 24-bit buffer will not survive.
*/
const mpu = world.metresPerUnit;
if (next === "drive") {
kit.camera.near = 0.5 / mpu;
kit.camera.far = Math.max(40_000 / mpu, boardSpan * 0.2);
} else if (next === "actor") {
kit.camera.near = 0.25 / mpu;
kit.camera.far = boardSpan * 4;
} else if (next === "aircraft") {
kit.camera.near = 1 / mpu;
kit.camera.far = boardSpan * 4;
} else {
kit.camera.near = 0.1;
kit.camera.far = boardSpan * 4;
}
kit.controls.minDistance = next === "actor"
? 0.001
? 0.25 / mpu
: next === "aircraft"
? 0.01
? 1 / mpu
: orbitMinDistance;
kit.camera.updateProjectionMatrix();
if (next !== controlMode) {
+84 -32
View File
@@ -545,16 +545,47 @@ function makeShieldMaterial(identity: "us-highway" | "interstate", shield: strin
return material;
}
/**
* The atlas berm width, in scene units. Every other atlas offset is a fraction
* of this, so a metre corridor is the same drawing at `widthM / ATLAS_BERM_WIDTH`.
*/
const ATLAS_BERM_WIDTH = 3.5;
/** Atlas symbol vs the road as it is on the ground. See `createFreewayWorld`. */
export type FreewayScale = "atlas" | "metre";
/**
* Browser-feasible authored freeway world. Geometry is deliberately batched by
* road/marking class: the corridor gains lane-scale readability without one
* draw call per reflector, tree, or roadside prop.
*
* Two scales, because one board now has two jobs. **Atlas** is the 4.7 km
* glyph that reads as a corridor from the state pose — the drawing
* `createFreewayWorld` was authored as. **Metre** is the same cross-section
* restated so the berm is `road.widthM` (44 m on US-101), which is what a
* chase camera and a descended city can actually look at. Scene units of the
* atlas are a constant that assumed one board; putting cities on that board
* made the glyph wider than the Golden Gate. Withdrawing it close in was the
* half-measure; this is the other half. The two are separate groups so the
* LOD can show one and not the other without rebuilding, and so the atlas
* tests keep counting the atlas.
*/
export function createFreewayWorld(world: World, pack: TransportPack): THREE.Group {
export function createFreewayWorld(
world: World,
pack: TransportPack,
scale: FreewayScale = "atlas",
): THREE.Group {
const metre = scale === "metre";
const mpu = world.metresPerUnit;
if (metre && !(mpu > 0)) {
throw new Error("createFreewayWorld(\"metre\") needs world.metresPerUnit");
}
const u = (m: number) => m / mpu;
const group = new THREE.Group();
group.name = "freeway-world-v2";
group.name = metre ? "freeway-world-metre" : "freeway-world-v2";
const plan = buildFreewayWorldPlan(pack);
group.userData.planSeed = plan.seed;
group.userData.scale = scale;
const batch = new Batch();
const asphalt = [0x353a3d, 0x303538];
@@ -568,16 +599,26 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
const barrierMaterial = new THREE.MeshLambertMaterial({ color: 0xb6b4aa });
const guardMaterial = new THREE.MeshStandardMaterial({ color: 0x9fa8aa, metalness: 0.64, roughness: 0.42 });
const reflectorMaterial = new THREE.MeshBasicMaterial({ color: 0xf7e3a0, toneMapped: false });
const reflectorGeometry = new THREE.BoxGeometry(0.018, 0.01, 0.028);
const reflectorGeometry = metre
? new THREE.BoxGeometry(u(0.18), u(0.1), u(0.28))
: new THREE.BoxGeometry(0.018, 0.01, 0.028);
const treeTrunkMaterial = new THREE.MeshLambertMaterial({ color: 0x66513b });
const treeCrownMaterials = [
new THREE.MeshLambertMaterial({ color: 0x3f5942 }),
new THREE.MeshLambertMaterial({ color: 0x687347 }),
];
const trunkGeometry = new THREE.CylinderGeometry(0.045, 0.06, 0.45, 5);
const crownGeometry = new THREE.IcosahedronGeometry(0.28, 0);
const poleGeometry = new THREE.CylinderGeometry(0.022, 0.03, 0.72, 5);
const siloGeometry = new THREE.CylinderGeometry(0.14, 0.16, 0.55, 8);
const trunkGeometry = metre
? new THREE.CylinderGeometry(u(0.55), u(0.75), u(5.5), 5)
: new THREE.CylinderGeometry(0.045, 0.06, 0.45, 5);
const crownGeometry = metre
? new THREE.IcosahedronGeometry(u(7), 0)
: new THREE.IcosahedronGeometry(0.28, 0);
const poleGeometry = metre
? new THREE.CylinderGeometry(u(0.22), u(0.3), u(9), 5)
: new THREE.CylinderGeometry(0.022, 0.03, 0.72, 5);
const siloGeometry = metre
? new THREE.CylinderGeometry(u(2.2), u(2.5), u(8), 8)
: new THREE.CylinderGeometry(0.14, 0.16, 0.55, 8);
const roadsideFeatures = plan.routes.reduce((sum, route) => sum + route.roadside.length, 0);
const trunks = new THREE.InstancedMesh(trunkGeometry, treeTrunkMaterial, roadsideFeatures);
const coastalCrowns = new THREE.InstancedMesh(crownGeometry, treeCrownMaterials[0]!, roadsideFeatures);
@@ -599,7 +640,10 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
world.city.roads.forEach((road, roadIndex) => {
if (road.kind !== "freeway") return;
const path = drapePath(world, road.path, 52, 0.115);
const pavedM = road.widthM ?? road.width * (mpu || 1);
const k = metre ? pavedM / mpu / ATLAS_BERM_WIDTH : 1;
const s = (n: number) => n * k;
const path = drapePath(world, road.path, 52, s(0.115));
const route = plan.routes[roadIndex];
const identityIndex = route?.identity === "interstate" ? 1 : 0;
const routePath = route ? buildRoutePath(pack, route.routeId) : null;
@@ -631,24 +675,24 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
* like out of a car window.
*/
const bermMaterial = batch.material("deck", berm[identityIndex] ?? berm[0]!);
batch.add("freeway:berm", roadRibbonGeometry(path, 3.5, -0.02), bermMaterial);
batch.add("freeway:berm", roadRibbonGeometry(path, s(ATLAS_BERM_WIDTH), s(-0.02)), bermMaterial);
const batterMaterial = batch.material("deck", batter);
for (const side of [-1, 1] as const) {
batch.add(
"freeway:embankment",
bandGeometry(path, side * 1.75, -0.02, side * 2.3, -0.11),
bandGeometry(path, side * s(1.75), s(-0.02), side * s(2.3), s(-0.11)),
batterMaterial,
);
}
for (const side of [-1, 1] as const) {
batch.add(
"freeway:shoulder",
roadRibbonGeometry(offsetPath(path, side * 0.64), 1.18, 0.004),
roadRibbonGeometry(offsetPath(path, side * s(0.64)), s(1.18), s(0.004)),
batch.material("deck", shoulder[identityIndex] ?? shoulder[0]!),
);
batch.add(
"freeway:carriageway",
roadRibbonGeometry(offsetPath(path, side * 0.64), 1.03, 0.012),
roadRibbonGeometry(offsetPath(path, side * s(0.64)), s(1.03), s(0.012)),
batch.material("deck", asphalt[identityIndex] ?? asphalt[0]!),
);
/**
@@ -673,18 +717,18 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
*/
batch.add(
"freeway:edge-line",
roadRibbonGeometry(offsetPath(path, side * 0.12), 0.026, 0.038),
roadRibbonGeometry(offsetPath(path, side * s(0.12)), s(0.026), s(0.038)),
batch.material("marking", 0xf0c84f),
);
batch.add(
"freeway:edge-line",
roadRibbonGeometry(offsetPath(path, side * 1.16), 0.026, 0.038),
roadRibbonGeometry(offsetPath(path, side * s(1.16)), s(0.026), s(0.038)),
batch.material("marking", 0xe8ece8),
);
const dashes = batch.material("marking", 0xf4f4ec);
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.47, 0.022), dashes);
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.81, 0.022), dashes);
const guardPath = offsetPath(path, side * 1.27);
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * s(0.47), s(0.022)), dashes);
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * s(0.81), s(0.022)), dashes);
const guardPath = offsetPath(path, side * s(1.27));
/**
* One tubular segment per draped sample, and three sides, not five.
*
@@ -709,16 +753,16 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
*/
batch.add(
"freeway:outer-guardrail",
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length), 0.025, 3, false),
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length), s(0.025), 3, false),
guardMaterial,
);
}
// Low concrete median walls keep both carriageways visually independent.
for (const side of [-1, 1] as const) {
const medianPath = offsetPath(path, side * 0.075).map((point) => point.clone().setY(point.y + 0.065));
const medianPath = offsetPath(path, side * s(0.075)).map((point) => point.clone().setY(point.y + s(0.065)));
batch.add(
"freeway:median-barrier",
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length), 0.055, 3, false),
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length), s(0.055), 3, false),
barrierMaterial,
);
}
@@ -737,9 +781,9 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
for (const pointIndex of reflectorPoints.keys()) {
const point = reflectorPoints[pointIndex];
if (!point) continue;
for (const offset of [-0.81, -0.47, 0.47, 0.81]) {
for (const offset of [-0.81, -0.47, 0.47, 0.81].map(s)) {
const shifted = offsetPath(path, offset)[pointIndex * reflectorStride] ?? point;
dummy.position.set(shifted.x, shifted.y + 0.055, shifted.z);
dummy.position.set(shifted.x, shifted.y + s(0.055), shifted.z);
dummy.rotation.set(0, 0, 0);
dummy.scale.setScalar(1);
dummy.updateMatrix();
@@ -753,7 +797,9 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
const sample = sampleRoute(routePath, feature.distanceM);
const [x, z] = world.project(sample.lat, sample.lng);
const heading = (sample.headingDeg * Math.PI) / 180;
const sceneSetback = feature.kind === "route-sign" ? 1.42 : 1.7 + feature.setbackM * 0.014;
const sceneSetback = metre
? u(pavedM / 2 + feature.setbackM)
: feature.kind === "route-sign" ? 1.42 : 1.7 + feature.setbackM * 0.014;
const px = x + Math.cos(heading) * sceneSetback * feature.side;
const pz = z + Math.sin(heading) * sceneSetback * feature.side;
const ground = world.groundAt(sample.lat, sample.lng);
@@ -762,19 +808,21 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
// Nine signs used to be nine groups of two meshes; they are now two
// meshes for the whole route, and the shield's own name survives on the
// board so the scene graph still says which route it belongs to.
const post = new THREE.BoxGeometry(0.035, 0.62, 0.035);
post.translate(px, ground + 0.08 + 0.31, pz);
const post = new THREE.BoxGeometry(s(0.035), s(0.62), s(0.035));
post.translate(px, ground + s(0.08) + s(0.31), pz);
batch.add("freeway:sign-post", post, guardMaterial, { cast: true });
const board = new THREE.PlaneGeometry(0.42, 0.31);
const board = new THREE.PlaneGeometry(s(0.42), s(0.31));
board.rotateY(-heading + (feature.side === 1 ? Math.PI : 0));
board.translate(px, ground + 0.08 + 0.69, pz);
board.translate(px, ground + s(0.08) + s(0.69), pz);
batch.add(`freeway:sign:${route.shield}`, board, shieldMaterial);
continue;
}
const visualScale = feature.scale * 0.58;
const halfHeight = feature.kind === "power-pole" ? 0.36 : feature.kind === "silo" ? 0.275 : 0.225;
dummy.position.set(px, ground + halfHeight * visualScale, pz);
const visualScale = metre ? feature.scale : feature.scale * 0.58;
const halfHeight = metre
? feature.kind === "power-pole" ? u(4.5) : feature.kind === "silo" ? u(4) : u(2.75)
: feature.kind === "power-pole" ? 0.36 : feature.kind === "silo" ? 0.275 : 0.225;
dummy.position.set(px, ground + halfHeight * (metre ? 1 : visualScale), pz);
dummy.rotation.set(0, heading + feature.scale, 0);
dummy.scale.setScalar(visualScale);
dummy.updateMatrix();
@@ -782,8 +830,12 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
else if (feature.kind === "silo") silos.setMatrixAt(siloCount++, dummy.matrix);
else {
trunks.setMatrixAt(trunkCount++, dummy.matrix);
dummy.position.y += 0.25 * feature.scale;
dummy.scale.set(feature.scale * 0.7, feature.scale * 0.5, feature.scale * 0.62);
dummy.position.y += metre ? u(4) * feature.scale : 0.25 * feature.scale;
dummy.scale.set(
metre ? visualScale : feature.scale * 0.7,
metre ? visualScale * 0.7 : feature.scale * 0.5,
metre ? visualScale * 0.85 : feature.scale * 0.62,
);
dummy.updateMatrix();
if (feature.kind === "oak") coastalCrowns.setMatrixAt(coastalCrownCount++, dummy.matrix);
else orchardCrowns.setMatrixAt(orchardCrownCount++, dummy.matrix);
+37
View File
@@ -105,4 +105,41 @@ describe("freeway world v2", () => {
assert.ok(drawCalls <= 25, `freeway world emits ${drawCalls} draw calls`);
assert.ok(materials.size <= 20, `freeway world holds ${materials.size} materials`);
});
it("draws a 44 m berm in metre scale, not a 4.7 km glyph", () => {
const mpu = 111_320 / CALIFORNIA_CITY.latScale;
const world = {
city: CALIFORNIA_CITY,
metresPerUnit: mpu,
project(lat: number, lng: number): [number, number] {
return [(lng + 121) * 20, -(lat - 36) * 20];
},
groundAt(): number {
return 0;
},
} as unknown as World;
const group = createFreewayWorld(world, CALIFORNIA_TRANSPORT, "metre");
assert.equal(group.name, "freeway-world-metre");
const berm = group.children.find(
(child): child is THREE.Mesh => child instanceof THREE.Mesh && child.name === "freeway:berm",
);
assert.ok(berm, "metre corridor has no berm");
const pos = berm.geometry.getAttribute("position");
const widthM = Math.hypot(pos.getX(1)! - pos.getX(0)!, pos.getZ(1)! - pos.getZ(0)!) * mpu;
assert.ok(
widthM > 40 && widthM < 50,
`metre berm is ${widthM.toFixed(1)} m; US-101 is authored 44 m`,
);
const oak = group.children.find(
(child): child is THREE.InstancedMesh =>
child instanceof THREE.InstancedMesh && child.name === "freeway:coastal-oaks",
);
assert.ok(oak, "metre corridor has no oaks");
const radius = (oak.geometry as THREE.IcosahedronGeometry).parameters.radius * mpu;
assert.ok(
radius > 4 && radius < 12,
`a metre oak crown is ${radius.toFixed(1)} m; the atlas one was 500 m`,
);
});
});
+27
View File
@@ -43,6 +43,33 @@ describe("road traffic render layer", () => {
layer.dispose();
});
it("stands a true-metre car on a board that has metresPerUnit", () => {
const mpu = 1_919.3;
const world = {
metresPerUnit: mpu,
project(lat: number, lng: number): [number, number] {
return [(lng + 121) * 20, -(lat - 36) * 20];
},
groundAt(): number {
return 0;
},
} as unknown as World;
const camera = new THREE.PerspectiveCamera(42, 16 / 9, 0.1, 2_000);
const controls = {
target: new THREE.Vector3(),
enabled: true,
} as unknown as OrbitControls;
const layer = createRoadTrafficLayer(world, camera, controls, {
pack: CALIFORNIA_TRANSPORT,
routeId: "la-sf-us-101",
count: 4,
seed: 115,
});
const hero = layer.group.getObjectByName("model-x-hero")!;
assert.ok(Math.abs(hero.scale.x - 1 / mpu) < 1e-12);
layer.dispose();
});
it("switches routes and owns the orbit-control handoff while following", () => {
const { layer, camera, controls } = fixture();
layer.setRoute("la-sf-i-5");