Street Level: a posted view whose camera was taken stands down whole again

Applying the rest of a posted view after the camera was taken during its
terrain wait still let it change the camera: stop_tracking dropped a follow
the user had just started, and annotate_map framed the view's marks. A
camera taken during the wait now ends the view again, as before. The 2 s
terrain timeout and the per-view catch on the queue stay.
This commit is contained in:
German Khokhlov committed 2026-11-11 19:01:58 +04:00
1 parent 0ebc09079d
commit 71797427d6
4 files changed
+135 -23

No files matched your search

+1 -2
View File
@@ -5324,8 +5324,7 @@ another map; `street_level` does not combine with `follow`.
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 (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
app waits 2 s for one). Links carry
`sl=<source>,<lat>,<lon>`; 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`.
+16 -16
View File
@@ -500,7 +500,6 @@ export function installViews({
queue = queue
.then(async () => {
if (signal?.aborted) return;
let apply = run;
if (unplaced && view.street_level) {
// A terrain service that does not answer soon leaves the view's
// own estimate.
@@ -514,20 +513,21 @@ export function installViews({
.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)
// Whoever takes the camera while the terrain answers (a drag, a
// search, the assistant) keeps it: the view stands down.
if (owner.taken()) {
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
steps: [{ step: 'street_level', ok: false, superseded: true }],
},
replyOrigin,
);
return;
}
if (point)
view = createView({
...event.data.view,
street_level: { ...asked, ...point },
@@ -536,7 +536,7 @@ export function installViews({
const steps = await applyView(view, {
viewer,
dataManager,
run: apply,
run,
signal,
autoOpen,
});
+118
View File
@@ -0,0 +1,118 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { installViews } from './embed.js';
import { createGevActionRunner } from '../voice/gevActions.js';
import { createAnnotationEngine } from '../annotations/annotationEngine.js';
import { fakeCesiumViewer } from '../testSupport/streetLevelFakes.mjs';
// A camera taken while a posted view waits on terrain stays with whoever took
// it: the stale view stands down whole, so it neither stops a newer follow nor
// frames its own marks. Offline: real installViews, action runner and
// annotation engine; the viewer, renderer, flights data and terrain are fixtures.
globalThis.fetch = async () => { throw new Error('Unexpected network request in offline reproduction'); };
globalThis.requestAnimationFrame = () => 1;
globalThis.cancelAnimationFrame = () => {};
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
function deferred() {
let resolve;
const promise = new Promise(done => { resolve = done; });
return { promise, resolve };
}
async function scenario(t, effect) {
document.body.className = '';
const entered = deferred();
const terrain = deferred();
const viewer = fakeCesiumViewer();
viewer.clock = { onTick: new Cesium.Event() };
viewer.canvas = viewer.scene.canvas;
// A real perspective frustum looking down at 0,0; the old view's mark
// at 48N,2E is outside it. No override of the engine's visibility logic.
Object.assign(viewer.camera, {
position: Cesium.Cartesian3.fromDegrees(0, 0, 1000),
direction: new Cesium.Cartesian3(-1, 0, 0),
up: new Cesium.Cartesian3(0, 0, 1),
heading: 0,
frustum: new Cesium.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1, near: 1, far: 20000000 }),
});
const entity = { id: 'fixture-aircraft' };
const aircraft = { icao24: 'fixture', callsign: 'Fixture', latitude: 0, longitude: 0 };
let stopCalls = 0;
const flights = {
findByQuery: () => aircraft,
trackById: () => { viewer.trackedEntity = entity; return true; },
getTrackedInfo: () => viewer.trackedEntity ? aircraft : null,
stopTracking: () => { stopCalls++; viewer.trackedEntity = undefined; return true; },
};
const dataManager = {
layers: new Map([['flights', { module: flights }]]),
getAll: () => [{ id: 'flights', enabled: true }],
isEnabled: id => id === 'flights',
setLayerParams: () => true,
};
const shell = {
_navigationGeneration: 1,
initialRestorePromise: Promise.resolve(),
setCleanView() {},
runImmediateNavigation(_noun, navigate) { this._navigationGeneration++; return navigate(); },
};
const annotations = createAnnotationEngine({ viewer,
renderer: { add() {}, update() {}, remove() {}, sync() {}, destroy() {} },
});
const runner = createGevActionRunner({ viewer, dataManager, styleManager: shell, annotations });
const calls = [];
const run = async (name, args, options) => {
calls.push(name);
return runner(name, args, options);
};
const replies = [];
const peer = { postMessage: message => replies.push(message) };
const windowRef = new EventTarget();
windowRef.parent = peer;
windowRef.document = document;
const remove = installViews({ shell, viewer, dataManager, run, windowRef,
location: { search: '?embed=1', hash: '' },
terrain: { getHeights: () => { entered.resolve(); return terrain.promise; } },
});
t.after(() => { remove(); runner.dispose(); annotations.destroy(); document.body.className = ''; });
await tick();
const event = new Event('message');
Object.assign(event, { source: peer, origin: 'https://host.example', data: {
type: 'gev:view', id: 1, view: {
camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: -45 },
layers: ['flights'],
street_level: { provider: 'mapillary' },
annotations: effect === 'annotations'
? [{ type: 'pin', latitude: 48, longitude: 2, label: 'Old view' }] : [],
},
} });
windowRef.dispatchEvent(event);
await entered.promise;
if (effect === 'follow') {
const follow = await runner('track_entity', { query: 'Fixture', layerId: 'flights' });
assert.equal(follow.ok, true, 'new follow starts through the real runner');
assert.equal(viewer.trackedEntity, entity);
} else {
viewer.canvas.dispatchEvent(new Event('pointerdown'));
}
terrain.resolve([{ ellipsoid: 200 }]);
for (let n = 0; n < 50 && !replies.some(reply => reply.id === 1); n++) await tick();
const reply = replies.find(reply => reply.id === 1);
t.diagnostic(JSON.stringify({ effect, calls, stopCalls, followed: viewer.trackedEntity === entity,
annotationCount: annotations.count(), annotationFlights: viewer.flights.started, reply }));
assert.ok(reply, 'host receives completion');
assert.equal(reply.ok, false, 'superseded view is acknowledged as not applied');
assert.ok(!calls.includes('show_street_level'), 'superseded request never reaches photo handler');
if (effect === 'follow') {
assert.equal(stopCalls, 0, 'stale view must not stop the newer follow');
assert.equal(viewer.trackedEntity, entity, 'the new tracked entity stays selected');
} else {
assert.equal(viewer.flights.started, 0, 'stale annotations must not fly the camera');
}
}
for (const effect of ['follow', 'annotations']) {
test(`a camera taken during the terrain wait keeps the user's ${effect}`, { timeout: 5000 }, t => scenario(t, effect));
}
-5
View File
@@ -144,11 +144,6 @@ 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));
});
}