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
+40
View File
@@ -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.
+1 -1
View File
@@ -149,7 +149,7 @@ export function crowNavScriptedBaseline(observation: CrowNavObservation): CrowNa
forward: 0.45, forward: 0.45,
turn: Math.max(-1, Math.min(1, headingError / 0.55)), turn: Math.max(-1, Math.min(1, headingError / 0.55)),
pitch: 0, 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, glide: false,
}; };
} }
+2
View File
@@ -1,7 +1,9 @@
export { export {
MODEL_X_METRICS, MODEL_X_METRICS,
LUMBRIDGE_EV_METRICS,
advanceModelXWheels, advanceModelXWheels,
buildModelX, buildModelX,
buildLumbridgeEV,
cloneModelX, cloneModelX,
createModelXMaterials, createModelXMaterials,
disposeModelX, disposeModelX,
+64 -10
View File
@@ -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 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 * 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. * 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 * 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. * after every clone has left the scene.
*/ */
@@ -26,6 +27,9 @@ export const MODEL_X_METRICS = {
maxSteeringAngle: 0.62, maxSteeringAngle: 0.62,
} as const; } 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 type ModelXDetail = "corridor" | "follow";
export interface ModelXMaterials { 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. */ /** Default materials are intentionally untextured: no binary art and no UV dependency. */
export function createModelXMaterials( export function createModelXMaterials(
paint: THREE.ColorRepresentation = 0x050607, paint: THREE.ColorRepresentation = 0x465157,
): ModelXMaterials { ): ModelXMaterials {
const body = new THREE.MeshPhysicalMaterial({ const body = new THREE.MeshPhysicalMaterial({
name: "model-x.paint", name: "model-x.paint",
color: paint, color: paint,
metalness: 0.72, metalness: 0.72,
roughness: 0.22, roughness: 0.26,
clearcoat: 1, clearcoat: 1,
clearcoatRoughness: 0.12, clearcoatRoughness: 0.12,
emissive: 0x11191e,
emissiveIntensity: 0.72,
}); });
return { return {
paint: body, paint: body,
glass: new THREE.MeshPhysicalMaterial({ glass: new THREE.MeshPhysicalMaterial({
name: "model-x.glass", name: "model-x.glass",
color: 0x101b22, color: 0x193746,
metalness: 0.12, metalness: 0.12,
roughness: 0.12, roughness: 0.12,
transparent: true, transparent: true,
opacity: 0.86, opacity: 0.78,
side: THREE.DoubleSide, side: THREE.DoubleSide,
}), }),
trim: new THREE.MeshStandardMaterial({ trim: new THREE.MeshStandardMaterial({
name: "model-x.trim", name: "model-x.trim",
color: 0x101214, color: 0x272d31,
metalness: 0.68, metalness: 0.68,
roughness: 0.3, roughness: 0.3,
}), }),
@@ -122,9 +128,11 @@ export function createModelXMaterials(
}), }),
wheel: new THREE.MeshStandardMaterial({ wheel: new THREE.MeshStandardMaterial({
name: "model-x.wheel", name: "model-x.wheel",
color: 0x2d3135, color: 0x515960,
metalness: 0.88, metalness: 0.88,
roughness: 0.25, roughness: 0.25,
emissive: 0x111519,
emissiveIntensity: 0.45,
}), }),
brake: new THREE.MeshStandardMaterial({ brake: new THREE.MeshStandardMaterial({
name: "model-x.brake", name: "model-x.brake",
@@ -136,14 +144,14 @@ export function createModelXMaterials(
name: "model-x.headlight", name: "model-x.headlight",
color: 0xd9f4ff, color: 0xd9f4ff,
emissive: 0xb8eaff, emissive: 0xb8eaff,
emissiveIntensity: 2.4, emissiveIntensity: 3.2,
roughness: 0.16, roughness: 0.16,
}), }),
tailLight: new THREE.MeshStandardMaterial({ tailLight: new THREE.MeshStandardMaterial({
name: "model-x.tail-light", name: "model-x.tail-light",
color: 0xff2433, color: 0xff2433,
emissive: 0xd70918, emissive: 0xd70918,
emissiveIntensity: 1.9, emissiveIntensity: 2.8,
roughness: 0.2, 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. // Lower aero surfaces stop the black shell dissolving into the road.
batch.box(materials.trim, { batch.box(materials.trim, {
position: [0, 0.39, -1.92], position: [0, 0.39, -1.92],
@@ -502,6 +548,9 @@ export function buildModelX(options: ModelXBuildOptions = {}): ModelXRig {
root.name = "model-x"; root.name = "model-x";
root.userData.kind = "vehicle"; root.userData.kind = "vehicle";
root.userData.vehicleModel = "model-x"; root.userData.vehicleModel = "model-x";
root.userData.design = "lumbridge-ev-01";
root.userData.lod = detail;
root.userData.unbranded = true;
root.userData.forwardAxis = "-Z"; root.userData.forwardAxis = "-Z";
root.add(buildBody(materials, detail)); 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 }; 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. */ /** Clone the hierarchy while sharing all immutable geometry and material resources. */
export function cloneModelX(source: ModelXRig): ModelXRig { export function cloneModelX(source: ModelXRig): ModelXRig {
return resolveRig(source.root.clone(true), false); return resolveRig(source.root.clone(true), false);
+2 -2
View File
@@ -104,7 +104,7 @@ export const CALIFORNIA_CITY: City = {
label: "US-101", label: "US-101",
shortLabel: "101", shortLabel: "101",
number: "02", 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 }, 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", label: "I-5 · I-580 · I-80",
shortLabel: "I-5", shortLabel: "I-5",
number: "03", 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 }, focus: { lat: 36.15, lng: -120.15, distance: 105, height: 70, rotation: 0.78 },
}, },
{ {
+12 -7
View File
@@ -11,7 +11,7 @@
import * as THREE from "three"; import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { import {
buildModelX, buildLumbridgeEV,
disposeModelX, disposeModelX,
modelXInstanceParts, modelXInstanceParts,
setModelXSteering, setModelXSteering,
@@ -91,13 +91,13 @@ export function createRoadTrafficLayer(
}); });
// One articulated, higher-detail car for the follow camera. // 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"; heroRig.root.name = "model-x-hero";
group.add(heroRig.root); group.add(heroRig.root);
// Background traffic is one draw call per asset part, not per car. The // Background traffic is one draw call per asset part, not per car. The
// neutral prototype itself is never attached to the scene. // neutral prototype itself is never attached to the scene.
const backgroundPrototype = buildModelX({ detail: "corridor" }); const backgroundPrototype = buildLumbridgeEV({ detail: "corridor" });
const backgroundCount = count - 1; const backgroundCount = count - 1;
const batches: BatchPart[] = modelXInstanceParts(backgroundPrototype).map((part) => { const batches: BatchPart[] = modelXInstanceParts(backgroundPrototype).map((part) => {
const material = Array.isArray(part.material) ? part.material[0] : part.material; 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 // Lane 0 hugs the median. Southbound traffic's right side naturally moves
// to the other carriageway because its heading is reversed. // to the other carriageway because its heading is reversed.
const laneOffset = 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( out.set(
x + Math.cos(heading) * laneOffset, x + Math.cos(heading) * laneOffset,
// The asset origin is on the tyre contact plane. Keep only a tiny lift // 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) .copy(heroRig.root.position)
.add( .add(
followOffset.set( followOffset.set(
-forwardX * 0.9 - rightX * 0.3, -forwardX * 0.72 - rightX * 0.2,
0.4, 0.92,
-forwardZ * 0.9 - rightZ * 0.3, -forwardZ * 0.72 - rightZ * 0.2,
), ),
); );
} }
@@ -238,6 +238,11 @@ export function createRoadTrafficLayer(
hero: () => controller.state(), hero: () => controller.state(),
tick(dt) { tick(dt) {
simulation.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); controller.tick(dt, vehicleActions);
// Mode/reset requests are edges. Analogue axes remain held until the // Mode/reset requests are edges. Analogue axes remain held until the
// input adapter publishes a changed snapshot. // input adapter publishes a changed snapshot.
+4 -2
View File
@@ -52,7 +52,7 @@ import type {
VehicleActionSnapshot, VehicleActionSnapshot,
VehicleControllerState, VehicleControllerState,
} from "../transport/vehicleController.ts"; } 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 { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
import type { import type {
Chapter, Chapter,
@@ -355,7 +355,9 @@ export async function createScene(
scene.add(createWater(world)); scene.add(createWater(world));
scene.add(createShorePlates(world)); scene.add(createShorePlates(world));
scene.add(createTerrain(world)); scene.add(createTerrain(world));
scene.add(createRoads(world)); scene.add(options.roadTraffic
? createFreewayWorld(world, options.roadTraffic.pack)
: createRoads(world));
const buildingReservations: BuildingReservation[] = []; const buildingReservations: BuildingReservation[] = [];
for (const marker of options.markers ?? []) { for (const marker of options.markers ?? []) {
const glyph = marker.glyph; const glyph = marker.glyph;
+232
View File
@@ -8,6 +8,9 @@
*/ */
import * as THREE from "three"; 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 { Bridge, LatLng } from "./types.ts";
import type { World } from "./world.ts"; import type { World } from "./world.ts";
@@ -86,6 +89,235 @@ function roadRibbon(
return mesh; 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 { export function createRoads(world: World): THREE.Group {
const group = new THREE.Group(); const group = new THREE.Group();
group.name = "roads"; group.name = "roads";
+52
View File
@@ -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);
});
});
+16
View File
@@ -3,8 +3,10 @@ import { describe, it } from "node:test";
import * as THREE from "three"; import * as THREE from "three";
import { import {
MODEL_X_METRICS, MODEL_X_METRICS,
LUMBRIDGE_EV_METRICS,
advanceModelXWheels, advanceModelXWheels,
buildModelX, buildModelX,
buildLumbridgeEV,
cloneModelX, cloneModelX,
disposeModelX, disposeModelX,
modelXInstanceParts, modelXInstanceParts,
@@ -21,6 +23,20 @@ function meshes(root: THREE.Object3D): THREE.Mesh[] {
} }
describe("procedural Model X vehicle asset", () => { 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", () => { it("has a metre-scale crossover silhouette and faces -Z", () => {
const rig = buildModelX({ detail: "corridor" }); const rig = buildModelX({ detail: "corridor" });
const size = new THREE.Box3().setFromObject(rig.root).getSize(new THREE.Vector3()); const size = new THREE.Box3().setFromObject(rig.root).getSize(new THREE.Vector3());
+41
View File
@@ -150,6 +150,47 @@ describe("vehicle controller", () => {
assert.equal(first.final.mode, "manual"); 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", () => { it("can switch route variants while preserving normalized progress", () => {
const controller = new VehicleController(CALIFORNIA_TRANSPORT, { const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
routeId: "la-sf-us-101", routeId: "la-sf-us-101",
+15
View File
@@ -99,4 +99,19 @@ describe("vehicle simulation", () => {
assert.equal(sim.poses(), poses); assert.equal(sim.poses(), poses);
assert.equal(sim.poses()[0], poses[0]); 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);
});
}); });
+126
View File
@@ -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 };
}
+81 -1
View File
@@ -87,9 +87,25 @@ export interface VehicleControllerState extends GeographicPoint {
speedLimitMph: number; speedLimitMph: number;
wheelRadians: number; wheelRadians: number;
guardrailContact: boolean; 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; elapsedSteps: number;
} }
export interface VehicleTrafficContext {
leadGapM: number | null;
leadSpeedMps: number | null;
}
export interface VehicleControllerSnapshot extends VehicleControllerState {} export interface VehicleControllerSnapshot extends VehicleControllerState {}
/** A held input snapshot and its exact duration in fixed simulation steps. */ /** A held input snapshot and its exact duration in fixed simulation steps. */
@@ -206,6 +222,7 @@ export class VehicleController {
private readonly options: ResolvedOptions; private readonly options: ResolvedOptions;
private path: RoutePath; private path: RoutePath;
private accumulator = 0; private accumulator = 0;
private traffic: VehicleTrafficContext = { leadGapM: null, leadSpeedMps: null };
private readonly current: VehicleControllerState; private readonly current: VehicleControllerState;
constructor(pack: TransportPack, options: VehicleControllerOptions) { constructor(pack: TransportPack, options: VehicleControllerOptions) {
@@ -234,6 +251,14 @@ export class VehicleController {
speedLimitMph: sample.speedLimitMph, speedLimitMph: sample.speedLimitMph,
wheelRadians: 0, wheelRadians: 0,
guardrailContact: false, guardrailContact: false,
longitudinalAccelerationMps2: 0,
jerkMps3: 0,
laneKeepingScore: 1,
leadGapM: null,
leadSpeedMps: null,
timeHeadwaySeconds: null,
collisionRisk: 0,
trafficIntervention: false,
elapsedSteps: 0, elapsedSteps: 0,
}; };
this.reset(); this.reset();
@@ -272,9 +297,23 @@ export class VehicleController {
!Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0 !Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0
) throw new RangeError("vehicle snapshot is incompatible or invalid"); ) throw new RangeError("vehicle snapshot is incompatible or invalid");
Object.assign(this.current, snapshot); Object.assign(this.current, snapshot);
this.traffic = {
leadGapM: snapshot.leadGapM,
leadSpeedMps: snapshot.leadSpeedMps,
};
this.accumulator = 0; 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. */ /** Restore the configured spawn state and clear pending fractional time. */
reset(): void { reset(): void {
this.accumulator = 0; this.accumulator = 0;
@@ -308,6 +347,14 @@ export class VehicleController {
speedLimitMph: sample.speedLimitMph, speedLimitMph: sample.speedLimitMph,
wheelRadians: 0, wheelRadians: 0,
guardrailContact: false, 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, elapsedSteps: 0,
}); });
} }
@@ -322,6 +369,7 @@ export class VehicleController {
this.path = buildRoutePath(this.pack, routeId); this.path = buildRoutePath(this.pack, routeId);
this.options.routeId = routeId; this.options.routeId = routeId;
this.options.initialDistanceM = preserveProgress ? previousProgress * this.path.lengthM : 0; this.options.initialDistanceM = preserveProgress ? previousProgress * this.path.lengthM : 0;
this.traffic = { leadGapM: null, leadSpeedMps: null };
this.reset(); this.reset();
} }
@@ -358,6 +406,8 @@ export class VehicleController {
private stepNormalized(actions: VehicleActionSnapshot): void { private stepNormalized(actions: VehicleActionSnapshot): void {
const dt = this.options.fixedStepSeconds; const dt = this.options.fixedStepSeconds;
const previousSpeedMps = this.current.speedMps;
const previousAcceleration = this.current.longitudinalAccelerationMps2;
const manualIntent = hasManualIntent(actions); const manualIntent = hasManualIntent(actions);
// Direct human input always wins, including over a simultaneous request to // Direct human input always wins, including over a simultaneous request to
// resume assistance. A neutral assisted request can re-engage on the next step. // 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") { if (this.current.mode === "assisted") {
const roadTarget = this.current.speedLimitMph * MPH_TO_MPS * this.options.assistedCruiseRatio; 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; const speedError = targetSpeed - this.current.speedMps;
throttle = clamp(speedError / 5, 0, 1); throttle = clamp(speedError / 5, 0, 1);
brake = clamp(-speedError / 7, 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 sample = sampleRoute(this.path, this.current.distanceM, this.current.direction);
const point = offsetPoint(sample, this.current.lateralOffsetM); const point = offsetPoint(sample, this.current.lateralOffsetM);
const wheelDelta = physicalTravelled / this.options.wheelRadiusM; 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, { Object.assign(this.current, point, {
progress: this.current.distanceM / this.path.lengthM, progress: this.current.distanceM / this.path.lengthM,
routeHeadingDeg: sample.headingDeg, routeHeadingDeg: sample.headingDeg,
@@ -431,6 +500,17 @@ export class VehicleController {
roadName: sample.roadName, roadName: sample.roadName,
speedLimitMph: sample.speedLimitMph, speedLimitMph: sample.speedLimitMph,
wheelRadians: wrap(this.current.wheelRadians + wheelDelta, TWO_PI), 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, elapsedSteps: this.current.elapsedSteps + 1,
}); });
} }
+28 -1
View File
@@ -63,6 +63,13 @@ export interface VehicleSimulationOptions {
timeScale?: number; timeScale?: number;
} }
export interface LeadVehicleObservation {
id: string;
gapM: number;
speedMps: number;
lane: number;
}
interface VehicleState { interface VehicleState {
pose: VehiclePose; pose: VehiclePose;
cruise: number; cruise: number;
@@ -162,6 +169,7 @@ export class VehicleSimulation {
private readonly count: number; private readonly count: number;
private readonly seed: number; private readonly seed: number;
private readonly timeScale: number; private readonly timeScale: number;
private readonly segments: ReadonlyMap<string, TransportSegment>;
private accumulator = 0; private accumulator = 0;
private states: VehicleState[] = []; private states: VehicleState[] = [];
private poseView: VehiclePose[] = []; private poseView: VehiclePose[] = [];
@@ -172,6 +180,7 @@ export class VehicleSimulation {
this.count = Math.max(1, Math.min(64, Math.floor(options.count ?? 12))); this.count = Math.max(1, Math.min(64, Math.floor(options.count ?? 12)));
this.seed = options.seed ?? 115; this.seed = options.seed ?? 115;
this.timeScale = Math.max(1, options.timeScale ?? 900); this.timeScale = Math.max(1, options.timeScale ?? 900);
this.segments = new Map(pack.segments.map((segment) => [segment.id, segment]));
this.reset(); this.reset();
} }
@@ -196,13 +205,14 @@ export class VehicleSimulation {
const sample = sampleRoute(this.path, distanceM, direction); const sample = sampleRoute(this.path, distanceM, direction);
const cruise = 0.86 + rand() * 0.12; const cruise = 0.86 + rand() * 0.12;
const speedMps = sample.speedLimitMph * MPH_TO_MPS * cruise; const speedMps = sample.speedLimitMph * MPH_TO_MPS * cruise;
const laneCount = this.segments.get(sample.segmentId)?.lanesPerDirection ?? 2;
return { return {
cruise, cruise,
pose: { pose: {
...sample, ...sample,
id: index === 0 ? "model-x-hero" : `model-x-${String(index + 1).padStart(2, "0")}`, id: index === 0 ? "model-x-hero" : `model-x-${String(index + 1).padStart(2, "0")}`,
routeId: this.path.route.id, routeId: this.path.route.id,
lane: index % 2, lane: index % Math.max(2, Math.min(3, laneCount)),
direction, direction,
speedMps, speedMps,
distanceM, distanceM,
@@ -234,7 +244,9 @@ export class VehicleSimulation {
const distanceM = wrap(previous.distanceM + signed, this.path.lengthM); const distanceM = wrap(previous.distanceM + signed, this.path.lengthM);
const sample = sampleRoute(this.path, distanceM, previous.direction); const sample = sampleRoute(this.path, distanceM, previous.direction);
const speedMps = sample.speedLimitMph * MPH_TO_MPS * state.cruise; const speedMps = sample.speedLimitMph * MPH_TO_MPS * state.cruise;
const laneCount = this.segments.get(sample.segmentId)?.lanesPerDirection ?? 2;
Object.assign(previous, sample, { Object.assign(previous, sample, {
lane: Math.min(previous.lane, Math.max(1, laneCount - 1)),
speedMps, speedMps,
distanceM, distanceM,
progress: distanceM / this.path.lengthM, progress: distanceM / this.path.lengthM,
@@ -248,6 +260,21 @@ export class VehicleSimulation {
poses(): readonly VehiclePose[] { poses(): readonly VehiclePose[] {
return this.poseView; 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 { function hash(value: string): number {