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

+125
View File
@@ -3858,3 +3858,128 @@ test('selected draped history prepares geometry before surface heights resolve',
'unresolved marker corridors remain unavailable',
);
});
// A camera pose can change without a viewport-membership revision (notably
// a top-down orbit). Exercise the actual frame pass with an otherwise idle fleet.
test('parked transit reprojects held and reported courses at the bounded camera cadence', async (t) => {
const { createRendering } = await import('./rendering.js');
t.mock.timers.enable({ apis: ['Date'], now: 1000 });
const position = Cesium.Cartesian3.fromDegrees(0, 0, 20);
const cameraPosition = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
const scene = {
frameState: { mode: Cesium.SceneMode.SCENE3D },
canvas: { clientWidth: 1280, clientHeight: 800 },
camera: {
positionWC: cameraPosition,
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
frustum: new Cesium.PerspectiveFrustum({
fov: Math.PI / 3,
aspectRatio: 1.6,
near: 1,
far: 1e7,
}),
},
};
const pose = (heading) => {
const camera = scene.camera;
camera.heading = heading;
camera.rightWC = new Cesium.Cartesian3(
0,
Math.cos(heading),
-Math.sin(heading),
);
camera.upWC = new Cesium.Cartesian3(
0,
Math.sin(heading),
Math.cos(heading),
);
camera.viewMatrix = Cesium.Matrix4.computeView(
cameraPosition,
new Cesium.Cartesian3(-1, 0, 0),
camera.upWC,
camera.rightWC,
new Cesium.Matrix4(),
);
};
pose(0);
const make = (key, courseDeg, bearing) => ({
key,
courseDeg,
record: { bearing },
sample: { segmentCourseDeg: NaN },
marker: { position, rotation: 0.7, show: true },
});
const held = make('held', 90, 270);
const reportedOnly = make('reported', null, 90);
const unknown = make('unknown', null, null);
const hidden = make('hidden', 90, 90);
const state = {
_viewer: { scene },
_enabled: true,
_vehicles: new Map(
[held, reportedOnly, unknown, hidden].map((e) => [e.key, e]),
),
_visible: new Set([held, reportedOnly, unknown]),
_moving: new Set(),
_heightDirty: new Set(),
_rotationAt: 0,
_rotationRevision: -1,
_cameraRevision: 0,
_rotationDirty: false,
};
const noop = () => {};
const rendering = createRendering({
state,
services: {
render: {
governorRequestRender: noop,
holdContinuousRender: noop,
releaseContinuousRender: noop,
},
},
parts: { height: { nearGround: () => false }, trails: { update: noop } },
});
rendering.onPreRender();
const before = [held.marker.rotation, reportedOnly.marker.rotation];
pose(Math.PI / 2);
t.mock.timers.tick(100);
rendering.onPreRender();
assert.equal(
held.marker.rotation,
before[0],
'no rotation pass before 200 ms',
);
t.mock.timers.tick(100);
rendering.onPreRender();
for (const [i, entry] of [held, reportedOnly].entries()) {
const delta = Math.atan2(
Math.sin(entry.marker.rotation - before[i]),
Math.cos(entry.marker.rotation - before[i]),
);
assert.ok(
Math.abs(delta - Math.PI / 2) < 0.01,
`${entry.key}: rotation delta ${delta}`,
);
assert.equal(entry.courseDeg, 90);
}
assert.equal(
unknown.marker.rotation,
0,
'no course means screen-up, never a stale angle',
);
assert.equal(
hidden.marker.rotation,
0.7,
'hidden vehicles are not projected',
);
state._selectedKey = unknown.key;
unknown.marker.rotation = 0.7;
rendering.onPreRender();
assert.equal(
unknown.marker.rotation,
0,
'selected unknown course also stays screen-up',
);
});
+4 -1
View File
@@ -264,7 +264,10 @@ export function displayCourse(entry, nowMs) {
const target = Number.isFinite(entry.sample?.segmentCourseDeg)
? entry.sample.segmentCourseDeg
: null;
if (target === null) return entry.courseDeg ?? null;
if (target === null) {
if (Number.isFinite(entry.courseDeg)) return entry.courseDeg;
return Number.isFinite(entry.record?.bearing) ? entry.record.bearing : null;
}
if (entry.courseDeg === null || !Number.isFinite(entry.courseEvalAt)) {
return target;
}
+19
View File
@@ -144,3 +144,22 @@ test('mesh-floor sampling owns fully initialized numeric scratch before the firs
assert.deepEqual(Object.keys(value), shapes[i]),
);
});
test('course fallback prefers held course, then finite reported bearing, then unknown', () => {
for (const [held, bearing, expected] of [
[90, 270, 90],
[null, 90, 90],
[NaN, 0, 0],
[null, null, null],
[null, NaN, null],
[null, Infinity, null],
]) {
const e = {
courseDeg: held,
sample: { segmentCourseDeg: NaN },
record: { bearing },
};
applyDisplayCourse(e, 100);
assert.equal(e.courseDeg, expected);
}
});
+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();