1
0

California gets roads, traffic, and a car to follow

This commit is contained in:
2026-08-11 18:24:53 -07:00
parent 9c9e78f6f9
commit fe58290728
43 changed files with 5593 additions and 68 deletions
+380
View File
@@ -0,0 +1,380 @@
/**
* The first California transport corridor: two coarse LA-to-Bay itineraries.
*
* This is simulation geometry, not a navigation dataset. Coordinates were
* placed by hand at recognisable cities and junctions, with long road sections
* represented by one straight edge. In particular, I-5 does not enter San
* Francisco: that itinerary names its real Bay approach over I-580 and I-80.
*/
import type {
TransportAnchor,
TransportNode,
TransportPack,
TransportRoute,
TransportSegment,
} from "./types.ts";
const AUTHORED_NODE =
"Original coarse waypoint authored by the Tera project from general California geography; approximate and not for navigation.";
const AUTHORED_ROAD =
"Original coarse simulation segment authored by the Tera project; road identity and representative speed/lanes are approximate, not live navigation data.";
const INTERNAL_OFFICE =
"Transition coordinate mirrors Tera's authored office site catalogue; it is project data, not copied map geometry.";
export const CALIFORNIA_TRANSPORT_NODES: readonly TransportNode[] = [
{
id: "los-angeles",
label: "Los Angeles",
kind: "terminus",
position: { lat: 34.0522, lng: -118.2437 },
provenance: AUTHORED_NODE,
},
// US-101: the coast and Salinas Valley approach.
{
id: "ventura",
label: "Ventura",
kind: "waypoint",
position: { lat: 34.2805, lng: -119.2945 },
provenance: AUTHORED_NODE,
},
{
id: "santa-barbara",
label: "Santa Barbara",
kind: "waypoint",
position: { lat: 34.4208, lng: -119.6982 },
provenance: AUTHORED_NODE,
},
{
id: "santa-maria",
label: "Santa Maria",
kind: "waypoint",
position: { lat: 34.953, lng: -120.4357 },
provenance: AUTHORED_NODE,
},
{
id: "san-luis-obispo",
label: "San Luis Obispo",
kind: "waypoint",
position: { lat: 35.2828, lng: -120.6596 },
provenance: AUTHORED_NODE,
},
{
id: "paso-robles",
label: "Paso Robles",
kind: "waypoint",
position: { lat: 35.626, lng: -120.691 },
provenance: AUTHORED_NODE,
},
{
id: "king-city",
label: "King City",
kind: "waypoint",
position: { lat: 36.2127, lng: -121.126 },
provenance: AUTHORED_NODE,
},
{
id: "salinas",
label: "Salinas",
kind: "waypoint",
position: { lat: 36.6777, lng: -121.6555 },
provenance: AUTHORED_NODE,
},
{
id: "gilroy",
label: "Gilroy",
kind: "waypoint",
position: { lat: 37.0058, lng: -121.5683 },
provenance: AUTHORED_NODE,
},
{
id: "san-jose",
label: "San Jose",
kind: "junction",
position: { lat: 37.3382, lng: -121.8863 },
provenance: AUTHORED_NODE,
},
{
id: "redwood-city",
label: "Redwood City",
kind: "waypoint",
position: { lat: 37.4852, lng: -122.2364 },
provenance: AUTHORED_NODE,
},
// I-5: Central Valley, followed by an explicitly named Bay connector.
{
id: "santa-clarita",
label: "Santa Clarita",
kind: "waypoint",
position: { lat: 34.3917, lng: -118.5426 },
provenance: AUTHORED_NODE,
},
{
id: "grapevine",
label: "Grapevine",
kind: "waypoint",
position: { lat: 34.9416, lng: -118.929 },
provenance: AUTHORED_NODE,
},
{
id: "lost-hills",
label: "Lost Hills",
kind: "waypoint",
position: { lat: 35.6166, lng: -119.6943 },
provenance: AUTHORED_NODE,
},
{
id: "coalinga-interchange",
label: "Coalinga / SR-198",
kind: "junction",
position: { lat: 36.253, lng: -120.237 },
provenance: AUTHORED_NODE,
},
{
id: "santa-nella",
label: "Santa Nella",
kind: "junction",
position: { lat: 37.102, lng: -121.016 },
provenance: AUTHORED_NODE,
},
{
id: "tracy",
label: "Tracy",
kind: "junction",
position: { lat: 37.7397, lng: -121.4252 },
provenance: AUTHORED_NODE,
},
{
id: "altamont-pass",
label: "Altamont Pass",
kind: "waypoint",
position: { lat: 37.696, lng: -121.686 },
provenance: AUTHORED_NODE,
},
{
id: "dublin",
label: "Dublin",
kind: "waypoint",
position: { lat: 37.7022, lng: -121.9358 },
provenance: AUTHORED_NODE,
},
{
id: "oakland",
label: "Oakland",
kind: "junction",
position: { lat: 37.8044, lng: -122.2712 },
provenance: AUTHORED_NODE,
},
{
id: "bay-bridge",
label: "San Francisco-Oakland Bay Bridge",
kind: "junction",
position: { lat: 37.7983, lng: -122.3778 },
provenance: AUTHORED_NODE,
},
{
id: "san-francisco",
label: "San Francisco",
kind: "terminus",
position: { lat: 37.7749, lng: -122.4194 },
provenance: AUTHORED_NODE,
},
];
export const CALIFORNIA_TRANSPORT_SEGMENTS: readonly TransportSegment[] = [
// US-101 route.
["us101-la-ventura", "los-angeles", "ventura", 65, 3],
["us101-ventura-santa-barbara", "ventura", "santa-barbara", 65, 2],
["us101-santa-barbara-santa-maria", "santa-barbara", "santa-maria", 65, 2],
["us101-santa-maria-san-luis-obispo", "santa-maria", "san-luis-obispo", 65, 2],
["us101-san-luis-obispo-paso-robles", "san-luis-obispo", "paso-robles", 65, 2],
["us101-paso-robles-king-city", "paso-robles", "king-city", 65, 2],
["us101-king-city-salinas", "king-city", "salinas", 65, 2],
["us101-salinas-gilroy", "salinas", "gilroy", 65, 2],
["us101-gilroy-san-jose", "gilroy", "san-jose", 65, 3],
["us101-san-jose-redwood-city", "san-jose", "redwood-city", 65, 4],
["us101-redwood-city-san-francisco", "redwood-city", "san-francisco", 65, 4],
].map(([id, fromNodeId, toNodeId, speedLimitMph, lanesPerDirection]) => ({
id: id as string,
fromNodeId: fromNodeId as string,
toNodeId: toNodeId as string,
roadName: "US-101",
kind: "us-highway" as const,
speedLimitMph: speedLimitMph as number,
lanesPerDirection: lanesPerDirection as number,
provenance: AUTHORED_ROAD,
}));
const INTERSTATE_SEGMENTS: readonly TransportSegment[] = [
["i5-la-santa-clarita", "los-angeles", "santa-clarita", 65, 4],
["i5-santa-clarita-grapevine", "santa-clarita", "grapevine", 65, 3],
["i5-grapevine-lost-hills", "grapevine", "lost-hills", 70, 2],
["i5-lost-hills-coalinga", "lost-hills", "coalinga-interchange", 70, 2],
["i5-coalinga-santa-nella", "coalinga-interchange", "santa-nella", 70, 2],
["i5-santa-nella-tracy", "santa-nella", "tracy", 70, 3],
].map(([id, fromNodeId, toNodeId, speedLimitMph, lanesPerDirection]) => ({
id: id as string,
fromNodeId: fromNodeId as string,
toNodeId: toNodeId as string,
roadName: "I-5",
kind: "interstate" as const,
speedLimitMph: speedLimitMph as number,
lanesPerDirection: lanesPerDirection as number,
provenance: AUTHORED_ROAD,
}));
const BAY_CONNECTOR_SEGMENTS: readonly TransportSegment[] = [
{
id: "i580-tracy-altamont",
fromNodeId: "tracy",
toNodeId: "altamont-pass",
roadName: "I-580",
kind: "connector",
speedLimitMph: 65,
lanesPerDirection: 3,
provenance: AUTHORED_ROAD,
},
{
id: "i580-altamont-dublin",
fromNodeId: "altamont-pass",
toNodeId: "dublin",
roadName: "I-580",
kind: "connector",
speedLimitMph: 65,
lanesPerDirection: 4,
provenance: AUTHORED_ROAD,
},
{
id: "i580-dublin-oakland",
fromNodeId: "dublin",
toNodeId: "oakland",
roadName: "I-580",
kind: "connector",
speedLimitMph: 65,
lanesPerDirection: 4,
provenance: AUTHORED_ROAD,
},
{
id: "i80-oakland-bay-bridge",
fromNodeId: "oakland",
toNodeId: "bay-bridge",
roadName: "I-80 / Bay Bridge",
kind: "connector",
speedLimitMph: 50,
lanesPerDirection: 5,
provenance: AUTHORED_ROAD,
},
{
id: "i80-bay-bridge-san-francisco",
fromNodeId: "bay-bridge",
toNodeId: "san-francisco",
roadName: "I-80",
kind: "connector",
speedLimitMph: 50,
lanesPerDirection: 5,
provenance: AUTHORED_ROAD,
},
];
export const CALIFORNIA_I5_SEGMENTS: readonly TransportSegment[] = [
...INTERSTATE_SEGMENTS,
...BAY_CONNECTOR_SEGMENTS,
];
const US_101_SEGMENT_IDS = CALIFORNIA_TRANSPORT_SEGMENTS.map((segment) => segment.id);
const I_5_SEGMENT_IDS = CALIFORNIA_I5_SEGMENTS.map((segment) => segment.id);
export const CALIFORNIA_TRANSPORT_ROUTES: readonly TransportRoute[] = [
{
id: "la-sf-us-101",
label: "Los Angeles to San Francisco via US-101",
description: "The coastal and Salinas Valley route through Santa Barbara and San Jose.",
fromNodeId: "los-angeles",
toNodeId: "san-francisco",
segmentIds: US_101_SEGMENT_IDS,
provenance: AUTHORED_ROAD,
},
{
id: "la-sf-i-5",
label: "Los Angeles to San Francisco via I-5, I-580, and I-80",
description:
"The Central Valley route, leaving I-5 at Tracy for I-580 and I-80 across the Bay Bridge.",
fromNodeId: "los-angeles",
toNodeId: "san-francisco",
segmentIds: I_5_SEGMENT_IDS,
provenance: AUTHORED_ROAD,
},
];
export const CALIFORNIA_TRANSPORT_ANCHORS: readonly TransportAnchor[] = [
{
id: "city-socal",
kind: "city",
cityId: "socal",
label: "Southern California",
nodeId: "los-angeles",
position: { lat: 34.0522, lng: -118.2437 },
provenance: AUTHORED_NODE,
},
{
id: "city-sf",
kind: "city",
cityId: "sf",
label: "San Francisco",
nodeId: "san-francisco",
position: { lat: 37.7749, lng: -122.4194 },
provenance: AUTHORED_NODE,
},
{
id: "office-mateo-court",
kind: "office",
cityId: "socal",
officeId: "mateo-court",
label: "Mateo Court",
nodeId: "los-angeles",
position: { lat: 34.0395, lng: -118.2288 },
provenance: INTERNAL_OFFICE,
},
{
id: "office-lumbridge-hq",
kind: "office",
cityId: "sf",
officeId: "lumbridge-hq",
label: "Lumbridge HQ",
nodeId: "san-francisco",
position: { lat: 37.7897, lng: -122.3972 },
provenance: INTERNAL_OFFICE,
},
{
id: "office-frontier-valley",
kind: "office",
cityId: "sf",
officeId: "frontier-valley",
label: "Frontier Valley",
nodeId: "oakland",
position: { lat: 37.7756, lng: -122.3186 },
provenance: INTERNAL_OFFICE,
},
];
/** All corridor data in one JSON-safe object. */
export const CALIFORNIA_TRANSPORT: TransportPack = {
id: "california-la-bay",
name: "California: Los Angeles to the Bay",
schemaVersion: 1,
description:
"A coarse statewide simulation corridor connecting Tera's Southern California and San Francisco scenes.",
nodes: CALIFORNIA_TRANSPORT_NODES,
segments: [...CALIFORNIA_TRANSPORT_SEGMENTS, ...CALIFORNIA_I5_SEGMENTS],
routes: CALIFORNIA_TRANSPORT_ROUTES,
anchors: CALIFORNIA_TRANSPORT_ANCHORS,
provenance: [
"Original manually authored Tera project data; no third-party map geometry is embedded.",
"Coordinates, lane counts, and speed envelopes are coarse simulation inputs and must not be used for navigation.",
"Office transition coordinates mirror the repository's own src/offices/sites.ts catalogue.",
],
};
export default CALIFORNIA_TRANSPORT;
+94
View File
@@ -0,0 +1,94 @@
/**
* Serializable contracts for transport packs.
*
* These types deliberately contain data only: no classes, dates, maps, or
* three.js values. A pack can therefore cross an HTTP boundary, live in a
* Worker, or be recorded for a deterministic replay without translation.
*/
/** A WGS84-like geographic position in decimal degrees. */
export interface GeographicPoint {
lat: number;
lng: number;
}
/** The road system responsible for a segment. */
export type RoadKind = "interstate" | "us-highway" | "connector";
/** Why a node exists in the deliberately sparse corridor graph. */
export type TransportNodeKind = "terminus" | "waypoint" | "junction";
export interface TransportNode {
id: string;
label: string;
kind: TransportNodeKind;
position: GeographicPoint;
/** Human-readable origin and accuracy note for this authored coordinate. */
provenance: string;
}
/**
* A directed driveable edge. Reverse itineraries traverse the same edge in
* reverse; geometry is intentionally not duplicated for each direction.
*/
export interface TransportSegment {
id: string;
fromNodeId: string;
toNodeId: string;
/** The name a route badge or itinerary should show. */
roadName: string;
kind: RoadKind;
/** Coarse simulation envelope, not live traffic or navigation advice. */
speedLimitMph: number;
/** Number of through lanes in one direction at the representative section. */
lanesPerDirection: number;
provenance: string;
}
/** A named, ordered itinerary through the segment graph. */
export interface TransportRoute {
id: string;
label: string;
description: string;
fromNodeId: string;
toNodeId: string;
segmentIds: readonly string[];
provenance: string;
}
interface AnchorBase {
id: string;
label: string;
/** Nearest corridor node used to enter or leave the large-scale simulation. */
nodeId: string;
/** Exact transition marker; it need not lie on the corridor centreline. */
position: GeographicPoint;
provenance: string;
}
export interface CityTransportAnchor extends AnchorBase {
kind: "city";
cityId: string;
}
export interface OfficeTransportAnchor extends AnchorBase {
kind: "office";
cityId: string;
officeId: string;
}
export type TransportAnchor = CityTransportAnchor | OfficeTransportAnchor;
/** A complete coarse world graph and its links to finer Tera scenes. */
export interface TransportPack {
id: string;
name: string;
schemaVersion: 1;
description: string;
nodes: readonly TransportNode[];
segments: readonly TransportSegment[];
routes: readonly TransportRoute[];
anchors: readonly TransportAnchor[];
/** Pack-wide authorship and fitness-for-purpose notices. */
provenance: readonly string[];
}
+436
View File
@@ -0,0 +1,436 @@
/**
* Renderer-independent solo vehicle controls for a route-relative simulation.
*
* Inputs are normalized action snapshots rather than DOM events, so keyboards,
* gamepads, touch controls, remote clients, and recorded replays all drive the
* same deterministic fixed-step state machine.
*/
import type { GeographicPoint, TransportPack } from "./types.ts";
import {
buildRoutePath,
sampleRoute,
type RoutePath,
type RouteSample,
} from "./vehicleSim.ts";
const MPH_TO_MPS = 0.44704;
const EARTH_RADIUS_M = 6_371_000;
const TWO_PI = Math.PI * 2;
export type VehicleControlMode = "assisted" | "manual";
export type VehicleModeRequest = "none" | VehicleControlMode;
/** Device-neutral actions sampled for one rendered or fixed simulation frame. */
export interface VehicleActionSnapshot {
/** Accelerator position in the inclusive range [0, 1]. */
throttle: number;
/** Service brake position in the inclusive range [0, 1]. */
brake: number;
/** Steering input where -1 is full left and 1 is full right. */
steering: number;
handbrake: boolean;
/** One-shot mode request. Meaningful even when all analogue axes are neutral. */
modeRequest: VehicleModeRequest;
/** One-shot request to restore the configured initial state. */
reset: boolean;
}
export const NEUTRAL_VEHICLE_ACTIONS: Readonly<VehicleActionSnapshot> = Object.freeze({
throttle: 0,
brake: 0,
steering: 0,
handbrake: false,
modeRequest: "none",
reset: false,
});
export interface VehicleControllerOptions {
routeId: string;
mode?: VehicleControlMode;
direction?: 1 | -1;
initialDistanceM?: number;
initialLateralOffsetM?: number;
initialSpeedMps?: number;
/** Defaults to 60 Hz and is clamped to a safe simulation range. */
fixedStepSeconds?: number;
/** Caps catch-up after a sleeping tab. Defaults to 0.25 seconds. */
maxFrameDeltaSeconds?: number;
maximumSpeedMps?: number;
assistedCruiseRatio?: number;
guardrailOffsetM?: number;
wheelRadiusM?: number;
/**
* Multiplies longitudinal route progress without changing acceleration or
* steering response. State-scale boards use compression; metre-scale roads
* leave this at 1. Defaults to 1.
*/
travelScale?: number;
}
export interface VehicleControllerState extends GeographicPoint {
routeId: string;
mode: VehicleControlMode;
direction: 1 | -1;
/** Distance from the route's declared start, wrapped to its total length. */
distanceM: number;
progress: number;
/** Signed offset from route centre; positive is to the driver's right. */
lateralOffsetM: number;
speedMps: number;
/** Smoothed normalized steering position, independent of input device. */
steering: number;
routeHeadingDeg: number;
headingDeg: number;
segmentId: string;
roadName: string;
speedLimitMph: number;
wheelRadians: number;
guardrailContact: boolean;
elapsedSteps: number;
}
export interface VehicleControllerSnapshot extends VehicleControllerState {}
/** A held input snapshot and its exact duration in fixed simulation steps. */
export interface TimedVehicleInputFrame {
steps: number;
actions?: Partial<VehicleActionSnapshot>;
}
export interface VehicleReplayResult {
/** Initial state followed by one snapshot after every simulated step. */
trajectory: readonly VehicleControllerSnapshot[];
final: VehicleControllerSnapshot;
}
interface ResolvedOptions {
routeId: string;
mode: VehicleControlMode;
direction: 1 | -1;
initialDistanceM: number;
initialLateralOffsetM: number;
initialSpeedMps: number;
fixedStepSeconds: number;
maxFrameDeltaSeconds: number;
maximumSpeedMps: number;
assistedCruiseRatio: number;
guardrailOffsetM: number;
wheelRadiusM: number;
travelScale: number;
}
function finiteOr(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function wrap(value: number, modulus: number): number {
return ((value % modulus) + modulus) % modulus;
}
function moveToward(value: number, target: number, maximumDelta: number): number {
if (value < target) return Math.min(value + maximumDelta, target);
if (value > target) return Math.max(value - maximumDelta, target);
return value;
}
/** Clamp and sanitize input from any adapter before it reaches simulation. */
export function normalizeVehicleActions(
actions: Partial<VehicleActionSnapshot> | undefined,
): VehicleActionSnapshot {
const modeRequest = actions?.modeRequest;
return {
throttle: clamp(finiteOr(actions?.throttle, 0), 0, 1),
brake: clamp(finiteOr(actions?.brake, 0), 0, 1),
steering: clamp(finiteOr(actions?.steering, 0), -1, 1),
handbrake: actions?.handbrake === true,
modeRequest: modeRequest === "manual" || modeRequest === "assisted" ? modeRequest : "none",
reset: actions?.reset === true,
};
}
function resolveOptions(options: VehicleControllerOptions): ResolvedOptions {
return {
routeId: options.routeId,
mode: options.mode === "manual" ? "manual" : "assisted",
direction: options.direction === -1 ? -1 : 1,
initialDistanceM: finiteOr(options.initialDistanceM, 0),
initialLateralOffsetM: finiteOr(options.initialLateralOffsetM, 0),
initialSpeedMps: Math.max(0, finiteOr(options.initialSpeedMps, 0)),
fixedStepSeconds: clamp(finiteOr(options.fixedStepSeconds, 1 / 60), 1 / 240, 0.1),
maxFrameDeltaSeconds: clamp(finiteOr(options.maxFrameDeltaSeconds, 0.25), 0.05, 1),
maximumSpeedMps: clamp(finiteOr(options.maximumSpeedMps, 58), 5, 100),
assistedCruiseRatio: clamp(finiteOr(options.assistedCruiseRatio, 0.92), 0.25, 1.1),
guardrailOffsetM: clamp(finiteOr(options.guardrailOffsetM, 5.4), 1, 20),
wheelRadiusM: clamp(finiteOr(options.wheelRadiusM, 0.36), 0.1, 1),
travelScale: clamp(finiteOr(options.travelScale, 1), 1, 10_000),
};
}
function hasManualIntent(actions: VehicleActionSnapshot): boolean {
return (
actions.modeRequest === "manual" ||
actions.handbrake ||
actions.throttle > 0.02 ||
actions.brake > 0.02 ||
Math.abs(actions.steering) > 0.08
);
}
function offsetPoint(sample: RouteSample, lateralOffsetM: number): GeographicPoint {
const heading = (sample.headingDeg * Math.PI) / 180;
// Right-hand normal to a compass bearing: south for eastbound, east for northbound.
const northM = -Math.sin(heading) * lateralOffsetM;
const eastM = Math.cos(heading) * lateralOffsetM;
const latitudeRadians = (sample.lat * Math.PI) / 180;
return {
lat: sample.lat + (northM / EARTH_RADIUS_M) * (180 / Math.PI),
lng:
sample.lng +
(eastM / (EARTH_RADIUS_M * Math.max(0.01, Math.cos(latitudeRadians)))) * (180 / Math.PI),
};
}
/**
* Deterministic route-relative driving state machine.
*
* `tick` adapts render time to a fixed clock. `stepFixed` is the authoritative
* primitive for tests, networking, and replay and always advances exactly once.
*/
export class VehicleController {
private readonly pack: TransportPack;
private readonly options: ResolvedOptions;
private path: RoutePath;
private accumulator = 0;
private readonly current: VehicleControllerState;
constructor(pack: TransportPack, options: VehicleControllerOptions) {
this.pack = pack;
this.options = resolveOptions(options);
this.path = buildRoutePath(pack, this.options.routeId);
const sample = sampleRoute(this.path, this.options.initialDistanceM, this.options.direction);
const point = offsetPoint(sample, 0);
this.current = {
...point,
routeId: this.path.route.id,
mode: this.options.mode,
direction: this.options.direction,
distanceM: 0,
progress: 0,
lateralOffsetM: 0,
speedMps: 0,
steering: 0,
routeHeadingDeg: sample.headingDeg,
headingDeg: sample.headingDeg,
segmentId: sample.segmentId,
roadName: sample.roadName,
speedLimitMph: sample.speedLimitMph,
wheelRadians: 0,
guardrailContact: false,
elapsedSteps: 0,
};
this.reset();
}
fixedStepSeconds(): number {
return this.options.fixedStepSeconds;
}
routeId(): string {
return this.path.route.id;
}
/** Stable state object for allocation-free polling. Treat it as read-only. */
state(): Readonly<VehicleControllerState> {
return this.current;
}
/** Detached state suitable for logs, network frames, and equality assertions. */
snapshot(): VehicleControllerSnapshot {
return { ...this.current };
}
/** Restore the configured spawn state and clear pending fractional time. */
reset(): void {
this.accumulator = 0;
const distanceM = wrap(this.options.initialDistanceM, this.path.lengthM);
const lateralOffsetM = clamp(
this.options.initialLateralOffsetM,
-this.options.guardrailOffsetM,
this.options.guardrailOffsetM,
);
const speedMps = clamp(this.options.initialSpeedMps, 0, this.options.maximumSpeedMps);
const sample = sampleRoute(this.path, distanceM, this.options.direction);
const point = offsetPoint(sample, lateralOffsetM);
Object.assign(this.current, point, {
routeId: this.path.route.id,
mode: this.options.mode,
direction: this.options.direction,
distanceM,
progress: distanceM / this.path.lengthM,
lateralOffsetM,
speedMps,
steering: 0,
routeHeadingDeg: sample.headingDeg,
headingDeg: sample.headingDeg,
segmentId: sample.segmentId,
roadName: sample.roadName,
speedLimitMph: sample.speedLimitMph,
wheelRadians: 0,
guardrailContact: false,
elapsedSteps: 0,
});
}
/**
* Change corridor as an explicit reset. Progress may optionally be preserved,
* which is useful for switching route variants without retaining stale metres.
*/
setRoute(routeId: string, preserveProgress = false): void {
if (routeId === this.path.route.id) return;
const previousProgress = this.current.progress;
this.path = buildRoutePath(this.pack, routeId);
this.options.routeId = routeId;
this.options.initialDistanceM = preserveProgress ? previousProgress * this.path.lengthM : 0;
this.reset();
}
/** Advance rendered seconds and return the number of fixed steps executed. */
tick(
deltaSeconds: number,
actions: Partial<VehicleActionSnapshot> = NEUTRAL_VEHICLE_ACTIONS,
): number {
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
const normalized = normalizeVehicleActions(actions);
if (normalized.reset) {
this.reset();
return 0;
}
this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds);
let steps = 0;
while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) {
this.stepNormalized(normalized);
this.accumulator -= this.options.fixedStepSeconds;
steps += 1;
}
return steps;
}
/** Advance exactly one authoritative simulation step. */
stepFixed(actions: Partial<VehicleActionSnapshot> = NEUTRAL_VEHICLE_ACTIONS): void {
const normalized = normalizeVehicleActions(actions);
if (normalized.reset) {
this.reset();
return;
}
this.stepNormalized(normalized);
}
private stepNormalized(actions: VehicleActionSnapshot): void {
const dt = this.options.fixedStepSeconds;
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.
if (manualIntent) this.current.mode = "manual";
else if (actions.modeRequest === "assisted") this.current.mode = "assisted";
let throttle = actions.throttle;
let brake = actions.brake;
let steeringTarget = actions.steering;
if (this.current.mode === "assisted") {
const roadTarget = this.current.speedLimitMph * MPH_TO_MPS * this.options.assistedCruiseRatio;
const targetSpeed = Math.min(roadTarget, this.options.maximumSpeedMps);
const speedError = targetSpeed - this.current.speedMps;
throttle = clamp(speedError / 5, 0, 1);
brake = clamp(-speedError / 7, 0, 1);
steeringTarget = clamp(-this.current.lateralOffsetM / 2.4, -1, 1);
}
this.current.steering = moveToward(this.current.steering, steeringTarget, 3.8 * dt);
const aeroDrag = this.current.speedMps * this.current.speedMps * 0.0018;
const rollingDrag = this.current.speedMps > 0 ? 0.12 : 0;
const engineFade = 1 - 0.55 * (this.current.speedMps / this.options.maximumSpeedMps);
const acceleration =
throttle * 5.4 * Math.max(0.2, engineFade) -
brake * 9.5 -
(actions.handbrake ? 13 : 0) -
aeroDrag -
rollingDrag;
this.current.speedMps = clamp(
this.current.speedMps + acceleration * dt,
0,
this.options.maximumSpeedMps,
);
const previousDistance = this.current.distanceM;
const physicalTravelled = this.current.speedMps * dt;
const routeTravelled = physicalTravelled * this.options.travelScale;
this.current.distanceM = wrap(
previousDistance + routeTravelled * this.current.direction,
this.path.lengthM,
);
let proposedLateral =
this.current.lateralOffsetM + this.current.steering * this.current.speedMps * 0.2 * dt;
if (this.current.mode === "assisted") {
// Assistance damps the final few centimetres without an abrupt lane snap.
proposedLateral *= Math.exp(-0.35 * dt);
}
this.current.guardrailContact = Math.abs(proposedLateral) > this.options.guardrailOffsetM;
if (this.current.guardrailContact) {
proposedLateral = clamp(
proposedLateral,
-this.options.guardrailOffsetM,
this.options.guardrailOffsetM,
);
this.current.speedMps = Math.min(this.current.speedMps * 0.78, 12);
this.current.steering *= 0.35;
}
this.current.lateralOffsetM = proposedLateral;
const sample = sampleRoute(this.path, this.current.distanceM, this.current.direction);
const point = offsetPoint(sample, this.current.lateralOffsetM);
const wheelDelta = physicalTravelled / this.options.wheelRadiusM;
Object.assign(this.current, point, {
progress: this.current.distanceM / this.path.lengthM,
routeHeadingDeg: sample.headingDeg,
headingDeg: sample.headingDeg + this.current.steering * 9,
segmentId: sample.segmentId,
roadName: sample.roadName,
speedLimitMph: sample.speedLimitMph,
wheelRadians: wrap(this.current.wheelRadians + wheelDelta, TWO_PI),
elapsedSteps: this.current.elapsedSteps + 1,
});
}
}
/** Execute an exact, renderer-independent input recording. */
export function replayVehicleInputs(
pack: TransportPack,
options: VehicleControllerOptions,
frames: readonly TimedVehicleInputFrame[],
): VehicleReplayResult {
const controller = new VehicleController(pack, options);
const trajectory: VehicleControllerSnapshot[] = [controller.snapshot()];
for (const frame of frames) {
const steps = Math.max(0, Math.floor(finiteOr(frame.steps, 0)));
const actions = normalizeVehicleActions(frame.actions);
for (let index = 0; index < steps; index += 1) {
// Reset and mode requests are edge-triggered at the start of a timed frame.
controller.stepFixed(
index === 0
? actions
: { ...actions, modeRequest: "none", reset: false },
);
trajectory.push(controller.snapshot());
}
}
const final = trajectory.at(-1) ?? controller.snapshot();
return { trajectory, final };
}
+253
View File
@@ -0,0 +1,253 @@
/**
* Deterministic road traffic over a serializable `TransportPack`.
*
* The simulation owns route progress, never render objects. It advances on a
* fixed clock and exposes plain geographic poses, so a city scene can project
* them through `World` while a server or replay runner can use the same code
* without three.js. Long background-tab deltas are capped rather than replayed
* as a burst; traffic resumes smoothly instead of teleporting through a route.
*/
import type {
GeographicPoint,
TransportPack,
TransportRoute,
TransportSegment,
} from "./types.ts";
const EARTH_RADIUS_M = 6_371_000;
const MPH_TO_MPS = 0.44704;
const FIXED_STEP = 1 / 20;
const MAX_FRAME_DELTA = 0.25;
export interface RouteLeg {
segment: TransportSegment;
from: GeographicPoint;
to: GeographicPoint;
lengthM: number;
startM: number;
endM: number;
}
export interface RoutePath {
route: TransportRoute;
legs: readonly RouteLeg[];
lengthM: number;
}
export interface RouteSample extends GeographicPoint {
/** Compass bearing in degrees clockwise from true north. */
headingDeg: number;
segmentId: string;
roadName: string;
speedLimitMph: number;
}
export interface VehiclePose extends RouteSample {
id: string;
routeId: string;
/** 0 is the median-side lane; positive values move toward the shoulder. */
lane: number;
direction: 1 | -1;
speedMps: number;
distanceM: number;
progress: number;
wheelRadians: number;
}
export interface VehicleSimulationOptions {
routeId: string;
count?: number;
seed?: number;
/** Time compression for the statewide board. Defaults to 900x. */
timeScale?: number;
}
interface VehicleState {
pose: VehiclePose;
cruise: number;
}
function radians(degrees: number): number {
return (degrees * Math.PI) / 180;
}
/** Equirectangular distance; sub-metre agreement is unnecessary at this scale. */
export function distanceMetres(a: GeographicPoint, b: GeographicPoint): number {
const meanLat = radians((a.lat + b.lat) / 2);
const dy = radians(b.lat - a.lat);
const dx = radians(b.lng - a.lng) * Math.cos(meanLat);
return Math.hypot(dx, dy) * EARTH_RADIUS_M;
}
function bearing(a: GeographicPoint, b: GeographicPoint): number {
const meanLat = radians((a.lat + b.lat) / 2);
const north = b.lat - a.lat;
const east = (b.lng - a.lng) * Math.cos(meanLat);
return (Math.atan2(east, north) * 180) / Math.PI;
}
export function buildRoutePath(pack: TransportPack, routeId: string): RoutePath {
const route = pack.routes.find((candidate) => candidate.id === routeId);
if (!route) throw new Error(`transport: unknown route "${routeId}"`);
const nodes = new Map(pack.nodes.map((node) => [node.id, node]));
const segments = new Map(pack.segments.map((segment) => [segment.id, segment]));
const legs: RouteLeg[] = [];
let cursor = 0;
for (const id of route.segmentIds) {
const segment = segments.get(id);
if (!segment) throw new Error(`transport: route "${routeId}" references missing segment "${id}"`);
const from = nodes.get(segment.fromNodeId)?.position;
const to = nodes.get(segment.toNodeId)?.position;
if (!from || !to) throw new Error(`transport: segment "${id}" references a missing node`);
const lengthM = distanceMetres(from, to);
if (!(lengthM > 0)) throw new Error(`transport: segment "${id}" has no length`);
legs.push({ segment, from, to, lengthM, startM: cursor, endM: cursor + lengthM });
cursor += lengthM;
}
if (legs.length === 0) throw new Error(`transport: route "${routeId}" is empty`);
return { route, legs, lengthM: cursor };
}
function wrap(value: number, modulus: number): number {
return ((value % modulus) + modulus) % modulus;
}
export function sampleRoute(path: RoutePath, distanceM: number, direction: 1 | -1 = 1): RouteSample {
const travelled = wrap(distanceM, path.lengthM);
const leg = path.legs.find((candidate) => travelled <= candidate.endM) ?? path.legs.at(-1);
if (!leg) throw new Error(`transport: route "${path.route.id}" has no legs`);
const t = Math.max(0, Math.min(1, (travelled - leg.startM) / leg.lengthM));
const from = direction === 1 ? leg.from : leg.to;
const to = direction === 1 ? leg.to : leg.from;
// `distanceM` is always measured from the route's declared start. Reverse
// traffic advances that scalar downward, so its geographic interpolation is
// still `t`; only its bearing is reversed. Mirroring `t` here makes a
// southbound car move north while visually facing south.
const u = t;
return {
lat: leg.from.lat + (leg.to.lat - leg.from.lat) * u,
lng: leg.from.lng + (leg.to.lng - leg.from.lng) * u,
headingDeg: bearing(from, to),
segmentId: leg.segment.id,
roadName: leg.segment.roadName,
speedLimitMph: leg.segment.speedLimitMph,
};
}
/** Small reproducible generator; simulation results never depend on `Math.random()`. */
function seeded(seed: number): () => number {
let state = seed >>> 0;
return () => {
state += 0x6d2b79f5;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
};
}
export class VehicleSimulation {
private readonly pack: TransportPack;
private path: RoutePath;
private readonly count: number;
private readonly seed: number;
private readonly timeScale: number;
private accumulator = 0;
private states: VehicleState[] = [];
private poseView: VehiclePose[] = [];
constructor(pack: TransportPack, options: VehicleSimulationOptions) {
this.pack = pack;
this.path = buildRoutePath(pack, options.routeId);
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.reset();
}
routeId(): string {
return this.path.route.id;
}
setRoute(routeId: string): void {
if (routeId === this.path.route.id) return;
this.path = buildRoutePath(this.pack, routeId);
this.accumulator = 0;
this.reset();
}
private reset(): void {
const rand = seeded(this.seed ^ hash(this.path.route.id));
this.states = Array.from({ length: this.count }, (_, index) => {
// The first vehicle is the northbound follow-camera hero. Every third
// background vehicle after it runs south so both carriageways stay alive.
const direction: 1 | -1 = index > 0 && index % 3 === 0 ? -1 : 1;
const distanceM = ((index + rand() * 0.6) / this.count) * this.path.lengthM;
const sample = sampleRoute(this.path, distanceM, direction);
const cruise = 0.86 + rand() * 0.12;
const speedMps = sample.speedLimitMph * MPH_TO_MPS * cruise;
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,
direction,
speedMps,
distanceM,
progress: distanceM / this.path.lengthM,
wheelRadians: 0,
},
};
});
// Keep one stable array for render consumers. The pose objects within it
// are already mutated in place by `step`, so a 60 fps scene should not pay
// for a fresh wrapper array on every frame.
this.poseView = this.states.map((state) => state.pose);
}
/** Advance by rendered seconds; internally every state change is a 20 Hz step. */
tick(dt: number): void {
if (!Number.isFinite(dt) || dt <= 0) return;
this.accumulator += Math.min(dt, MAX_FRAME_DELTA);
while (this.accumulator >= FIXED_STEP) {
this.step(FIXED_STEP);
this.accumulator -= FIXED_STEP;
}
}
private step(dt: number): void {
for (const state of this.states) {
const previous = state.pose;
const signed = previous.speedMps * this.timeScale * dt * previous.direction;
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;
Object.assign(previous, sample, {
speedMps,
distanceM,
progress: distanceM / this.path.lengthM,
// 0.36 m is a representative Model X tyre radius.
wheelRadians: wrap(previous.wheelRadians + (Math.abs(signed) / 0.36), Math.PI * 2),
});
}
}
/** Stable objects, mutated in place; render layers may retain references. */
poses(): readonly VehiclePose[] {
return this.poseView;
}
}
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;
}