diff --git a/CHANGELOG.md b/CHANGELOG.md index ae98dbd56..7325ab009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 031aedf31..7f26e1e71 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/qa-transit-heading.mjs b/scripts/qa-transit-heading.mjs new file mode 100644 index 000000000..a16bab9bc --- /dev/null +++ b/scripts/qa-transit-heading.mjs @@ -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(); + } +} diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index be39546ea..aac1d8f60 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -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', diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 74e80d550..795bbc654 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -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', + ); +}); diff --git a/src/layers/transit/movement.js b/src/layers/transit/movement.js index 716bc9082..84128cc39 100644 --- a/src/layers/transit/movement.js +++ b/src/layers/transit/movement.js @@ -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; } diff --git a/src/layers/transit/movement.test.mjs b/src/layers/transit/movement.test.mjs index fd71360e4..e758a52c8 100644 --- a/src/layers/transit/movement.test.mjs +++ b/src/layers/transit/movement.test.mjs @@ -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); + } +}); diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 0bb1f0ff6..96d77f2a2 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -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();