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:
+28
-10
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user