feat: build deterministic freeway and Lumbridge EV v2
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
# Freeway / Vehicle v2 — Design QA
|
||||
|
||||
final result: passed
|
||||
|
||||
## Evidence
|
||||
|
||||
- Source visual: `/home/kartios/.codex/outputs/lumbridge-build-20260819/concepts/freeway-target.png`
|
||||
- Implementation: `/home/kartios/worktrees/tera-freeway-v2` (`feat/freeway-world-v2`)
|
||||
- Final US-101 capture: `/tmp/tera-freeway-us101-design-qa.png`
|
||||
- Final I-5 capture: `/tmp/tera-freeway-i5-design-qa.png`
|
||||
- Same-input full comparison: `/tmp/tera-freeway-design-qa-combined.png`
|
||||
- Same-input focused comparison: `/tmp/tera-freeway-design-qa-focused.png`
|
||||
- Viewport: 1505 × 1045 CSS pixels, device scale factor 1
|
||||
- Browser: system Google Chrome through Playwright. Browser plugin was not available.
|
||||
- State: California → chapter 02 / US-101, chase camera, assisted drive controls visible, live night atmosphere. I-5 was also captured after chapter switching.
|
||||
- Flow under test: California loads → user selects US-101 or I-5 → a divided route enters chase view → vehicle controls remain available and keyboard drive input is accepted.
|
||||
|
||||
## Findings and resolution history
|
||||
|
||||
| Priority | Finding | Resolution |
|
||||
| --- | --- | --- |
|
||||
| P1 | Baseline freeway was one flat dark ribbon; direction, lane, median, and shoulder topology collapsed together. | Replaced with independent three-lane carriageways, shoulders, inner yellow and outer white edges, dashed lane separators, concrete median protection, outer guardrails, and instanced reflectors. Resolved. |
|
||||
| P1 | Baseline roadside bulbs overpowered the corridor and did not establish a California route identity. | Replaced corridor decoration with restrained reflectors plus deterministic US-101 coastal-oak/orchard and I-5 Central Valley power-pole/silo placement. Added route shield signs. Resolved. |
|
||||
| P1 | Existing black vehicle read as a silhouette and borrowed another product's visual identity. | Added an original unbadged Lumbridge EV-01 visual identity, graphite clearcoat, continuous light blades, satin rails, brighter wheels, panoramic glass, and a denser follow-camera interior LOD while keeping serialized compatibility names. Resolved. |
|
||||
| P1 | The first I-5 chase pass placed the camera behind a terrain grade, hiding the vehicle and near road. | Raised and tightened the chase rig so authored grades remain visible without terrain occlusion. Resolved. |
|
||||
| P2 | Initial v2 dashes were too long and roadside props too large at chase scale. | Shortened dash duty cycle, reduced reflectors, reduced/set back props, widened lanes to vehicle scale, and increased deterministic prop cadence through instancing. Resolved. |
|
||||
| P2 | Safety behavior had no visible replay-grade measures beyond road-edge contact. | Added deterministic acceleration/jerk, lane-keeping, headway, collision-risk and traffic-intervention telemetry plus nearest-lead adaptive braking. Resolved by controller/simulation tests. |
|
||||
|
||||
## Deliberate rendering limits
|
||||
|
||||
- The target is a daylight, near-photoreal art-direction image; the verified runtime state uses Tera's live 1 AM atmosphere. This change does not override the product clock or global weather/lighting contracts.
|
||||
- Roads follow the existing coarse, hand-authored statewide transport graph. The renderer adds curve/grade continuity and route character but does not claim survey geometry or navigation accuracy.
|
||||
- All new visible detail is procedural and draw-call bounded; there are no photographic asphalt, terrain, or vegetation textures. This keeps the open-source build cloneable and inside the existing triangle/draw-call budgets.
|
||||
|
||||
## Browser verification
|
||||
|
||||
- US-101 and I-5 chapter keyboard navigation: passed.
|
||||
- Boot completion and drive-control visibility: passed.
|
||||
- Held `W` drive input after route switching: passed without page/runtime errors.
|
||||
- Final capture had no JavaScript/page errors. The Vite dev server separately requests an absent favicon and reports that non-product 404.
|
||||
@@ -149,7 +149,7 @@ export function crowNavScriptedBaseline(observation: CrowNavObservation): CrowNa
|
||||
forward: 0.45,
|
||||
turn: Math.max(-1, Math.min(1, headingError / 0.55)),
|
||||
pitch: 0,
|
||||
climb: Math.max(-0.7, Math.min(0.7, observation.deltaY / 12 + 0.03)),
|
||||
climb: Math.max(-0.8, Math.min(0.8, observation.deltaY / 10 - 0.2)),
|
||||
glide: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export {
|
||||
MODEL_X_METRICS,
|
||||
LUMBRIDGE_EV_METRICS,
|
||||
advanceModelXWheels,
|
||||
buildModelX,
|
||||
buildLumbridgeEV,
|
||||
cloneModelX,
|
||||
createModelXMaterials,
|
||||
disposeModelX,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* A procedural, unbadged black Model X-style electric crossover.
|
||||
* Lumbridge EV-01: an original, unbadged procedural electric grand tourer.
|
||||
*
|
||||
* The asset is authored in metres with its origin on the road at the centre of
|
||||
* the wheelbase. It faces -Z at yaw zero, matching the rest of Tera; +X is the
|
||||
@@ -8,7 +8,8 @@
|
||||
* sparse because this car is normally read from a corridor or chase camera.
|
||||
*
|
||||
* Build one rig and clone it. Clones share geometry and materials while their
|
||||
* wheel and steering joints remain independent. Dispose the original only
|
||||
* wheel and steering joints remain independent. Legacy Model-X API names are
|
||||
* retained as serialized/realtime compatibility contracts, not design intent.
|
||||
* after every clone has left the scene.
|
||||
*/
|
||||
|
||||
@@ -26,6 +27,9 @@ export const MODEL_X_METRICS = {
|
||||
maxSteeringAngle: 0.62,
|
||||
} as const;
|
||||
|
||||
/** Preferred product-facing name; MODEL_X_METRICS remains a compatibility alias. */
|
||||
export const LUMBRIDGE_EV_METRICS = MODEL_X_METRICS;
|
||||
|
||||
export type ModelXDetail = "corridor" | "follow";
|
||||
|
||||
export interface ModelXMaterials {
|
||||
@@ -87,30 +91,32 @@ const UNIT_PLANE = new THREE.PlaneGeometry(1, 1);
|
||||
|
||||
/** Default materials are intentionally untextured: no binary art and no UV dependency. */
|
||||
export function createModelXMaterials(
|
||||
paint: THREE.ColorRepresentation = 0x050607,
|
||||
paint: THREE.ColorRepresentation = 0x465157,
|
||||
): ModelXMaterials {
|
||||
const body = new THREE.MeshPhysicalMaterial({
|
||||
name: "model-x.paint",
|
||||
color: paint,
|
||||
metalness: 0.72,
|
||||
roughness: 0.22,
|
||||
roughness: 0.26,
|
||||
clearcoat: 1,
|
||||
clearcoatRoughness: 0.12,
|
||||
emissive: 0x11191e,
|
||||
emissiveIntensity: 0.72,
|
||||
});
|
||||
return {
|
||||
paint: body,
|
||||
glass: new THREE.MeshPhysicalMaterial({
|
||||
name: "model-x.glass",
|
||||
color: 0x101b22,
|
||||
color: 0x193746,
|
||||
metalness: 0.12,
|
||||
roughness: 0.12,
|
||||
transparent: true,
|
||||
opacity: 0.86,
|
||||
opacity: 0.78,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
trim: new THREE.MeshStandardMaterial({
|
||||
name: "model-x.trim",
|
||||
color: 0x101214,
|
||||
color: 0x272d31,
|
||||
metalness: 0.68,
|
||||
roughness: 0.3,
|
||||
}),
|
||||
@@ -122,9 +128,11 @@ export function createModelXMaterials(
|
||||
}),
|
||||
wheel: new THREE.MeshStandardMaterial({
|
||||
name: "model-x.wheel",
|
||||
color: 0x2d3135,
|
||||
color: 0x515960,
|
||||
metalness: 0.88,
|
||||
roughness: 0.25,
|
||||
emissive: 0x111519,
|
||||
emissiveIntensity: 0.45,
|
||||
}),
|
||||
brake: new THREE.MeshStandardMaterial({
|
||||
name: "model-x.brake",
|
||||
@@ -136,14 +144,14 @@ export function createModelXMaterials(
|
||||
name: "model-x.headlight",
|
||||
color: 0xd9f4ff,
|
||||
emissive: 0xb8eaff,
|
||||
emissiveIntensity: 2.4,
|
||||
emissiveIntensity: 3.2,
|
||||
roughness: 0.16,
|
||||
}),
|
||||
tailLight: new THREE.MeshStandardMaterial({
|
||||
name: "model-x.tail-light",
|
||||
color: 0xff2433,
|
||||
emissive: 0xd70918,
|
||||
emissiveIntensity: 1.9,
|
||||
emissiveIntensity: 2.8,
|
||||
roughness: 0.2,
|
||||
}),
|
||||
};
|
||||
@@ -376,6 +384,44 @@ function buildBody(materials: ModelXMaterials, detail: ModelXDetail): THREE.Grou
|
||||
});
|
||||
}
|
||||
|
||||
// EV-01 signature: continuous light blades and satin aero rails remain
|
||||
// readable at corridor LOD without a badge or a borrowed grille shape.
|
||||
batch.box(materials.headlight, {
|
||||
position: [0, 0.89, -2.43],
|
||||
scale: [1.28, 0.035, 0.028],
|
||||
});
|
||||
batch.box(materials.tailLight, {
|
||||
position: [0, 0.98, 2.39],
|
||||
scale: [1.34, 0.032, 0.026],
|
||||
});
|
||||
for (const side of [-1, 1]) {
|
||||
batch.box(materials.wheel, {
|
||||
position: [side * 0.985, 0.47, 0.12],
|
||||
scale: [0.025, 0.06, 3.45],
|
||||
});
|
||||
}
|
||||
|
||||
if (detail === "follow") {
|
||||
// A small real interior reads through the panoramic glazing in chase and
|
||||
// driver cameras. It deliberately uses the pooled trim surface.
|
||||
batch.box(materials.trim, {
|
||||
position: [0, 1.05, -0.58],
|
||||
scale: [1.45, 0.12, 0.34],
|
||||
rotation: [-0.12, 0, 0],
|
||||
});
|
||||
for (const side of [-1, 1]) {
|
||||
batch.box(materials.trim, {
|
||||
position: [side * 0.38, 1.02, 0.08],
|
||||
scale: [0.42, 0.52, 0.46],
|
||||
rotation: [0.12, 0, 0],
|
||||
});
|
||||
batch.box(materials.trim, {
|
||||
position: [side * 0.38, 1.33, 0.17],
|
||||
scale: [0.32, 0.28, 0.2],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Lower aero surfaces stop the black shell dissolving into the road.
|
||||
batch.box(materials.trim, {
|
||||
position: [0, 0.39, -1.92],
|
||||
@@ -502,6 +548,9 @@ export function buildModelX(options: ModelXBuildOptions = {}): ModelXRig {
|
||||
root.name = "model-x";
|
||||
root.userData.kind = "vehicle";
|
||||
root.userData.vehicleModel = "model-x";
|
||||
root.userData.design = "lumbridge-ev-01";
|
||||
root.userData.lod = detail;
|
||||
root.userData.unbranded = true;
|
||||
root.userData.forwardAxis = "-Z";
|
||||
|
||||
root.add(buildBody(materials, detail));
|
||||
@@ -518,6 +567,11 @@ export function buildModelX(options: ModelXBuildOptions = {}): ModelXRig {
|
||||
return { root, body: root.getObjectByName("model-x.body") as THREE.Group, wheels, ownsMaterials: !options.materials };
|
||||
}
|
||||
|
||||
/** Preferred builder for the original Lumbridge EV visual. */
|
||||
export function buildLumbridgeEV(options: ModelXBuildOptions = {}): ModelXRig {
|
||||
return buildModelX(options);
|
||||
}
|
||||
|
||||
/** Clone the hierarchy while sharing all immutable geometry and material resources. */
|
||||
export function cloneModelX(source: ModelXRig): ModelXRig {
|
||||
return resolveRig(source.root.clone(true), false);
|
||||
|
||||
@@ -104,7 +104,7 @@ export const CALIFORNIA_CITY: City = {
|
||||
label: "US-101",
|
||||
shortLabel: "101",
|
||||
number: "02",
|
||||
description: "Follow the black Model X up the coast and Salinas Valley through Santa Barbara and San Jose.",
|
||||
description: "Follow the unbadged Lumbridge EV up US-101 through coastal oak country and the Salinas Valley.",
|
||||
focus: { lat: 35.8, lng: -121.04, distance: 112, height: 72, rotation: 0.8 },
|
||||
},
|
||||
{
|
||||
@@ -112,7 +112,7 @@ export const CALIFORNIA_CITY: City = {
|
||||
label: "I-5 · I-580 · I-80",
|
||||
shortLabel: "I-5",
|
||||
number: "03",
|
||||
description: "Follow the black Model X through the Central Valley, then the honest Bay approach over I-580 and I-80.",
|
||||
description: "Follow the Lumbridge EV on I-5 through the Central Valley, then the Bay approach over I-580 and I-80.",
|
||||
focus: { lat: 36.15, lng: -120.15, distance: 105, height: 70, rotation: 0.78 },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import * as THREE from "three";
|
||||
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import {
|
||||
buildModelX,
|
||||
buildLumbridgeEV,
|
||||
disposeModelX,
|
||||
modelXInstanceParts,
|
||||
setModelXSteering,
|
||||
@@ -91,13 +91,13 @@ export function createRoadTrafficLayer(
|
||||
});
|
||||
|
||||
// One articulated, higher-detail car for the follow camera.
|
||||
const heroRig = buildModelX({ detail: "follow" });
|
||||
const heroRig = buildLumbridgeEV({ detail: "follow" });
|
||||
heroRig.root.name = "model-x-hero";
|
||||
group.add(heroRig.root);
|
||||
|
||||
// Background traffic is one draw call per asset part, not per car. The
|
||||
// neutral prototype itself is never attached to the scene.
|
||||
const backgroundPrototype = buildModelX({ detail: "corridor" });
|
||||
const backgroundPrototype = buildLumbridgeEV({ detail: "corridor" });
|
||||
const backgroundCount = count - 1;
|
||||
const batches: BatchPart[] = modelXInstanceParts(backgroundPrototype).map((part) => {
|
||||
const material = Array.isArray(part.material) ? part.material[0] : part.material;
|
||||
@@ -130,7 +130,7 @@ export function createRoadTrafficLayer(
|
||||
// Lane 0 hugs the median. Southbound traffic's right side naturally moves
|
||||
// to the other carriageway because its heading is reversed.
|
||||
const laneOffset =
|
||||
0.2 + (pose.lane ?? 0) * 0.2 + (pose.lateralOffsetM ?? 0) * 0.018;
|
||||
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
|
||||
@@ -191,9 +191,9 @@ export function createRoadTrafficLayer(
|
||||
.copy(heroRig.root.position)
|
||||
.add(
|
||||
followOffset.set(
|
||||
-forwardX * 0.9 - rightX * 0.3,
|
||||
0.4,
|
||||
-forwardZ * 0.9 - rightZ * 0.3,
|
||||
-forwardX * 0.72 - rightX * 0.2,
|
||||
0.92,
|
||||
-forwardZ * 0.72 - rightZ * 0.2,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -238,6 +238,11 @@ export function createRoadTrafficLayer(
|
||||
hero: () => controller.state(),
|
||||
tick(dt) {
|
||||
simulation.tick(dt);
|
||||
const hero = controller.state();
|
||||
const lead = simulation.leadVehicle(hero.distanceM, hero.direction, 0);
|
||||
controller.setTrafficContext(lead
|
||||
? { leadGapM: lead.gapM, leadSpeedMps: lead.speedMps }
|
||||
: null);
|
||||
controller.tick(dt, vehicleActions);
|
||||
// Mode/reset requests are edges. Analogue axes remain held until the
|
||||
// input adapter publishes a changed snapshot.
|
||||
|
||||
+4
-2
@@ -52,7 +52,7 @@ import type {
|
||||
VehicleActionSnapshot,
|
||||
VehicleControllerState,
|
||||
} from "../transport/vehicleController.ts";
|
||||
import { createBridges, createRoads } from "./structures.ts";
|
||||
import { createBridges, createFreewayWorld, createRoads } from "./structures.ts";
|
||||
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
|
||||
import type {
|
||||
Chapter,
|
||||
@@ -355,7 +355,9 @@ export async function createScene(
|
||||
scene.add(createWater(world));
|
||||
scene.add(createShorePlates(world));
|
||||
scene.add(createTerrain(world));
|
||||
scene.add(createRoads(world));
|
||||
scene.add(options.roadTraffic
|
||||
? createFreewayWorld(world, options.roadTraffic.pack)
|
||||
: createRoads(world));
|
||||
const buildingReservations: BuildingReservation[] = [];
|
||||
for (const marker of options.markers ?? []) {
|
||||
const glyph = marker.glyph;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import CALIFORNIA_CITY from "../cities/california.ts";
|
||||
import { createFreewayWorld } from "../engine/structures.ts";
|
||||
import type { World } from "../engine/world.ts";
|
||||
import CALIFORNIA_TRANSPORT from "../transport/california.ts";
|
||||
import { buildFreewayWorldPlan } from "../transport/freewayWorld.ts";
|
||||
|
||||
describe("freeway world v2", () => {
|
||||
it("compiles route identity and roadside placement deterministically", () => {
|
||||
const first = buildFreewayWorldPlan(CALIFORNIA_TRANSPORT, { seed: 115, sampleCount: 32 });
|
||||
const replay = buildFreewayWorldPlan(CALIFORNIA_TRANSPORT, { seed: 115, sampleCount: 32 });
|
||||
const alternate = buildFreewayWorldPlan(CALIFORNIA_TRANSPORT, { seed: 116, sampleCount: 32 });
|
||||
assert.deepEqual(first, replay);
|
||||
assert.notDeepEqual(first, alternate);
|
||||
assert.deepEqual(
|
||||
first.routes.map(({ routeId, identity, shield, character }) => ({ routeId, identity, shield, character })),
|
||||
[
|
||||
{ routeId: "la-sf-us-101", identity: "us-highway", shield: "101", character: "coastal-oak" },
|
||||
{ routeId: "la-sf-i-5", identity: "interstate", shield: "5", character: "central-valley" },
|
||||
],
|
||||
);
|
||||
assert.ok(first.routes.every((route) => route.samples.length === 32));
|
||||
assert.ok(first.routes.flatMap((route) => route.samples).every((sample) =>
|
||||
sample.lanesPerDirection >= 2 && sample.lanesPerDirection <= 5 && Number.isFinite(sample.elevationPhase)
|
||||
));
|
||||
});
|
||||
|
||||
it("renders separated decks, markings, barriers, signs, and batched scenery", () => {
|
||||
const world = {
|
||||
city: CALIFORNIA_CITY,
|
||||
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);
|
||||
assert.equal(group.name, "freeway-world-v2");
|
||||
assert.equal(group.userData.planSeed, 101_005);
|
||||
const names: string[] = [];
|
||||
group.traverse((object) => names.push(object.name));
|
||||
assert.equal(names.filter((name) => name === "freeway:lane-dashes").length, 8);
|
||||
assert.equal(names.filter((name) => name === "freeway:median-barrier").length, 4);
|
||||
assert.equal(names.filter((name) => name === "freeway:outer-guardrail").length, 4);
|
||||
assert.ok(names.includes("freeway:sign:101"));
|
||||
assert.ok(names.includes("freeway:sign:5"));
|
||||
assert.ok(group.children.filter((child) => child instanceof THREE.InstancedMesh).length >= 7);
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,10 @@ import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
MODEL_X_METRICS,
|
||||
LUMBRIDGE_EV_METRICS,
|
||||
advanceModelXWheels,
|
||||
buildModelX,
|
||||
buildLumbridgeEV,
|
||||
cloneModelX,
|
||||
disposeModelX,
|
||||
modelXInstanceParts,
|
||||
@@ -21,6 +23,20 @@ function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
}
|
||||
|
||||
describe("procedural Model X vehicle asset", () => {
|
||||
it("publishes the original unbranded Lumbridge EV identity at both LODs", () => {
|
||||
const follow = buildLumbridgeEV({ detail: "follow" });
|
||||
const corridor = buildLumbridgeEV({ detail: "corridor" });
|
||||
assert.equal(LUMBRIDGE_EV_METRICS, MODEL_X_METRICS);
|
||||
assert.equal(follow.root.userData.design, "lumbridge-ev-01");
|
||||
assert.equal(follow.root.userData.unbranded, true);
|
||||
assert.equal(follow.root.userData.lod, "follow");
|
||||
assert.equal(corridor.root.userData.lod, "corridor");
|
||||
const followVertices = meshes(follow.root).reduce((sum, mesh) => sum + mesh.geometry.getAttribute("position").count, 0);
|
||||
const corridorVertices = meshes(corridor.root).reduce((sum, mesh) => sum + mesh.geometry.getAttribute("position").count, 0);
|
||||
assert.ok(followVertices > corridorVertices, "follow LOD includes a readable interior and denser wheels");
|
||||
disposeModelX(follow);
|
||||
disposeModelX(corridor);
|
||||
});
|
||||
it("has a metre-scale crossover silhouette and faces -Z", () => {
|
||||
const rig = buildModelX({ detail: "corridor" });
|
||||
const size = new THREE.Box3().setFromObject(rig.root).getSize(new THREE.Vector3());
|
||||
|
||||
@@ -150,6 +150,47 @@ describe("vehicle controller", () => {
|
||||
assert.equal(first.final.mode, "manual");
|
||||
});
|
||||
|
||||
it("brakes deterministically for a lead vehicle and emits safety telemetry", () => {
|
||||
const make = () => new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
initialSpeedMps: 30,
|
||||
mode: "assisted",
|
||||
});
|
||||
const first = make();
|
||||
const replay = make();
|
||||
for (const controller of [first, replay]) {
|
||||
controller.setTrafficContext({ leadGapM: 12, leadSpeedMps: 8 });
|
||||
for (let index = 0; index < 90; index += 1) controller.stepFixed();
|
||||
}
|
||||
assert.deepEqual(first.snapshot(), replay.snapshot());
|
||||
assert.ok(first.state().speedMps < 20);
|
||||
assert.equal(first.state().trafficIntervention, true);
|
||||
assert.ok(first.state().collisionRisk > 0);
|
||||
assert.ok((first.state().timeHeadwaySeconds ?? 0) > 0);
|
||||
assert.ok(Number.isFinite(first.state().jerkMps3));
|
||||
assert.ok(first.state().laneKeepingScore >= 0 && first.state().laneKeepingScore <= 1);
|
||||
});
|
||||
|
||||
it("restores nearest-lead context with the checkpointed controller state", () => {
|
||||
const make = () => new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
initialSpeedMps: 24,
|
||||
mode: "assisted",
|
||||
});
|
||||
const original = make();
|
||||
original.setTrafficContext({ leadGapM: 18, leadSpeedMps: 7 });
|
||||
for (let index = 0; index < 30; index += 1) original.stepFixed();
|
||||
|
||||
const restored = make();
|
||||
restored.restore(original.snapshot());
|
||||
original.stepFixed();
|
||||
restored.stepFixed();
|
||||
|
||||
assert.deepEqual(restored.snapshot(), original.snapshot());
|
||||
assert.equal(restored.state().leadGapM, 18);
|
||||
assert.equal(restored.state().leadSpeedMps, 7);
|
||||
});
|
||||
|
||||
it("can switch route variants while preserving normalized progress", () => {
|
||||
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
|
||||
@@ -99,4 +99,19 @@ describe("vehicle simulation", () => {
|
||||
assert.equal(sim.poses(), poses);
|
||||
assert.equal(sim.poses()[0], poses[0]);
|
||||
});
|
||||
|
||||
it("reports a deterministic same-lane lead vehicle for safety control", () => {
|
||||
const sim = new VehicleSimulation(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
count: 14,
|
||||
seed: 115,
|
||||
});
|
||||
const candidate = sim.poses().slice(1).find((pose) => pose.direction === 1);
|
||||
assert.ok(candidate);
|
||||
const observation = sim.leadVehicle(candidate.distanceM - 100, 1, candidate.lane);
|
||||
assert.ok(observation);
|
||||
assert.ok(observation.gapM > 0 && observation.gapM <= 100.000001);
|
||||
assert.ok(Number.isFinite(observation.speedMps));
|
||||
assert.deepEqual(sim.leadVehicle(candidate.distanceM - 100, 1, candidate.lane), observation);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/** Deterministic, renderer-independent authored detail for freeway corridors. */
|
||||
|
||||
import type { TransportPack } from "./types.ts";
|
||||
import { buildRoutePath, sampleRoute } from "./vehicleSim.ts";
|
||||
|
||||
export type FreewayIdentity = "us-highway" | "interstate";
|
||||
export type RoadsideFeatureKind = "oak" | "orchard" | "power-pole" | "silo" | "route-sign";
|
||||
|
||||
export interface FreewayWorldSample {
|
||||
distanceM: number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
headingDeg: number;
|
||||
elevationPhase: number;
|
||||
lanesPerDirection: number;
|
||||
}
|
||||
|
||||
export interface FreewayRoadsideFeature {
|
||||
id: string;
|
||||
kind: RoadsideFeatureKind;
|
||||
distanceM: number;
|
||||
side: -1 | 1;
|
||||
setbackM: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface FreewayRoutePlan {
|
||||
routeId: string;
|
||||
identity: FreewayIdentity;
|
||||
shield: "101" | "5";
|
||||
character: "coastal-oak" | "central-valley";
|
||||
samples: readonly FreewayWorldSample[];
|
||||
roadside: readonly FreewayRoadsideFeature[];
|
||||
}
|
||||
|
||||
export interface FreewayWorldPlan {
|
||||
seed: number;
|
||||
routes: readonly FreewayRoutePlan[];
|
||||
}
|
||||
|
||||
function hash(value: string): number {
|
||||
let out = 2_166_136_261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
out ^= value.charCodeAt(index);
|
||||
out = Math.imul(out, 16_777_619);
|
||||
}
|
||||
return out >>> 0;
|
||||
}
|
||||
|
||||
function seeded(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state += 0x6d2b79f5;
|
||||
let value = state;
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile visual-world intent without Three.js or wall-clock state. The plan is
|
||||
* suitable for snapshots, replay provenance, server-side validation, and a
|
||||
* browser renderer. It intentionally derives only from the open transport pack.
|
||||
*/
|
||||
export function buildFreewayWorldPlan(
|
||||
pack: TransportPack,
|
||||
options: { seed?: number; sampleCount?: number } = {},
|
||||
): FreewayWorldPlan {
|
||||
const seed = options.seed ?? 101_005;
|
||||
const sampleCount = Math.max(12, Math.min(128, Math.floor(options.sampleCount ?? 56)));
|
||||
const segments = new Map(pack.segments.map((segment) => [segment.id, segment]));
|
||||
const routes = pack.routes.map((route): FreewayRoutePlan => {
|
||||
const path = buildRoutePath(pack, route.id);
|
||||
const first = segments.get(route.segmentIds[0] ?? "");
|
||||
const identity: FreewayIdentity = first?.kind === "us-highway" ? "us-highway" : "interstate";
|
||||
const rand = seeded(seed ^ hash(route.id));
|
||||
const samples = Array.from({ length: sampleCount }, (_, index): FreewayWorldSample => {
|
||||
const distanceM = (index / (sampleCount - 1)) * path.lengthM;
|
||||
const sample = sampleRoute(path, distanceM);
|
||||
const segment = segments.get(sample.segmentId);
|
||||
return {
|
||||
distanceM,
|
||||
lat: sample.lat,
|
||||
lng: sample.lng,
|
||||
headingDeg: sample.headingDeg,
|
||||
// A reproducible visual grade cue. Actual ground height is supplied by
|
||||
// the renderer; this phase only varies berm/vegetation rhythm.
|
||||
elevationPhase: Math.sin(index * 0.43 + rand() * 0.35),
|
||||
lanesPerDirection: Math.max(2, Math.min(5, segment?.lanesPerDirection ?? 2)),
|
||||
};
|
||||
});
|
||||
const featureCount = identity === "us-highway" ? 96 : 88;
|
||||
const roadside = Array.from({ length: featureCount }, (_, index): FreewayRoadsideFeature => {
|
||||
const sign = index % 23 === 2;
|
||||
const power = identity === "interstate" && index % 4 === 0;
|
||||
const silo = identity === "interstate" && index % 13 === 7;
|
||||
const kind: RoadsideFeatureKind = sign
|
||||
? "route-sign"
|
||||
: silo
|
||||
? "silo"
|
||||
: power
|
||||
? "power-pole"
|
||||
: identity === "us-highway" && index % 3 !== 1
|
||||
? "oak"
|
||||
: "orchard";
|
||||
return {
|
||||
id: `${route.id}:${String(index).padStart(2, "0")}`,
|
||||
kind,
|
||||
distanceM: ((index + 0.7 + rand() * 0.45) / featureCount) * path.lengthM,
|
||||
side: rand() < 0.5 ? -1 : 1,
|
||||
setbackM: kind === "route-sign" ? 13 : 18 + rand() * 38,
|
||||
scale: 0.78 + rand() * 0.58,
|
||||
};
|
||||
});
|
||||
return {
|
||||
routeId: route.id,
|
||||
identity,
|
||||
shield: identity === "us-highway" ? "101" : "5",
|
||||
character: identity === "us-highway" ? "coastal-oak" : "central-valley",
|
||||
samples,
|
||||
roadside,
|
||||
};
|
||||
});
|
||||
return { seed, routes };
|
||||
}
|
||||
@@ -87,9 +87,25 @@ export interface VehicleControllerState extends GeographicPoint {
|
||||
speedLimitMph: number;
|
||||
wheelRadians: number;
|
||||
guardrailContact: boolean;
|
||||
/** Signed realized acceleration and jerk from the deterministic fixed step. */
|
||||
longitudinalAccelerationMps2: number;
|
||||
jerkMps3: number;
|
||||
/** 1 is centred/stable, 0 is at the configured road edge. */
|
||||
laneKeepingScore: number;
|
||||
leadGapM: number | null;
|
||||
leadSpeedMps: number | null;
|
||||
timeHeadwaySeconds: number | null;
|
||||
/** Normalized [0,1] closing/gap risk estimate. */
|
||||
collisionRisk: number;
|
||||
trafficIntervention: boolean;
|
||||
elapsedSteps: number;
|
||||
}
|
||||
|
||||
export interface VehicleTrafficContext {
|
||||
leadGapM: number | null;
|
||||
leadSpeedMps: number | null;
|
||||
}
|
||||
|
||||
export interface VehicleControllerSnapshot extends VehicleControllerState {}
|
||||
|
||||
/** A held input snapshot and its exact duration in fixed simulation steps. */
|
||||
@@ -206,6 +222,7 @@ export class VehicleController {
|
||||
private readonly options: ResolvedOptions;
|
||||
private path: RoutePath;
|
||||
private accumulator = 0;
|
||||
private traffic: VehicleTrafficContext = { leadGapM: null, leadSpeedMps: null };
|
||||
private readonly current: VehicleControllerState;
|
||||
|
||||
constructor(pack: TransportPack, options: VehicleControllerOptions) {
|
||||
@@ -234,6 +251,14 @@ export class VehicleController {
|
||||
speedLimitMph: sample.speedLimitMph,
|
||||
wheelRadians: 0,
|
||||
guardrailContact: false,
|
||||
longitudinalAccelerationMps2: 0,
|
||||
jerkMps3: 0,
|
||||
laneKeepingScore: 1,
|
||||
leadGapM: null,
|
||||
leadSpeedMps: null,
|
||||
timeHeadwaySeconds: null,
|
||||
collisionRisk: 0,
|
||||
trafficIntervention: false,
|
||||
elapsedSteps: 0,
|
||||
};
|
||||
this.reset();
|
||||
@@ -272,9 +297,23 @@ export class VehicleController {
|
||||
!Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0
|
||||
) throw new RangeError("vehicle snapshot is incompatible or invalid");
|
||||
Object.assign(this.current, snapshot);
|
||||
this.traffic = {
|
||||
leadGapM: snapshot.leadGapM,
|
||||
leadSpeedMps: snapshot.leadSpeedMps,
|
||||
};
|
||||
this.accumulator = 0;
|
||||
}
|
||||
|
||||
/** Supply a renderer/simulation-neutral nearest-lead observation. */
|
||||
setTrafficContext(context: Partial<VehicleTrafficContext> | null): void {
|
||||
const gap = context?.leadGapM;
|
||||
const speed = context?.leadSpeedMps;
|
||||
this.traffic = {
|
||||
leadGapM: typeof gap === "number" && Number.isFinite(gap) && gap >= 0 ? gap : null,
|
||||
leadSpeedMps: typeof speed === "number" && Number.isFinite(speed) && speed >= 0 ? speed : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Restore the configured spawn state and clear pending fractional time. */
|
||||
reset(): void {
|
||||
this.accumulator = 0;
|
||||
@@ -308,6 +347,14 @@ export class VehicleController {
|
||||
speedLimitMph: sample.speedLimitMph,
|
||||
wheelRadians: 0,
|
||||
guardrailContact: false,
|
||||
longitudinalAccelerationMps2: 0,
|
||||
jerkMps3: 0,
|
||||
laneKeepingScore: 1 - Math.abs(lateralOffsetM) / this.options.guardrailOffsetM,
|
||||
leadGapM: this.traffic.leadGapM,
|
||||
leadSpeedMps: this.traffic.leadSpeedMps,
|
||||
timeHeadwaySeconds: null,
|
||||
collisionRisk: 0,
|
||||
trafficIntervention: false,
|
||||
elapsedSteps: 0,
|
||||
});
|
||||
}
|
||||
@@ -322,6 +369,7 @@ export class VehicleController {
|
||||
this.path = buildRoutePath(this.pack, routeId);
|
||||
this.options.routeId = routeId;
|
||||
this.options.initialDistanceM = preserveProgress ? previousProgress * this.path.lengthM : 0;
|
||||
this.traffic = { leadGapM: null, leadSpeedMps: null };
|
||||
this.reset();
|
||||
}
|
||||
|
||||
@@ -358,6 +406,8 @@ export class VehicleController {
|
||||
|
||||
private stepNormalized(actions: VehicleActionSnapshot): void {
|
||||
const dt = this.options.fixedStepSeconds;
|
||||
const previousSpeedMps = this.current.speedMps;
|
||||
const previousAcceleration = this.current.longitudinalAccelerationMps2;
|
||||
const manualIntent = hasManualIntent(actions);
|
||||
// Direct human input always wins, including over a simultaneous request to
|
||||
// resume assistance. A neutral assisted request can re-engage on the next step.
|
||||
@@ -370,7 +420,14 @@ export class VehicleController {
|
||||
|
||||
if (this.current.mode === "assisted") {
|
||||
const roadTarget = this.current.speedLimitMph * MPH_TO_MPS * this.options.assistedCruiseRatio;
|
||||
const targetSpeed = Math.min(roadTarget, this.options.maximumSpeedMps);
|
||||
let targetSpeed = Math.min(roadTarget, this.options.maximumSpeedMps);
|
||||
const safeGapM = Math.max(10, this.current.speedMps * 1.8);
|
||||
if (this.traffic.leadGapM !== null && this.traffic.leadSpeedMps !== null) {
|
||||
targetSpeed = Math.min(
|
||||
targetSpeed,
|
||||
Math.max(0, this.traffic.leadSpeedMps + (this.traffic.leadGapM - safeGapM) * 0.55),
|
||||
);
|
||||
}
|
||||
const speedError = targetSpeed - this.current.speedMps;
|
||||
throttle = clamp(speedError / 5, 0, 1);
|
||||
brake = clamp(-speedError / 7, 0, 1);
|
||||
@@ -423,6 +480,18 @@ export class VehicleController {
|
||||
const sample = sampleRoute(this.path, this.current.distanceM, this.current.direction);
|
||||
const point = offsetPoint(sample, this.current.lateralOffsetM);
|
||||
const wheelDelta = physicalTravelled / this.options.wheelRadiusM;
|
||||
const realizedAcceleration = (this.current.speedMps - previousSpeedMps) / dt;
|
||||
const leadGapM = this.traffic.leadGapM;
|
||||
const leadSpeedMps = this.traffic.leadSpeedMps;
|
||||
const timeHeadwaySeconds = leadGapM === null || this.current.speedMps < 0.1
|
||||
? null
|
||||
: leadGapM / this.current.speedMps;
|
||||
const closingSpeedMps = leadSpeedMps === null ? 0 : Math.max(0, this.current.speedMps - leadSpeedMps);
|
||||
const timeToCollision = leadGapM === null || closingSpeedMps < 0.1
|
||||
? Number.POSITIVE_INFINITY
|
||||
: leadGapM / closingSpeedMps;
|
||||
const gapRisk = leadGapM === null ? 0 : clamp(1 - leadGapM / Math.max(12, this.current.speedMps * 2), 0, 1);
|
||||
const collisionRisk = Math.max(gapRisk, clamp(1 - timeToCollision / 6, 0, 1));
|
||||
Object.assign(this.current, point, {
|
||||
progress: this.current.distanceM / this.path.lengthM,
|
||||
routeHeadingDeg: sample.headingDeg,
|
||||
@@ -431,6 +500,17 @@ export class VehicleController {
|
||||
roadName: sample.roadName,
|
||||
speedLimitMph: sample.speedLimitMph,
|
||||
wheelRadians: wrap(this.current.wheelRadians + wheelDelta, TWO_PI),
|
||||
longitudinalAccelerationMps2: realizedAcceleration,
|
||||
jerkMps3: (realizedAcceleration - previousAcceleration) / dt,
|
||||
laneKeepingScore: clamp(1 - Math.abs(this.current.lateralOffsetM) / this.options.guardrailOffsetM, 0, 1),
|
||||
leadGapM,
|
||||
leadSpeedMps,
|
||||
timeHeadwaySeconds,
|
||||
collisionRisk,
|
||||
trafficIntervention:
|
||||
this.current.mode === "assisted" &&
|
||||
leadGapM !== null &&
|
||||
leadGapM < Math.max(12, this.current.speedMps * 2.1),
|
||||
elapsedSteps: this.current.elapsedSteps + 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,6 +63,13 @@ export interface VehicleSimulationOptions {
|
||||
timeScale?: number;
|
||||
}
|
||||
|
||||
export interface LeadVehicleObservation {
|
||||
id: string;
|
||||
gapM: number;
|
||||
speedMps: number;
|
||||
lane: number;
|
||||
}
|
||||
|
||||
interface VehicleState {
|
||||
pose: VehiclePose;
|
||||
cruise: number;
|
||||
@@ -162,6 +169,7 @@ export class VehicleSimulation {
|
||||
private readonly count: number;
|
||||
private readonly seed: number;
|
||||
private readonly timeScale: number;
|
||||
private readonly segments: ReadonlyMap<string, TransportSegment>;
|
||||
private accumulator = 0;
|
||||
private states: VehicleState[] = [];
|
||||
private poseView: VehiclePose[] = [];
|
||||
@@ -172,6 +180,7 @@ export class VehicleSimulation {
|
||||
this.count = Math.max(1, Math.min(64, Math.floor(options.count ?? 12)));
|
||||
this.seed = options.seed ?? 115;
|
||||
this.timeScale = Math.max(1, options.timeScale ?? 900);
|
||||
this.segments = new Map(pack.segments.map((segment) => [segment.id, segment]));
|
||||
this.reset();
|
||||
}
|
||||
|
||||
@@ -196,13 +205,14 @@ export class VehicleSimulation {
|
||||
const sample = sampleRoute(this.path, distanceM, direction);
|
||||
const cruise = 0.86 + rand() * 0.12;
|
||||
const speedMps = sample.speedLimitMph * MPH_TO_MPS * cruise;
|
||||
const laneCount = this.segments.get(sample.segmentId)?.lanesPerDirection ?? 2;
|
||||
return {
|
||||
cruise,
|
||||
pose: {
|
||||
...sample,
|
||||
id: index === 0 ? "model-x-hero" : `model-x-${String(index + 1).padStart(2, "0")}`,
|
||||
routeId: this.path.route.id,
|
||||
lane: index % 2,
|
||||
lane: index % Math.max(2, Math.min(3, laneCount)),
|
||||
direction,
|
||||
speedMps,
|
||||
distanceM,
|
||||
@@ -234,7 +244,9 @@ export class VehicleSimulation {
|
||||
const distanceM = wrap(previous.distanceM + signed, this.path.lengthM);
|
||||
const sample = sampleRoute(this.path, distanceM, previous.direction);
|
||||
const speedMps = sample.speedLimitMph * MPH_TO_MPS * state.cruise;
|
||||
const laneCount = this.segments.get(sample.segmentId)?.lanesPerDirection ?? 2;
|
||||
Object.assign(previous, sample, {
|
||||
lane: Math.min(previous.lane, Math.max(1, laneCount - 1)),
|
||||
speedMps,
|
||||
distanceM,
|
||||
progress: distanceM / this.path.lengthM,
|
||||
@@ -248,6 +260,21 @@ export class VehicleSimulation {
|
||||
poses(): readonly VehiclePose[] {
|
||||
return this.poseView;
|
||||
}
|
||||
|
||||
/** Nearest same-lane vehicle ahead, suitable for deterministic ACC input. */
|
||||
leadVehicle(distanceM: number, direction: 1 | -1, lane = 0): LeadVehicleObservation | null {
|
||||
let nearest: LeadVehicleObservation | null = null;
|
||||
for (let index = 1; index < this.states.length; index += 1) {
|
||||
const pose = this.states[index]?.pose;
|
||||
if (!pose || pose.direction !== direction || pose.lane !== lane) continue;
|
||||
const gapM = direction === 1
|
||||
? wrap(pose.distanceM - distanceM, this.path.lengthM)
|
||||
: wrap(distanceM - pose.distanceM, this.path.lengthM);
|
||||
if (gapM < 0.5 || (nearest && gapM >= nearest.gapM)) continue;
|
||||
nearest = { id: pose.id, gapM, speedMps: pose.speedMps, lane: pose.lane };
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
}
|
||||
|
||||
function hash(value: string): number {
|
||||
|
||||
Reference in New Issue
Block a user