From 0ebc09079d48b69b902444d93164b0fb64f56be1 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 18:02:15 +0400 Subject: [PATCH] Street Level: polar look-at, a bounded terrain wait, takeovers during the flight - The look-at longitude wraps fully, so a tilted camera at a pole gives a point on the globe. The strict off-globe check on street_level is undone: points are clamped again, as before. - A posted view that fails is answered, and the views behind it still apply. - A posted view waits at most 2 s for the terrain under its camera, then keeps its own estimate. - A camera taken during that wait stays where it is: the rest of the view applies, and only its photos stand down. - show_street_level keeps a search or a press on the globe made during its own flight: it adopts only its own navigation stamps on landing, and a gesture is never forgiven. --- docs/CURRENT-STATE.md | 4 +- src/app/embed.js | 106 +++++++++++++++++----------- src/navigationPolicy.js | 6 +- src/streetLevelReplacement.test.mjs | 89 ++++++++++++++++++++++- src/view/index.js | 9 +-- src/view/index.test.mjs | 15 ---- src/voice/gevActions.js | 9 ++- src/voice/gevActions.test.mjs | 34 +++++++-- 8 files changed, 193 insertions(+), 79 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 038f59627..b9585de17 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5323,7 +5323,9 @@ another map; `street_level` does not combine with `follow`. `show_in_gods_eye_view`, and the embedded app for a posted view with no point, measure the view's altitude from the terrain under the camera (`groundedLookAt` with the terrain heights service) before taking the look-at -point, and keep the view's own estimate without a terrain answer. Links carry +point, and keep the view's own estimate without a terrain answer (the embedded +app waits 2 s for one). A camera taken during that wait stays where it is: +the rest of the view applies and its photos do not open. Links carry `sl=,,`; the globe panel applies views through the action, and a plain link runs it once restored (one shot: the rewritten hash drops `sl`). A link that follows an entity ignores `sl`. diff --git a/src/app/embed.js b/src/app/embed.js index 13e928df1..5a3119da9 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -39,6 +39,8 @@ const FOLLOW_LAYERS = { const FOLLOW_ATTEMPTS = 20; const FOLLOW_RETRY_MS = 1000; const FLIGHT_SECONDS = 2; +/** How long a posted view waits for the terrain under its camera. */ +const TERRAIN_WAIT_MS = 2000; /** * Whether this page shows the app embedded: `?embed=1` when another page @@ -495,51 +497,71 @@ export function installViews({ typeof asked === 'object' && asked.lat == null && asked.lon == null; - queue = queue.then(async () => { - if (signal?.aborted) return; - if (unplaced && view.street_level) { - // Whoever takes the camera while the terrain answers (a drag, a - // search, the assistant) keeps it: the view stands down. - const owner = watchCameraOwner(viewer, shell); - const point = await groundedLookAt(view.camera, terrain, signal) - .catch(() => null) - .finally(() => owner.stop()); + queue = queue + .then(async () => { if (signal?.aborted) return; - if (owner.taken()) { - post( - { - type: EMBED_APPLIED_MESSAGE, - id, - ok: false, - steps: [{ step: 'street_level', ok: false, superseded: true }], - }, - replyOrigin, - ); - return; + let apply = run; + if (unplaced && view.street_level) { + // A terrain service that does not answer soon leaves the view's + // own estimate. + const wait = AbortSignal.timeout(TERRAIN_WAIT_MS); + const owner = watchCameraOwner(viewer, shell); + const point = await groundedLookAt( + view.camera, + terrain, + signal ? AbortSignal.any([signal, wait]) : wait, + ) + .catch(() => null) + .finally(() => owner.stop()); + if (signal?.aborted) return; + // Whoever takes the camera meanwhile (a drag, a search, the + // assistant) keeps it: the photos stand down, as when the camera + // is taken during the action, and the rest of the view applies. + if (owner.taken()) + apply = (name, ...rest) => + name === 'show_street_level' + ? { + ok: false, + cancelled: true, + superseded: true, + error: 'The camera moved on before the photo opened', + } + : run(name, ...rest); + else if (point) + view = createView({ + ...event.data.view, + street_level: { ...asked, ...point }, + }); } - if (point) - view = createView({ - ...event.data.view, - street_level: { ...asked, ...point }, - }); - } - const steps = await applyView(view, { - viewer, - dataManager, - run, - signal, - autoOpen, - }); - post( - { - type: EMBED_APPLIED_MESSAGE, - id, - ok: steps.every((step) => step.ok), - steps, - }, - replyOrigin, + const steps = await applyView(view, { + viewer, + dataManager, + run: apply, + signal, + autoOpen, + }); + post( + { + type: EMBED_APPLIED_MESSAGE, + id, + ok: steps.every((step) => step.ok), + steps, + }, + replyOrigin, + ); + }) + // A view that fails is answered, and the views behind it still apply. + .catch((error) => + post( + { + type: EMBED_APPLIED_MESSAGE, + id, + ok: false, + error: error?.message || String(error), + }, + replyOrigin, + ), ); - }); }; windowRef.addEventListener('message', onMessage); void ready.then(() => { diff --git a/src/navigationPolicy.js b/src/navigationPolicy.js index 0813b773e..63e489ed8 100644 --- a/src/navigationPolicy.js +++ b/src/navigationPolicy.js @@ -188,8 +188,9 @@ const CAMERA_GESTURES = ['pointerdown', 'wheel']; /** * Whether the camera is still a request's: a navigation the shell stamps (a * search, the assistant, a link) or a press or wheel on the globe hands it to - * someone else. `claim()` makes the camera as it is now the request's (after - * its own flight); `stop()` removes the gesture listeners. + * someone else. `claim()` takes the navigation stamped so far as the + * request's (its own flight); a gesture is never forgiven. `stop()` removes + * the gesture listeners. */ export function watchCameraOwner(viewer, navigation) { let generation = navigation?._navigationGeneration; @@ -203,7 +204,6 @@ export function watchCameraOwner(viewer, navigation) { return { claim: () => { generation = navigation?._navigationGeneration; - taken = false; }, taken: () => taken || diff --git a/src/streetLevelReplacement.test.mjs b/src/streetLevelReplacement.test.mjs index 2d931d707..84fe3cf30 100644 --- a/src/streetLevelReplacement.test.mjs +++ b/src/streetLevelReplacement.test.mjs @@ -5,10 +5,12 @@ import { createStreetLevelLayer } from './layers/streetLevel/index.js'; import { fakeStreetLevelProvider, fakeCesiumViewer } from './testSupport/streetLevelFakes.mjs'; import { showStreetLevel } from './voice/gevActions.js'; import { installViews } from './app/embed.js'; +import { groundedLookAt } from './view/index.js'; +import { composeCatalog, coreTools } from './tools/index.js'; // Replacing a street-level photo, across sources and against the user's own -// moves; posted views waiting on terrain. Offline: providers, lookups, terrain -// and flights are fixtures. +// moves; posted views waiting on terrain, and their look-at near a pole. +// Offline: providers, lookups, terrain and flights are fixtures. globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); }; const settle = () => new Promise(resolve => setTimeout(resolve, 0)); function deferred() { @@ -142,6 +144,11 @@ for (const takeover of ['navigation', 'gesture', 'none']) { assert.equal(h.flights.length, takeover === 'none' ? 1 : 0, 'superseded view must not reclaim the camera'); assert.equal(a.calls.open.length, takeover === 'none' ? 1 : 0); assert.equal(reply.ok, takeover === 'none'); + // Only the photos stand down: the rest of the view still applies, and + // the camera stays with whoever took it. + const steps = reply.steps.map(step => step.step); + assert.ok(steps.includes('clear_annotations'), JSON.stringify(steps)); + assert.ok(!steps.includes('camera'), JSON.stringify(steps)); }); } @@ -199,3 +206,81 @@ test('a failed replacement from another source does not keep the old photo\'s de assert.deepEqual({ imageId: street.imageId, position: street.position, creator: street.creator, externalUrl: street.externalUrl }, { imageId: null, position: null, creator: null, externalUrl: null }, 'destroyed provider photo must not be attributed to replacement'); }); + +const rejections = []; +process.on('unhandledRejection', reason => { rejections.push(String(reason?.message || reason)); }); +// 111 m from the North Pole, looking east and down: 1 km ahead is more than a +// full turn of that parallel. +const north = { camera: { lat: 89.999, lon: 170, altitude_m: 1000, pitch_deg: -45, heading_deg: 90 }, street_level: { provider: 'mapillary' } }; +const placed = { camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: -45 }, street_level: { provider: 'mapillary', lat: 48, lon: 2 } }; + +test('a grounded look-at near a pole stays on the globe', async () => { + const point = await groundedLookAt(north.camera, { getHeights: async () => [{ ellipsoid: 0 }] }); + assert.ok(Math.abs(point.lat) <= 90 && Math.abs(point.lon) <= 180, JSON.stringify(point)); +}); + +test('show_in_gods_eye_view frames the South Pole with a tilted camera', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { app: { baseUrl: 'http://localhost:4173/' }, terrain: { getHeights: async () => [{ ellipsoid: 2835 }] } }, + }); + const result = await catalog.call('show_in_gods_eye_view', { + camera: { lat: -90, lon: 0, altitude_m: 3500, pitch_deg: -30, heading_deg: 45 }, + street_level: { provider: 'mapillary' }, + }); + const street = result.data?.view?.street_level; + assert.ok(street && Math.abs(street.lat) <= 90 && Math.abs(street.lon) <= 180, JSON.stringify(result)); +}); + +test('a posted polar view is answered and the next view still applies', async t => { + rejections.length = 0; + const h = await harness(t, [provider('mapillary')]); + const e = await embed(t, h, { getHeights: async () => [{ ellipsoid: 0 }] }); + e.post(1, north); + e.post(2, placed); + for (let n = 0; n < 20 && !e.replies.some(r => r.id === 2); n++) await settle(); + t.diagnostic(JSON.stringify({ replies: e.replies.filter(r => r.id), rejections })); + assert.ok(e.replies.some(r => r.id === 1), 'the polar view gets an answer'); + assert.ok(e.replies.some(r => r.id === 2 && r.ok === true), 'the next view still applies'); + assert.deepEqual(rejections, [], 'no rejection escapes the views queue'); +}); + +test('a stalled terrain answer does not hold posted views', { timeout: 15000 }, async t => { + const h = await harness(t, [provider('mapillary')]); + let asked = 0; + // Hangs like a stalled upstream; gives up only when its signal aborts. + const terrain = { + getHeights: (_points, { signal } = {}) => { + asked++; + return new Promise((_, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true })); + }, + }; + const e = await embed(t, h, terrain); + e.post(1, view(-45)); + e.post(2, placed); + const started = Date.now(); + while (Date.now() - started < 5000 && !e.replies.some(r => r.id === 2)) await new Promise(resolve => setTimeout(resolve, 100)); + t.diagnostic(JSON.stringify({ asked, waitedMs: Date.now() - started, replies: e.replies.filter(r => r.id).map(r => r.id) })); + assert.equal(asked, 1); + assert.ok(e.replies.some(r => r.id === 1), 'the view falls back to its own estimate within 5 s'); + assert.ok(e.replies.some(r => r.id === 2), 'the next view is not held behind it'); +}); + +test('a posted view that throws is answered and does not hold the views behind it', async t => { + rejections.length = 0; + const h = await harness(t, [provider('mapillary')]); + const getAll = h.dataManager.getAll; + let calls = 0; + h.dataManager.getAll = () => { + if (calls++ === 0) throw new Error('Fixture layer list failure'); + return getAll(); + }; + const e = await embed(t, h, null); + e.post(1, placed); + e.post(2, placed); + for (let n = 0; n < 20 && !e.replies.some(r => r.id === 2); n++) await settle(); + t.diagnostic(JSON.stringify({ replies: e.replies.filter(r => r.id), rejections })); + assert.deepEqual(e.replies.find(r => r.id === 1), { type: 'gev:view-applied', id: 1, ok: false, error: 'Fixture layer list failure' }); + assert.ok(e.replies.some(r => r.id === 2 && r.ok === true), 'the next view still applies'); + assert.deepEqual(rejections, []); +}); diff --git a/src/view/index.js b/src/view/index.js index cd5f20f31..be7324fe0 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -119,7 +119,8 @@ export function lookAtPoint(camera) { const lon = camera.lon + dLon; return { lat: clamp(camera.lat + dLat, -90, 90), - lon: lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon, + // Near a pole one step ahead can be several turns of the parallel. + lon: ((((lon + 180) % 360) + 360) % 360) - 180, }; } @@ -387,12 +388,6 @@ export function createView({ // photos at X, also when the camera sits behind it. It is a pair: half // of one is not mixed with the camera's other half. const given = [latitude(streetLevel.lat), longitude(streetLevel.lon)]; - // A coordinate given off the globe is an error, not a reason to guess. - for (const [index, key] of ['lat', 'lon'].entries()) - if (streetLevel[key] != null && given[index] === undefined) - throw new TypeError( - `Street-level ${key} is not a valid coordinate: ${streetLevel[key]}`, - ); const seen = lookAtPoint(camera); const [lat, lon] = given.includes(undefined) ? [seen.lat, seen.lon] : given; street = Object.freeze({ diff --git a/src/view/index.test.mjs b/src/view/index.test.mjs index 046d76b3c..9fda7d149 100644 --- a/src/view/index.test.mjs +++ b/src/view/index.test.mjs @@ -208,21 +208,6 @@ test('street-level photos travel in the link, light their source and bring their assert.equal(streetLevelFromParams(new URLSearchParams('sl=bing,1,2')), null); assert.equal(streetLevelFromParams(new URLSearchParams('lat=1')), null); assert.equal(createView({ camera: { lat: 1, lon: 2 } }).street_level, null); - // A point off the globe is refused, like an unknown source. - for (const bad of [ - { lat: 95, lon: 2 }, - { lat: 48, lon: 200 }, - { lat: Number.NaN, lon: 2 }, - ]) - assert.throws( - () => - createView({ - camera: { lat: 1, lon: 2 }, - street_level: { provider: 'mapillary', ...bad }, - }), - TypeError, - JSON.stringify(bad), - ); // Half a point is not mixed with the camera's other half. const half = createView({ camera: { lat: 1, lon: 2 }, diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 14b8102c3..9a38408b2 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -812,6 +812,7 @@ export function createGevActionRunner({ runOptions, current, reportProgress, + otherHandoffs, }); } @@ -3813,9 +3814,12 @@ async function requestStreetLevel( runOptions = {}, current = () => true, reportProgress = () => {}, + otherHandoffs = () => 0, }, ) { const action = 'show_street_level'; + // Navigations other than voice fly-tos (this request's own flight) so far. + const handoffs = otherHandoffs(); let provider = null; let providerName = null; let label = null; @@ -3972,8 +3976,9 @@ async function requestStreetLevel( } // A mode that hides the panel may have started during the flight. The - // camera is the request's from where its flight landed. - if (location) owner.claim(); + // camera is the request's from where its flight landed, unless another + // navigation took it during the flight. + if (location && otherHandoffs() === handoffs) owner.claim(); const arrived = interrupted('view'); if (arrived) return arrived; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 64e36c42b..98bc786db 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -6573,11 +6573,10 @@ function createStreetLevelHarness({ const lookups = []; const origins = []; const revealed = []; - const { viewer, styleManager, currentGeneration } = createVoiceNavigationHarness(); + const { viewer, styleManager, currentGeneration, panelSearch } = createVoiceNavigationHarness(); // Who owns the camera: the shell's navigation stamps and globe gestures. - let userNavigations = 0; Object.defineProperty(styleManager, '_navigationGeneration', { - get: () => currentGeneration() + userNavigations, + get: () => currentGeneration(), }); viewer.scene.canvas = Object.assign(new EventTarget(), { clientWidth: 1200, clientHeight: 800 }); let completeFlight = null; @@ -6719,10 +6718,9 @@ function createStreetLevelHarness({ origins, revealed, street, - // The user searches or flies elsewhere, or drags the globe. - navigate: () => { - userNavigations += 1; - }, + // The user searches or flies elsewhere (a stamp, as the shell's), or + // drags the globe. + navigate: panelSearch, gesture: () => viewer.scene.canvas.dispatchEvent(new Event('pointerdown')), }; } @@ -7428,6 +7426,28 @@ test('show_street_level owns the camera from where its own flight lands', async assert.ok(h.order.some((step) => step.startsWith('open:mapillary'))); }); +// A camera taken during the action's own flight. 'navigate' is a +// location-panel search: a stamp that leaves the flight flying. 'gesture' is a +// press on the globe, which does not cancel a Cesium flight either. +for (const take of ['navigate', 'gesture']) { + test(`show_street_level stands down when the camera is taken during its own flight (${take})`, async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const h = createStreetLevelHarness(); + const pending = h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(h.order, ['fly'], 'the flight is under way'); + h[take](); + h.finishFlight(); + const result = await pending; + assert.deepEqual([result.ok, result.superseded], [false, true], result.error); + assert.deepEqual( + h.order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), + [], + 'no map switch, no source, no billed photo for the place the user left', + ); + }); +} + test('show_street_level closes the previous photo, so one that finds nothing leaves no other place on screen', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const empty = createStreetLevelHarness({ enabled: true, photoOpen: true, nearest: null });