fix(transit): keep parked vehicles pointed along their course while the camera orbits (#603)

## Changes

Follow-up to #596 from the owner's look: a parked transit vehicle turned with the camera. Moving vehicles project their world course into screen space on every camera change, but a vehicle that had never moved on screen could have no display course and was skipped by that pass, leaving its billboard at a screen-anchored angle; rotation invalidation also relied on camera events that a pose change without a revision could miss. Now every visible vehicle with any usable course (derived, held, or the feed's reported bearing) is re-projected at the existing 5 Hz cadence on camera pose changes. Moving playback and the 120°/s course limiter are unchanged.

## How it was verified

- `npm run format:check` (861 files), `npm run check:boundaries` (109 groups), `npm test` (4,068 passing), `npm run build` clean; tracking regression 109/109 in a real browser on Google 3D tiles.
- Node tests: a stationary vehicle with a held course, and one with only a reported bearing, under a camera at heading 0° then 90° yield screen rotations 90° apart.
- Browser harness (`scripts/qa-transit.mjs`, keyed on Google 3D tiles): a parked scripted vehicle orbited by 90° rotates 90.000° on screen; a moving vehicle's rotation still matches its rendered travel; both checks fail on main and pass here. Full keyed run: 201 pass; the nine failures are the per-frame allocation gate already recorded as a follow-up and threshold-edge readability lines on the Boston matrix, both unchanged from #596.
This commit is contained in:
Bilawal Sidhu authored and GitHub committed 2026-09-15 14:15:27 -05:00
1 parent 0bb61f53c6
commit 50d3672a00
8 files changed
+348 -21

No files matched your search

+28 -20
View File
@@ -1,5 +1,8 @@
import * as Cesium from 'cesium';
import { perspectiveProjectedRotation } from '../../data/iconOrientation.js';
import {
cameraPoseSignature,
perspectiveProjectedRotation,
} from '../../data/iconOrientation.js';
import {
updatePlayback,
applyDisplayCourse,
@@ -308,20 +311,27 @@ export function createRendering({ state, services, parts }) {
const scene = state._viewer?.scene;
if (!scene?.camera) return;
if (now - state._rotationAt < ROTATION_REFRESH_MS) return;
if (
state._rotationRevision === state._cameraRevision &&
!state._rotationDirty &&
state._moving.size === 0
)
return;
state._rotationAt = now;
const cameraChanged = state._rotationRevision !== state._cameraRevision;
const pose = cameraPoseSignature(scene.camera);
const cameraChanged =
state._rotationPose !== pose ||
state._rotationRevision !== state._cameraRevision;
if (!cameraChanged && !state._rotationDirty && state._moving.size === 0)
return;
state._rotationPose = pose;
state._rotationRevision = state._cameraRevision;
state._rotationDirty = false;
const monoNow = performance.now();
for (const entry of state._visible) {
// Idle contacts still need a course when their first usable bearing arrives.
if (!state._moving.has(entry)) applyDisplayCourse(entry, monoNow);
if (entry.key === state._selectedKey) continue;
const course = entry.courseDeg;
if (course === null) continue;
if (!Number.isFinite(course)) {
entry.marker.rotation = 0;
entry.rotationCourse = null;
continue;
}
const position = entry.marker.position;
if (
!cameraChanged &&
@@ -417,17 +427,15 @@ export function createRendering({ state, services, parts }) {
parts.trails.update();
refreshRotations(now);
const selected = state._vehicles.get(state._selectedKey);
if (
selected &&
state._visible.has(selected) &&
selected.courseDeg !== null
) {
selected.marker.rotation = perspectiveProjectedRotation(
state._viewer.scene,
selected.marker.position,
selected.courseDeg,
selected.marker.rotation,
);
if (selected && state._visible.has(selected)) {
selected.marker.rotation = Number.isFinite(selected.courseDeg)
? perspectiveProjectedRotation(
state._viewer.scene,
selected.marker.position,
selected.courseDeg,
selected.marker.rotation,
)
: 0;
}
if (state._heightDirty.size) governorRequestRender('transit-height-batch');
syncRenderHold();