mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
0bb61f53c6
commit
50d3672a00
8 files changed
+348
-21
No files matched your search
@@ -1,5 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
- Keep parked transit vehicles aligned to their world course during camera orbits, fall back to reported bearing, and keep vehicles with no course consistently screen-up.
|
||||
|
||||
- Separate application-shell responsibilities and state ownership while preserving
|
||||
the layer, scene and voice API. Revoke pending globe-reset callbacks on disposal
|
||||
and detach old Directions services when replacing a data manager.
|
||||
|
||||
@@ -702,6 +702,7 @@
|
||||
"src/layers/transit/viewport.js",
|
||||
"src/layers/transit/lifecycle.test.mjs",
|
||||
"scripts/qa-transit.mjs",
|
||||
"scripts/qa-transit-heading.mjs",
|
||||
"src/tooling/transitProvider.test.mjs",
|
||||
"src/app/layers/transit.js",
|
||||
"src/data/transitIcons.js",
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
/** Prove parked and moving headings in the real scene, without live feeds. */
|
||||
export async function runTransitHeadingRegression(base, check) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
args: ['--no-sandbox', '--disable-background-timer-throttling'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(
|
||||
`${base}/?welcome=0#lat=42.3601&lon=-71.0589&alt=1200&heading=0&pitch=-90`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.dataManager?.layers?.has('transit'),
|
||||
{ timeout: 90000 },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 6000));
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
layer._loadTransitFleetForTest(4, { lat: 42.3601, lon: -71.0589 }, 100);
|
||||
const state = layer._transitStateForTest(),
|
||||
parts = layer._transitPartsForTest();
|
||||
const { destroyTrack } = await import('/src/data/contactPlayback.js');
|
||||
const {
|
||||
initializePlayback,
|
||||
recordFix,
|
||||
updatePlayback,
|
||||
attachFloorToPlace,
|
||||
} = await import('/src/layers/transit/movement.js');
|
||||
// The first three are parked: held east, reported east only, unknown.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const entry = state._vehicles.get(`mbta:qa-${i}`);
|
||||
state._moving.delete(entry);
|
||||
destroyTrack(entry.track);
|
||||
initializePlayback(entry, state._historyBudget);
|
||||
recordFix(entry, {
|
||||
t: Date.now() - 30000,
|
||||
lat: entry.record.lat,
|
||||
lon: entry.record.lon,
|
||||
});
|
||||
attachFloorToPlace(entry, entry.record, 20);
|
||||
updatePlayback(entry, Date.now(), performance.now());
|
||||
entry.courseDeg = i === 0 ? 90 : null;
|
||||
entry.record.bearing = i === 1 ? 90 : null;
|
||||
entry.displayPaths.clear();
|
||||
parts.rendering.placeSample(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
}
|
||||
parts.rendering.syncRenderHold();
|
||||
window.__headingErrors = [];
|
||||
app.viewer.scene.renderError.addEventListener((scene, error) =>
|
||||
window.__headingErrors.push(error.message),
|
||||
);
|
||||
});
|
||||
const pose = async (heading) => {
|
||||
await page.evaluate((heading) => {
|
||||
const camera = window.__godsEyeView.viewer.camera;
|
||||
const C = camera.positionCartographic.constructor;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(C.fromDegrees(-71.0589, 42.3601, 1200)),
|
||||
orientation: { heading, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, heading);
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
};
|
||||
const sample = () =>
|
||||
page.evaluate(async () => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene;
|
||||
const state = app.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitStateForTest();
|
||||
const mul = (m, v) =>
|
||||
[0, 1, 2, 3].map(
|
||||
(r) =>
|
||||
m[r] * v[0] +
|
||||
m[r + 4] * v[1] +
|
||||
m[r + 8] * v[2] +
|
||||
m[r + 12] * v[3],
|
||||
);
|
||||
const project = (p) => {
|
||||
const clip = mul(
|
||||
scene.camera.frustum.projectionMatrix,
|
||||
mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]),
|
||||
);
|
||||
return {
|
||||
x: ((clip[0] / clip[3] + 1) * scene.canvas.clientWidth) / 2,
|
||||
y: ((1 - clip[1] / clip[3]) * scene.canvas.clientHeight) / 2,
|
||||
};
|
||||
};
|
||||
return [0, 1, 2, 3].map((i) => {
|
||||
const entry = state._vehicles.get(`mbta:qa-${i}`);
|
||||
const p = project(entry.marker.position);
|
||||
return {
|
||||
rotation: entry.marker.rotation,
|
||||
course: entry.courseDeg,
|
||||
moving: state._moving.has(entry),
|
||||
visible: entry.marker.show,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
};
|
||||
});
|
||||
});
|
||||
await pose(0);
|
||||
const before = await sample();
|
||||
await pose(Math.PI / 2);
|
||||
const after = await sample();
|
||||
const deltaDeg = (a, b) =>
|
||||
(Math.atan2(Math.sin(b - a), Math.cos(b - a)) * 180) / Math.PI;
|
||||
for (const [i, label] of ['held course', 'reported bearing'].entries()) {
|
||||
const delta = deltaDeg(before[i].rotation, after[i].rotation);
|
||||
check(
|
||||
`parked ${label} reprojects through a 90 degree camera orbit`,
|
||||
before[i].visible &&
|
||||
after[i].visible &&
|
||||
!before[i].moving &&
|
||||
!after[i].moving &&
|
||||
Math.abs(delta - 90) <= 5,
|
||||
JSON.stringify({ before: before[i], after: after[i], delta }),
|
||||
);
|
||||
}
|
||||
check(
|
||||
'unknown parked course remains screen-up',
|
||||
before[2].rotation === 0 && after[2].rotation === 0,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
const moved = (await sample())[3];
|
||||
const dx = moved.x - after[3].x,
|
||||
dy = moved.y - after[3].y;
|
||||
const travelRotation = Math.atan2(-dx, -dy);
|
||||
const error = Math.abs(deltaDeg(travelRotation, moved.rotation));
|
||||
check(
|
||||
'moving vehicle still points along its rendered travel after orbit',
|
||||
moved.visible && moved.moving && Math.hypot(dx, dy) > 0.5 && error <= 5,
|
||||
JSON.stringify({
|
||||
pixels: Math.hypot(dx, dy),
|
||||
error,
|
||||
after: after[3],
|
||||
moved,
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'heading regression has no scene render errors',
|
||||
(await page.evaluate(() => window.__headingErrors)).length === 0,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { runTransitHeadingRegression } from './qa-transit-heading.mjs';
|
||||
import {
|
||||
boundPageEvaluations,
|
||||
reportTransitVisibility,
|
||||
@@ -79,6 +80,18 @@ const check = (
|
||||
failures += 1;
|
||||
} else if (!passed) failures += 1;
|
||||
};
|
||||
// A focused deterministic regression can run independently of live-feed QA.
|
||||
if (process.argv.includes('--heading-only')) {
|
||||
try {
|
||||
await runTransitHeadingRegression(BASE, check);
|
||||
} catch (error) {
|
||||
check('heading regression completed', false, error.stack || error.message);
|
||||
}
|
||||
console.log(`HEADING: ${failures ? 'FAIL' : 'PASS'} (${failures} failed)`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
}
|
||||
if (runs('heading')) await runTransitHeadingRegression(BASE, check);
|
||||
|
||||
const renderingSource = await readFile(
|
||||
new URL('../src/layers/transit/rendering.js', import.meta.url),
|
||||
'utf8',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user