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
+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;
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,
});
}
+28 -1
View File
@@ -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 {