From 019ff331483ca9d1f5d0c5061c52ce92db7d9e61 Mon Sep 17 00:00:00 2001 From: Kartios Date: Mon, 24 Aug 2026 22:14:31 -0700 Subject: [PATCH] feat: a true-metre corridor next to the atlas glyph, and a camera that can see the car MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/engine/roadTraffic.ts | 38 ++++++++--- src/engine/scene.ts | 70 +++++++++++++------- src/engine/structures.ts | 116 ++++++++++++++++++++++++---------- src/test/freewayWorld.test.ts | 37 +++++++++++ src/test/roadTraffic.test.ts | 27 ++++++++ 5 files changed, 223 insertions(+), 65 deletions(-) diff --git a/src/engine/roadTraffic.ts b/src/engine/roadTraffic.ts index 44fb425..27afc88 100644 --- a/src/engine/roadTraffic.ts +++ b/src/engine/roadTraffic.ts @@ -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, ), ); } diff --git a/src/engine/scene.ts b/src/engine/scene.ts index 3c9b03c..72ab673 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -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) { diff --git a/src/engine/structures.ts b/src/engine/structures.ts index 58bee3d..6bdd139 100644 --- a/src/engine/structures.ts +++ b/src/engine/structures.ts @@ -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); diff --git a/src/test/freewayWorld.test.ts b/src/test/freewayWorld.test.ts index 223929a..1ea51d1 100644 --- a/src/test/freewayWorld.test.ts +++ b/src/test/freewayWorld.test.ts @@ -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`, + ); + }); }); diff --git a/src/test/roadTraffic.test.ts b/src/test/roadTraffic.test.ts index f2fc33a..8bd3fe5 100644 --- a/src/test/roadTraffic.test.ts +++ b/src/test/roadTraffic.test.ts @@ -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");