1
0

feat: build deterministic freeway and Lumbridge EV v2

This commit is contained in:
2026-08-19 01:12:47 -07:00
parent 94de2d8bee
commit ddf814a657
15 changed files with 716 additions and 24 deletions
+232
View File
@@ -8,6 +8,9 @@
*/
import * as THREE from "three";
import { buildFreewayWorldPlan } from "../transport/freewayWorld.ts";
import type { TransportPack } from "../transport/types.ts";
import { buildRoutePath, sampleRoute } from "../transport/vehicleSim.ts";
import type { Bridge, LatLng } from "./types.ts";
import type { World } from "./world.ts";
@@ -86,6 +89,235 @@ function roadRibbon(
return mesh;
}
function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vector3[] {
return points.map((point, index) => {
const previous = points[Math.max(0, index - 1)] ?? point;
const next = points[Math.min(points.length - 1, index + 1)] ?? point;
const dx = next.x - previous.x;
const dz = next.z - previous.z;
const length = Math.hypot(dx, dz) || 1;
return new THREE.Vector3(point.x + (-dz / length) * offset, point.y, point.z + (dx / length) * offset);
});
}
/** Merge alternating path spans into one dashed marking mesh. */
function dashedRibbon(
points: readonly THREE.Vector3[],
offset: number,
width: number,
color: number,
): THREE.Mesh {
const shifted = offsetPath(points, offset);
const positions: number[] = [];
const indices: number[] = [];
for (let index = 0; index < shifted.length - 1; index += 2) {
const a = shifted[index];
const spanEnd = shifted[Math.min(index + 1, shifted.length - 1)];
if (!a || !spanEnd) continue;
const b = a.clone().lerp(spanEnd, 0.44);
const dx = b.x - a.x;
const dz = b.z - a.z;
const length = Math.hypot(dx, dz) || 1;
const nx = (-dz / length) * width / 2;
const nz = (dx / length) * width / 2;
const base = positions.length / 3;
positions.push(
a.x + nx, a.y + 0.035, a.z + nz,
a.x - nx, a.y + 0.035, a.z - nz,
b.x + nx, b.y + 0.035, b.z + nz,
b.x - nx, b.y + 0.035, b.z - nz,
);
indices.push(base, base + 2, base + 1, base + 1, base + 2, base + 3);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshBasicMaterial({ color, toneMapped: false, side: THREE.DoubleSide }),
);
mesh.name = "freeway:lane-dashes";
return mesh;
}
function makeShieldMaterial(identity: "us-highway" | "interstate", shield: string): THREE.Material {
if (typeof document === "undefined") {
return new THREE.MeshBasicMaterial({ color: identity === "interstate" ? 0x2d5b8c : 0xe8edf0 });
}
const canvas = document.createElement("canvas");
canvas.width = 256;
canvas.height = 192;
const context = canvas.getContext("2d");
if (!context) return new THREE.MeshBasicMaterial({ color: 0xe8edf0 });
context.fillStyle = identity === "interstate" ? "#174b80" : "#f4f5ef";
context.fillRect(6, 6, 244, 180);
context.lineWidth = 12;
context.strokeStyle = identity === "interstate" ? "#f3f5f7" : "#151b20";
context.strokeRect(6, 6, 244, 180);
context.fillStyle = identity === "interstate" ? "#f3f5f7" : "#151b20";
context.font = "700 52px ui-monospace, monospace";
context.textAlign = "center";
context.fillText(identity === "interstate" ? "INTERSTATE" : "US", 128, 63);
context.font = "800 86px ui-monospace, monospace";
context.fillText(shield, 128, 151);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return new THREE.MeshBasicMaterial({ map: texture, toneMapped: false, side: THREE.DoubleSide });
}
/**
* 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.
*/
export function createFreewayWorld(world: World, pack: TransportPack): THREE.Group {
const group = new THREE.Group();
group.name = "freeway-world-v2";
const plan = buildFreewayWorldPlan(pack);
group.userData.planSeed = plan.seed;
const asphalt = [0x353a3d, 0x303538];
const shoulder = [0x555759, 0x4e5153];
const berm = [0x64705c, 0x74674c];
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 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 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);
const orchardCrowns = new THREE.InstancedMesh(crownGeometry, treeCrownMaterials[1]!, roadsideFeatures);
const poles = new THREE.InstancedMesh(poleGeometry, guardMaterial, roadsideFeatures);
const silos = new THREE.InstancedMesh(siloGeometry, barrierMaterial, roadsideFeatures);
trunks.name = "freeway:roadside-trunks";
coastalCrowns.name = "freeway:coastal-oaks";
orchardCrowns.name = "freeway:orchards";
poles.name = "freeway:power-poles";
silos.name = "freeway:valley-silos";
let trunkCount = 0;
let coastalCrownCount = 0;
let orchardCrownCount = 0;
let poleCount = 0;
let siloCount = 0;
const dummy = new THREE.Object3D();
world.city.roads.forEach((road, roadIndex) => {
if (road.kind !== "freeway") return;
const path = drapePath(world, road.path, 52, 0.115);
const route = plan.routes[roadIndex];
const identityIndex = route?.identity === "interstate" ? 1 : 0;
const routePath = route ? buildRoutePath(pack, route.routeId) : null;
// Broad earthwork under separate decks makes grade and curve changes read.
group.add(roadRibbon(path, 2.75, berm[identityIndex] ?? berm[0]!, -0.09));
for (const side of [-1, 1] as const) {
group.add(roadRibbon(offsetPath(path, side * 0.64), 1.18, shoulder[identityIndex] ?? shoulder[0]!, 0.004));
group.add(roadRibbon(offsetPath(path, side * 0.64), 1.03, asphalt[identityIndex] ?? asphalt[0]!, 0.012));
// Inner yellow edge, two lane dividers, outer white shoulder edge.
group.add(roadRibbon(offsetPath(path, side * 0.12), 0.026, 0xf0c84f, 0.038));
group.add(roadRibbon(offsetPath(path, side * 1.16), 0.026, 0xe8ece8, 0.038));
group.add(dashedRibbon(path, side * 0.47, 0.022, 0xf4f4ec));
group.add(dashedRibbon(path, side * 0.81, 0.022, 0xf4f4ec));
const guardPath = offsetPath(path, side * 1.27);
const guard = new THREE.Mesh(
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length * 2), 0.025, 5, false),
guardMaterial,
);
guard.name = "freeway:outer-guardrail";
guard.castShadow = true;
group.add(guard);
}
// 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 median = new THREE.Mesh(
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length * 2), 0.055, 4, false),
barrierMaterial,
);
median.name = "freeway:median-barrier";
group.add(median);
}
// Retroreflectors are instanced and restrained, never roadside light blobs.
const reflectorPoints = path.filter((_, index) => index % 2 === 0);
const reflectors = new THREE.InstancedMesh(reflectorGeometry, reflectorMaterial, reflectorPoints.length * 4);
reflectors.name = "freeway:reflectors";
let reflectorIndex = 0;
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]) {
const shifted = offsetPath(path, offset)[pointIndex * 2] ?? point;
dummy.position.set(shifted.x, shifted.y + 0.055, shifted.z);
dummy.rotation.set(0, 0, 0);
dummy.scale.setScalar(1);
dummy.updateMatrix();
reflectors.setMatrixAt(reflectorIndex++, dummy.matrix);
}
}
reflectors.count = reflectorIndex;
group.add(reflectors);
if (!route || !routePath) return;
const shieldMaterial = makeShieldMaterial(route.identity, route.shield);
for (const feature of route.roadside) {
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 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);
if (feature.kind === "route-sign") {
const sign = new THREE.Group();
sign.name = `freeway:sign:${route.shield}`;
const post = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.62, 0.035), guardMaterial);
post.position.y = 0.31;
const board = new THREE.Mesh(new THREE.PlaneGeometry(0.42, 0.31), shieldMaterial);
board.position.y = 0.69;
board.rotation.y = -heading + (feature.side === 1 ? Math.PI : 0);
sign.add(post, board);
sign.position.set(px, ground + 0.08, pz);
sign.userData.routeId = route.routeId;
group.add(sign);
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);
dummy.rotation.set(0, heading + feature.scale, 0);
dummy.scale.setScalar(visualScale);
dummy.updateMatrix();
if (feature.kind === "power-pole") poles.setMatrixAt(poleCount++, dummy.matrix);
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.updateMatrix();
if (feature.kind === "oak") coastalCrowns.setMatrixAt(coastalCrownCount++, dummy.matrix);
else orchardCrowns.setMatrixAt(orchardCrownCount++, dummy.matrix);
}
}
});
trunks.count = trunkCount;
poles.count = poleCount;
silos.count = siloCount;
coastalCrowns.count = coastalCrownCount;
orchardCrowns.count = orchardCrownCount;
group.add(trunks, coastalCrowns, orchardCrowns, poles, silos);
return group;
}
export function createRoads(world: World): THREE.Group {
const group = new THREE.Group();
group.name = "roads";