diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..02359f3 --- /dev/null +++ b/design-qa.md @@ -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. diff --git a/src/arena/crowNav.ts b/src/arena/crowNav.ts index 6c75b74..1d31271 100644 --- a/src/arena/crowNav.ts +++ b/src/arena/crowNav.ts @@ -149,7 +149,7 @@ export function crowNavScriptedBaseline(observation: CrowNavObservation): CrowNa forward: 0.45, turn: Math.max(-1, Math.min(1, headingError / 0.55)), pitch: 0, - climb: Math.max(-0.7, Math.min(0.7, observation.deltaY / 12 + 0.03)), + climb: Math.max(-0.8, Math.min(0.8, observation.deltaY / 10 - 0.2)), glide: false, }; } diff --git a/src/assets/vehicles/index.ts b/src/assets/vehicles/index.ts index aa2dfbb..e51efb2 100644 --- a/src/assets/vehicles/index.ts +++ b/src/assets/vehicles/index.ts @@ -1,7 +1,9 @@ export { MODEL_X_METRICS, + LUMBRIDGE_EV_METRICS, advanceModelXWheels, buildModelX, + buildLumbridgeEV, cloneModelX, createModelXMaterials, disposeModelX, diff --git a/src/assets/vehicles/modelX.ts b/src/assets/vehicles/modelX.ts index c316831..a9fa573 100644 --- a/src/assets/vehicles/modelX.ts +++ b/src/assets/vehicles/modelX.ts @@ -1,5 +1,5 @@ /** - * A procedural, unbadged black Model X-style electric crossover. + * Lumbridge EV-01: an original, unbadged procedural electric grand tourer. * * The asset is authored in metres with its origin on the road at the centre of * the wheelbase. It faces -Z at yaw zero, matching the rest of Tera; +X is the @@ -8,7 +8,8 @@ * sparse because this car is normally read from a corridor or chase camera. * * Build one rig and clone it. Clones share geometry and materials while their - * wheel and steering joints remain independent. Dispose the original only + * wheel and steering joints remain independent. Legacy Model-X API names are + * retained as serialized/realtime compatibility contracts, not design intent. * after every clone has left the scene. */ @@ -26,6 +27,9 @@ export const MODEL_X_METRICS = { maxSteeringAngle: 0.62, } as const; +/** Preferred product-facing name; MODEL_X_METRICS remains a compatibility alias. */ +export const LUMBRIDGE_EV_METRICS = MODEL_X_METRICS; + export type ModelXDetail = "corridor" | "follow"; export interface ModelXMaterials { @@ -87,30 +91,32 @@ const UNIT_PLANE = new THREE.PlaneGeometry(1, 1); /** Default materials are intentionally untextured: no binary art and no UV dependency. */ export function createModelXMaterials( - paint: THREE.ColorRepresentation = 0x050607, + paint: THREE.ColorRepresentation = 0x465157, ): ModelXMaterials { const body = new THREE.MeshPhysicalMaterial({ name: "model-x.paint", color: paint, metalness: 0.72, - roughness: 0.22, + roughness: 0.26, clearcoat: 1, clearcoatRoughness: 0.12, + emissive: 0x11191e, + emissiveIntensity: 0.72, }); return { paint: body, glass: new THREE.MeshPhysicalMaterial({ name: "model-x.glass", - color: 0x101b22, + color: 0x193746, metalness: 0.12, roughness: 0.12, transparent: true, - opacity: 0.86, + opacity: 0.78, side: THREE.DoubleSide, }), trim: new THREE.MeshStandardMaterial({ name: "model-x.trim", - color: 0x101214, + color: 0x272d31, metalness: 0.68, roughness: 0.3, }), @@ -122,9 +128,11 @@ export function createModelXMaterials( }), wheel: new THREE.MeshStandardMaterial({ name: "model-x.wheel", - color: 0x2d3135, + color: 0x515960, metalness: 0.88, roughness: 0.25, + emissive: 0x111519, + emissiveIntensity: 0.45, }), brake: new THREE.MeshStandardMaterial({ name: "model-x.brake", @@ -136,14 +144,14 @@ export function createModelXMaterials( name: "model-x.headlight", color: 0xd9f4ff, emissive: 0xb8eaff, - emissiveIntensity: 2.4, + emissiveIntensity: 3.2, roughness: 0.16, }), tailLight: new THREE.MeshStandardMaterial({ name: "model-x.tail-light", color: 0xff2433, emissive: 0xd70918, - emissiveIntensity: 1.9, + emissiveIntensity: 2.8, roughness: 0.2, }), }; @@ -376,6 +384,44 @@ function buildBody(materials: ModelXMaterials, detail: ModelXDetail): THREE.Grou }); } + // EV-01 signature: continuous light blades and satin aero rails remain + // readable at corridor LOD without a badge or a borrowed grille shape. + batch.box(materials.headlight, { + position: [0, 0.89, -2.43], + scale: [1.28, 0.035, 0.028], + }); + batch.box(materials.tailLight, { + position: [0, 0.98, 2.39], + scale: [1.34, 0.032, 0.026], + }); + for (const side of [-1, 1]) { + batch.box(materials.wheel, { + position: [side * 0.985, 0.47, 0.12], + scale: [0.025, 0.06, 3.45], + }); + } + + if (detail === "follow") { + // A small real interior reads through the panoramic glazing in chase and + // driver cameras. It deliberately uses the pooled trim surface. + batch.box(materials.trim, { + position: [0, 1.05, -0.58], + scale: [1.45, 0.12, 0.34], + rotation: [-0.12, 0, 0], + }); + for (const side of [-1, 1]) { + batch.box(materials.trim, { + position: [side * 0.38, 1.02, 0.08], + scale: [0.42, 0.52, 0.46], + rotation: [0.12, 0, 0], + }); + batch.box(materials.trim, { + position: [side * 0.38, 1.33, 0.17], + scale: [0.32, 0.28, 0.2], + }); + } + } + // Lower aero surfaces stop the black shell dissolving into the road. batch.box(materials.trim, { position: [0, 0.39, -1.92], @@ -502,6 +548,9 @@ export function buildModelX(options: ModelXBuildOptions = {}): ModelXRig { root.name = "model-x"; root.userData.kind = "vehicle"; root.userData.vehicleModel = "model-x"; + root.userData.design = "lumbridge-ev-01"; + root.userData.lod = detail; + root.userData.unbranded = true; root.userData.forwardAxis = "-Z"; root.add(buildBody(materials, detail)); @@ -518,6 +567,11 @@ export function buildModelX(options: ModelXBuildOptions = {}): ModelXRig { return { root, body: root.getObjectByName("model-x.body") as THREE.Group, wheels, ownsMaterials: !options.materials }; } +/** Preferred builder for the original Lumbridge EV visual. */ +export function buildLumbridgeEV(options: ModelXBuildOptions = {}): ModelXRig { + return buildModelX(options); +} + /** Clone the hierarchy while sharing all immutable geometry and material resources. */ export function cloneModelX(source: ModelXRig): ModelXRig { return resolveRig(source.root.clone(true), false); diff --git a/src/cities/california.ts b/src/cities/california.ts index b541f15..1c21ba0 100644 --- a/src/cities/california.ts +++ b/src/cities/california.ts @@ -104,7 +104,7 @@ export const CALIFORNIA_CITY: City = { label: "US-101", shortLabel: "101", number: "02", - description: "Follow the black Model X up the coast and Salinas Valley through Santa Barbara and San Jose.", + description: "Follow the unbadged Lumbridge EV up US-101 through coastal oak country and the Salinas Valley.", focus: { lat: 35.8, lng: -121.04, distance: 112, height: 72, rotation: 0.8 }, }, { @@ -112,7 +112,7 @@ export const CALIFORNIA_CITY: City = { label: "I-5 · I-580 · I-80", shortLabel: "I-5", number: "03", - description: "Follow the black Model X through the Central Valley, then the honest Bay approach over I-580 and I-80.", + description: "Follow the Lumbridge EV on I-5 through the Central Valley, then the Bay approach over I-580 and I-80.", focus: { lat: 36.15, lng: -120.15, distance: 105, height: 70, rotation: 0.78 }, }, { diff --git a/src/engine/roadTraffic.ts b/src/engine/roadTraffic.ts index 2541a7e..4fc5985 100644 --- a/src/engine/roadTraffic.ts +++ b/src/engine/roadTraffic.ts @@ -11,7 +11,7 @@ import * as THREE from "three"; import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { - buildModelX, + buildLumbridgeEV, disposeModelX, modelXInstanceParts, setModelXSteering, @@ -91,13 +91,13 @@ export function createRoadTrafficLayer( }); // One articulated, higher-detail car for the follow camera. - const heroRig = buildModelX({ detail: "follow" }); + const heroRig = buildLumbridgeEV({ detail: "follow" }); heroRig.root.name = "model-x-hero"; group.add(heroRig.root); // Background traffic is one draw call per asset part, not per car. The // neutral prototype itself is never attached to the scene. - const backgroundPrototype = buildModelX({ detail: "corridor" }); + const backgroundPrototype = buildLumbridgeEV({ detail: "corridor" }); const backgroundCount = count - 1; const batches: BatchPart[] = modelXInstanceParts(backgroundPrototype).map((part) => { const material = Array.isArray(part.material) ? part.material[0] : part.material; @@ -130,7 +130,7 @@ export function createRoadTrafficLayer( // Lane 0 hugs the median. Southbound traffic's right side naturally moves // to the other carriageway because its heading is reversed. const laneOffset = - 0.2 + (pose.lane ?? 0) * 0.2 + (pose.lateralOffsetM ?? 0) * 0.018; + 0.29 + (pose.lane ?? 0) * 0.34 + (pose.lateralOffsetM ?? 0) * 0.03; out.set( x + Math.cos(heading) * laneOffset, // The asset origin is on the tyre contact plane. Keep only a tiny lift @@ -191,9 +191,9 @@ export function createRoadTrafficLayer( .copy(heroRig.root.position) .add( followOffset.set( - -forwardX * 0.9 - rightX * 0.3, - 0.4, - -forwardZ * 0.9 - rightZ * 0.3, + -forwardX * 0.72 - rightX * 0.2, + 0.92, + -forwardZ * 0.72 - rightZ * 0.2, ), ); } @@ -238,6 +238,11 @@ export function createRoadTrafficLayer( hero: () => controller.state(), tick(dt) { simulation.tick(dt); + const hero = controller.state(); + const lead = simulation.leadVehicle(hero.distanceM, hero.direction, 0); + controller.setTrafficContext(lead + ? { leadGapM: lead.gapM, leadSpeedMps: lead.speedMps } + : null); controller.tick(dt, vehicleActions); // Mode/reset requests are edges. Analogue axes remain held until the // input adapter publishes a changed snapshot. diff --git a/src/engine/scene.ts b/src/engine/scene.ts index d2dd4fd..0d6aeb8 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -52,7 +52,7 @@ import type { VehicleActionSnapshot, VehicleControllerState, } from "../transport/vehicleController.ts"; -import { createBridges, createRoads } from "./structures.ts"; +import { createBridges, createFreewayWorld, createRoads } from "./structures.ts"; import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts"; import type { Chapter, @@ -355,7 +355,9 @@ export async function createScene( scene.add(createWater(world)); scene.add(createShorePlates(world)); scene.add(createTerrain(world)); - scene.add(createRoads(world)); + scene.add(options.roadTraffic + ? createFreewayWorld(world, options.roadTraffic.pack) + : createRoads(world)); const buildingReservations: BuildingReservation[] = []; for (const marker of options.markers ?? []) { const glyph = marker.glyph; diff --git a/src/engine/structures.ts b/src/engine/structures.ts index a10383a..3efce4e 100644 --- a/src/engine/structures.ts +++ b/src/engine/structures.ts @@ -8,6 +8,9 @@ */ import * as THREE from "three"; +import { buildFreewayWorldPlan } from "../transport/freewayWorld.ts"; +import type { TransportPack } from "../transport/types.ts"; +import { buildRoutePath, sampleRoute } from "../transport/vehicleSim.ts"; import type { Bridge, LatLng } from "./types.ts"; import type { World } from "./world.ts"; @@ -86,6 +89,235 @@ function roadRibbon( return mesh; } +function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vector3[] { + return points.map((point, index) => { + const previous = points[Math.max(0, index - 1)] ?? point; + const next = points[Math.min(points.length - 1, index + 1)] ?? point; + const dx = next.x - previous.x; + const dz = next.z - previous.z; + const length = Math.hypot(dx, dz) || 1; + return new THREE.Vector3(point.x + (-dz / length) * offset, point.y, point.z + (dx / length) * offset); + }); +} + +/** Merge alternating path spans into one dashed marking mesh. */ +function dashedRibbon( + points: readonly THREE.Vector3[], + offset: number, + width: number, + color: number, +): THREE.Mesh { + const shifted = offsetPath(points, offset); + const positions: number[] = []; + const indices: number[] = []; + for (let index = 0; index < shifted.length - 1; index += 2) { + const a = shifted[index]; + const spanEnd = shifted[Math.min(index + 1, shifted.length - 1)]; + if (!a || !spanEnd) continue; + const b = a.clone().lerp(spanEnd, 0.44); + const dx = b.x - a.x; + const dz = b.z - a.z; + const length = Math.hypot(dx, dz) || 1; + const nx = (-dz / length) * width / 2; + const nz = (dx / length) * width / 2; + const base = positions.length / 3; + positions.push( + a.x + nx, a.y + 0.035, a.z + nz, + a.x - nx, a.y + 0.035, a.z - nz, + b.x + nx, b.y + 0.035, b.z + nz, + b.x - nx, b.y + 0.035, b.z - nz, + ); + indices.push(base, base + 2, base + 1, base + 1, base + 2, base + 3); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + const mesh = new THREE.Mesh( + geometry, + new THREE.MeshBasicMaterial({ color, toneMapped: false, side: THREE.DoubleSide }), + ); + mesh.name = "freeway:lane-dashes"; + return mesh; +} + +function makeShieldMaterial(identity: "us-highway" | "interstate", shield: string): THREE.Material { + if (typeof document === "undefined") { + return new THREE.MeshBasicMaterial({ color: identity === "interstate" ? 0x2d5b8c : 0xe8edf0 }); + } + const canvas = document.createElement("canvas"); + canvas.width = 256; + canvas.height = 192; + const context = canvas.getContext("2d"); + if (!context) return new THREE.MeshBasicMaterial({ color: 0xe8edf0 }); + context.fillStyle = identity === "interstate" ? "#174b80" : "#f4f5ef"; + context.fillRect(6, 6, 244, 180); + context.lineWidth = 12; + context.strokeStyle = identity === "interstate" ? "#f3f5f7" : "#151b20"; + context.strokeRect(6, 6, 244, 180); + context.fillStyle = identity === "interstate" ? "#f3f5f7" : "#151b20"; + context.font = "700 52px ui-monospace, monospace"; + context.textAlign = "center"; + context.fillText(identity === "interstate" ? "INTERSTATE" : "US", 128, 63); + context.font = "800 86px ui-monospace, monospace"; + context.fillText(shield, 128, 151); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.needsUpdate = true; + return new THREE.MeshBasicMaterial({ map: texture, toneMapped: false, side: THREE.DoubleSide }); +} + +/** + * Browser-feasible authored freeway world. Geometry is deliberately batched by + * road/marking class: the corridor gains lane-scale readability without one + * draw call per reflector, tree, or roadside prop. + */ +export function createFreewayWorld(world: World, pack: TransportPack): THREE.Group { + const group = new THREE.Group(); + group.name = "freeway-world-v2"; + const plan = buildFreewayWorldPlan(pack); + group.userData.planSeed = plan.seed; + + const asphalt = [0x353a3d, 0x303538]; + const shoulder = [0x555759, 0x4e5153]; + const berm = [0x64705c, 0x74674c]; + const barrierMaterial = new THREE.MeshLambertMaterial({ color: 0xb6b4aa }); + const guardMaterial = new THREE.MeshStandardMaterial({ color: 0x9fa8aa, metalness: 0.64, roughness: 0.42 }); + const reflectorMaterial = new THREE.MeshBasicMaterial({ color: 0xf7e3a0, toneMapped: false }); + const reflectorGeometry = new THREE.BoxGeometry(0.018, 0.01, 0.028); + const treeTrunkMaterial = new THREE.MeshLambertMaterial({ color: 0x66513b }); + const treeCrownMaterials = [ + new THREE.MeshLambertMaterial({ color: 0x3f5942 }), + new THREE.MeshLambertMaterial({ color: 0x687347 }), + ]; + const trunkGeometry = new THREE.CylinderGeometry(0.045, 0.06, 0.45, 5); + const crownGeometry = new THREE.IcosahedronGeometry(0.28, 0); + const poleGeometry = new THREE.CylinderGeometry(0.022, 0.03, 0.72, 5); + const siloGeometry = new THREE.CylinderGeometry(0.14, 0.16, 0.55, 8); + const roadsideFeatures = plan.routes.reduce((sum, route) => sum + route.roadside.length, 0); + const trunks = new THREE.InstancedMesh(trunkGeometry, treeTrunkMaterial, roadsideFeatures); + const coastalCrowns = new THREE.InstancedMesh(crownGeometry, treeCrownMaterials[0]!, roadsideFeatures); + const orchardCrowns = new THREE.InstancedMesh(crownGeometry, treeCrownMaterials[1]!, roadsideFeatures); + const poles = new THREE.InstancedMesh(poleGeometry, guardMaterial, roadsideFeatures); + const silos = new THREE.InstancedMesh(siloGeometry, barrierMaterial, roadsideFeatures); + trunks.name = "freeway:roadside-trunks"; + coastalCrowns.name = "freeway:coastal-oaks"; + orchardCrowns.name = "freeway:orchards"; + poles.name = "freeway:power-poles"; + silos.name = "freeway:valley-silos"; + let trunkCount = 0; + let coastalCrownCount = 0; + let orchardCrownCount = 0; + let poleCount = 0; + let siloCount = 0; + const dummy = new THREE.Object3D(); + + world.city.roads.forEach((road, roadIndex) => { + if (road.kind !== "freeway") return; + const path = drapePath(world, road.path, 52, 0.115); + const route = plan.routes[roadIndex]; + const identityIndex = route?.identity === "interstate" ? 1 : 0; + const routePath = route ? buildRoutePath(pack, route.routeId) : null; + // Broad earthwork under separate decks makes grade and curve changes read. + group.add(roadRibbon(path, 2.75, berm[identityIndex] ?? berm[0]!, -0.09)); + for (const side of [-1, 1] as const) { + group.add(roadRibbon(offsetPath(path, side * 0.64), 1.18, shoulder[identityIndex] ?? shoulder[0]!, 0.004)); + group.add(roadRibbon(offsetPath(path, side * 0.64), 1.03, asphalt[identityIndex] ?? asphalt[0]!, 0.012)); + // Inner yellow edge, two lane dividers, outer white shoulder edge. + group.add(roadRibbon(offsetPath(path, side * 0.12), 0.026, 0xf0c84f, 0.038)); + group.add(roadRibbon(offsetPath(path, side * 1.16), 0.026, 0xe8ece8, 0.038)); + group.add(dashedRibbon(path, side * 0.47, 0.022, 0xf4f4ec)); + group.add(dashedRibbon(path, side * 0.81, 0.022, 0xf4f4ec)); + const guardPath = offsetPath(path, side * 1.27); + const guard = new THREE.Mesh( + new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length * 2), 0.025, 5, false), + guardMaterial, + ); + guard.name = "freeway:outer-guardrail"; + guard.castShadow = true; + group.add(guard); + } + // Low concrete median walls keep both carriageways visually independent. + for (const side of [-1, 1] as const) { + const medianPath = offsetPath(path, side * 0.075).map((point) => point.clone().setY(point.y + 0.065)); + const median = new THREE.Mesh( + new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length * 2), 0.055, 4, false), + barrierMaterial, + ); + median.name = "freeway:median-barrier"; + group.add(median); + } + // Retroreflectors are instanced and restrained, never roadside light blobs. + const reflectorPoints = path.filter((_, index) => index % 2 === 0); + const reflectors = new THREE.InstancedMesh(reflectorGeometry, reflectorMaterial, reflectorPoints.length * 4); + reflectors.name = "freeway:reflectors"; + let reflectorIndex = 0; + for (const pointIndex of reflectorPoints.keys()) { + const point = reflectorPoints[pointIndex]; + if (!point) continue; + for (const offset of [-0.81, -0.47, 0.47, 0.81]) { + const shifted = offsetPath(path, offset)[pointIndex * 2] ?? point; + dummy.position.set(shifted.x, shifted.y + 0.055, shifted.z); + dummy.rotation.set(0, 0, 0); + dummy.scale.setScalar(1); + dummy.updateMatrix(); + reflectors.setMatrixAt(reflectorIndex++, dummy.matrix); + } + } + reflectors.count = reflectorIndex; + group.add(reflectors); + + if (!route || !routePath) return; + const shieldMaterial = makeShieldMaterial(route.identity, route.shield); + for (const feature of route.roadside) { + const sample = sampleRoute(routePath, feature.distanceM); + const [x, z] = world.project(sample.lat, sample.lng); + const heading = (sample.headingDeg * Math.PI) / 180; + const sceneSetback = feature.kind === "route-sign" ? 1.42 : 1.7 + feature.setbackM * 0.014; + const px = x + Math.cos(heading) * sceneSetback * feature.side; + const pz = z + Math.sin(heading) * sceneSetback * feature.side; + const ground = world.groundAt(sample.lat, sample.lng); + if (feature.kind === "route-sign") { + const sign = new THREE.Group(); + sign.name = `freeway:sign:${route.shield}`; + const post = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.62, 0.035), guardMaterial); + post.position.y = 0.31; + const board = new THREE.Mesh(new THREE.PlaneGeometry(0.42, 0.31), shieldMaterial); + board.position.y = 0.69; + board.rotation.y = -heading + (feature.side === 1 ? Math.PI : 0); + sign.add(post, board); + sign.position.set(px, ground + 0.08, pz); + sign.userData.routeId = route.routeId; + group.add(sign); + continue; + } + const visualScale = feature.scale * 0.58; + const halfHeight = feature.kind === "power-pole" ? 0.36 : feature.kind === "silo" ? 0.275 : 0.225; + dummy.position.set(px, ground + halfHeight * visualScale, pz); + dummy.rotation.set(0, heading + feature.scale, 0); + dummy.scale.setScalar(visualScale); + dummy.updateMatrix(); + if (feature.kind === "power-pole") poles.setMatrixAt(poleCount++, dummy.matrix); + else if (feature.kind === "silo") silos.setMatrixAt(siloCount++, dummy.matrix); + else { + trunks.setMatrixAt(trunkCount++, dummy.matrix); + dummy.position.y += 0.25 * feature.scale; + dummy.scale.set(feature.scale * 0.7, feature.scale * 0.5, feature.scale * 0.62); + dummy.updateMatrix(); + if (feature.kind === "oak") coastalCrowns.setMatrixAt(coastalCrownCount++, dummy.matrix); + else orchardCrowns.setMatrixAt(orchardCrownCount++, dummy.matrix); + } + } + }); + trunks.count = trunkCount; + poles.count = poleCount; + silos.count = siloCount; + coastalCrowns.count = coastalCrownCount; + orchardCrowns.count = orchardCrownCount; + group.add(trunks, coastalCrowns, orchardCrowns, poles, silos); + return group; +} + export function createRoads(world: World): THREE.Group { const group = new THREE.Group(); group.name = "roads"; diff --git a/src/test/freewayWorld.test.ts b/src/test/freewayWorld.test.ts new file mode 100644 index 0000000..90df285 --- /dev/null +++ b/src/test/freewayWorld.test.ts @@ -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); + }); +}); diff --git a/src/test/vehicleAsset.test.ts b/src/test/vehicleAsset.test.ts index ba9aaa8..acd2e0b 100644 --- a/src/test/vehicleAsset.test.ts +++ b/src/test/vehicleAsset.test.ts @@ -3,8 +3,10 @@ import { describe, it } from "node:test"; import * as THREE from "three"; import { MODEL_X_METRICS, + LUMBRIDGE_EV_METRICS, advanceModelXWheels, buildModelX, + buildLumbridgeEV, cloneModelX, disposeModelX, modelXInstanceParts, @@ -21,6 +23,20 @@ function meshes(root: THREE.Object3D): THREE.Mesh[] { } describe("procedural Model X vehicle asset", () => { + it("publishes the original unbranded Lumbridge EV identity at both LODs", () => { + const follow = buildLumbridgeEV({ detail: "follow" }); + const corridor = buildLumbridgeEV({ detail: "corridor" }); + assert.equal(LUMBRIDGE_EV_METRICS, MODEL_X_METRICS); + assert.equal(follow.root.userData.design, "lumbridge-ev-01"); + assert.equal(follow.root.userData.unbranded, true); + assert.equal(follow.root.userData.lod, "follow"); + assert.equal(corridor.root.userData.lod, "corridor"); + const followVertices = meshes(follow.root).reduce((sum, mesh) => sum + mesh.geometry.getAttribute("position").count, 0); + const corridorVertices = meshes(corridor.root).reduce((sum, mesh) => sum + mesh.geometry.getAttribute("position").count, 0); + assert.ok(followVertices > corridorVertices, "follow LOD includes a readable interior and denser wheels"); + disposeModelX(follow); + disposeModelX(corridor); + }); it("has a metre-scale crossover silhouette and faces -Z", () => { const rig = buildModelX({ detail: "corridor" }); const size = new THREE.Box3().setFromObject(rig.root).getSize(new THREE.Vector3()); diff --git a/src/test/vehicleController.test.ts b/src/test/vehicleController.test.ts index 6925fca..2dc3dbc 100644 --- a/src/test/vehicleController.test.ts +++ b/src/test/vehicleController.test.ts @@ -150,6 +150,47 @@ describe("vehicle controller", () => { assert.equal(first.final.mode, "manual"); }); + it("brakes deterministically for a lead vehicle and emits safety telemetry", () => { + const make = () => new VehicleController(CALIFORNIA_TRANSPORT, { + routeId: "la-sf-us-101", + initialSpeedMps: 30, + mode: "assisted", + }); + const first = make(); + const replay = make(); + for (const controller of [first, replay]) { + controller.setTrafficContext({ leadGapM: 12, leadSpeedMps: 8 }); + for (let index = 0; index < 90; index += 1) controller.stepFixed(); + } + assert.deepEqual(first.snapshot(), replay.snapshot()); + assert.ok(first.state().speedMps < 20); + assert.equal(first.state().trafficIntervention, true); + assert.ok(first.state().collisionRisk > 0); + assert.ok((first.state().timeHeadwaySeconds ?? 0) > 0); + assert.ok(Number.isFinite(first.state().jerkMps3)); + assert.ok(first.state().laneKeepingScore >= 0 && first.state().laneKeepingScore <= 1); + }); + + it("restores nearest-lead context with the checkpointed controller state", () => { + const make = () => new VehicleController(CALIFORNIA_TRANSPORT, { + routeId: "la-sf-us-101", + initialSpeedMps: 24, + mode: "assisted", + }); + const original = make(); + original.setTrafficContext({ leadGapM: 18, leadSpeedMps: 7 }); + for (let index = 0; index < 30; index += 1) original.stepFixed(); + + const restored = make(); + restored.restore(original.snapshot()); + original.stepFixed(); + restored.stepFixed(); + + assert.deepEqual(restored.snapshot(), original.snapshot()); + assert.equal(restored.state().leadGapM, 18); + assert.equal(restored.state().leadSpeedMps, 7); + }); + it("can switch route variants while preserving normalized progress", () => { const controller = new VehicleController(CALIFORNIA_TRANSPORT, { routeId: "la-sf-us-101", diff --git a/src/test/vehicleSim.test.ts b/src/test/vehicleSim.test.ts index 953a5ce..298b16a 100644 --- a/src/test/vehicleSim.test.ts +++ b/src/test/vehicleSim.test.ts @@ -99,4 +99,19 @@ describe("vehicle simulation", () => { assert.equal(sim.poses(), poses); assert.equal(sim.poses()[0], poses[0]); }); + + it("reports a deterministic same-lane lead vehicle for safety control", () => { + const sim = new VehicleSimulation(CALIFORNIA_TRANSPORT, { + routeId: "la-sf-us-101", + count: 14, + seed: 115, + }); + const candidate = sim.poses().slice(1).find((pose) => pose.direction === 1); + assert.ok(candidate); + const observation = sim.leadVehicle(candidate.distanceM - 100, 1, candidate.lane); + assert.ok(observation); + assert.ok(observation.gapM > 0 && observation.gapM <= 100.000001); + assert.ok(Number.isFinite(observation.speedMps)); + assert.deepEqual(sim.leadVehicle(candidate.distanceM - 100, 1, candidate.lane), observation); + }); }); diff --git a/src/transport/freewayWorld.ts b/src/transport/freewayWorld.ts new file mode 100644 index 0000000..ac5182f --- /dev/null +++ b/src/transport/freewayWorld.ts @@ -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 }; +} diff --git a/src/transport/vehicleController.ts b/src/transport/vehicleController.ts index 4d1672d..a6a5358 100644 --- a/src/transport/vehicleController.ts +++ b/src/transport/vehicleController.ts @@ -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 | 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, }); } diff --git a/src/transport/vehicleSim.ts b/src/transport/vehicleSim.ts index a4b419d..25b249f 100644 --- a/src/transport/vehicleSim.ts +++ b/src/transport/vehicleSim.ts @@ -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; 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 {