feat: build deterministic freeway and Lumbridge EV v2
This commit is contained in:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user