From 145adc08e06ba5079f9aa87b3a14b4cae7ddb64c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 7 Nov 2026 03:44:38 +0400 Subject: [PATCH] Keep the tracked aircraft through a failed engine switch on Cesium Cesium follows a display's sprite through a proxy Entity that mirrors the native's readout fields, and gevRestoring was not one of them. A follow the flights layer resumed on attach therefore read as new navigation to the shell's tracked-entity watcher, which made the share restore of the same attach count itself superseded and clear the selection. That is why the aircraft was lost after a failed switch's rollback onto Cesium, and after the retry. The proxy now shows the restoring mark while its follow starts. The flights renderer test runs a detach, a failed attach to a candidate, the rollback and a retry on headless Cesium engines, and checks that the aircraft stays tracked and no navigation is stamped. --- src/globe/cesium/follow.js | 7 ++- src/globe/cesium/follow.test.mjs | 20 +++++++ src/layers/flights/renderer.test.mjs | 84 ++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/globe/cesium/follow.js b/src/globe/cesium/follow.js index 81c2097c5..30fcc80e1 100644 --- a/src/globe/cesium/follow.js +++ b/src/globe/cesium/follow.js @@ -23,13 +23,18 @@ import { toCesium3 } from './convert.js'; const MAX_FRAME_ATTEMPTS = 120; const entityName = (entity) => (entity ? entity.name || entity.id : undefined); // What viewer.trackedEntity readers take from a tracked Entity, read through -// a proxy from the layer's own handle. +// a proxy from the layer's own handle. gevRestoring marks a follow that +// rebinds the session's own subject (a resumed renderer, a share restore) +// while it starts: trackedEntityChanged readers (the shell's navigation +// stamp) must see it on the proxy, or a resumed follow supersedes the +// restore it belongs to. const READOUT_FIELDS = [ 'gevTrackedId', 'gevLabelModel', 'gevDisplayPosition', 'gevVisualPosition', 'gevSelectionOrigin', + 'gevRestoring', ]; const ZOOM_INERTIA_STATES = new WeakMap(); diff --git a/src/globe/cesium/follow.test.mjs b/src/globe/cesium/follow.test.mjs index 6d1f3f40e..80d331713 100644 --- a/src/globe/cesium/follow.test.mjs +++ b/src/globe/cesium/follow.test.mjs @@ -109,6 +109,26 @@ test('a native that is not an Entity is echoed and followed through an ENU proxy assert.equal(viewer.entities.contains(proxy), false); }); +test("the proxy shows a restoring native's mark while its follow starts", () => { + const { viewer, camera } = setup(); + const native = { id: 'flights-tracked-1', gevTrackedId: 'flights:abc123' }; + // What trackedEntityChanged readers see as the follow starts. + const seen = []; + viewer.trackedEntityChanged.addEventListener((entity) => + seen.push(entity?.gevRestoring), + ); + native.gevRestoring = true; + const handle = camera.follow({ + native, + position: () => TARGET, + viewFrom: () => ({ x: 0, y: -500, z: 200 }), + }); + delete native.gevRestoring; + assert.deepEqual(seen, [true]); + assert.equal(viewer.trackedEntity.gevRestoring, undefined, 'read through'); + handle.stop(); +}); + test('a camera command or another tracked entity ends the follow', () => { const { viewer, camera, changes } = setup(); const options = { position: () => TARGET, viewFrom: { x: 0, y: -500, z: 0 } }; diff --git a/src/layers/flights/renderer.test.mjs b/src/layers/flights/renderer.test.mjs index e90323ee0..2a1d050d6 100644 --- a/src/layers/flights/renderer.test.mjs +++ b/src/layers/flights/renderer.test.mjs @@ -293,3 +293,87 @@ test('the flights logic draws, tracks and follows on the Cesium engine through t assert.equal(engine.lines.list().length, 0, 'no line left behind'); engine.destroy(); }); + +test('a failed engine switch, its rollback and the retry re-follow the tracked aircraft as a restore', async () => { + const { createCesiumMoversEngine } = + await import('../../testSupport/cesiumMoversEngine.mjs'); + const { createFlightsDisplay } = await import('./render/display.js'); + const { aircraftAtlasLayout } = await import('../aircraft/render/display.js'); + const layout = aircraftAtlasLayout(); + // The shell's navigation stamp (LayerBindings.observeCamera): a follow + // Cesium announces through trackedEntityChanged is new navigation unless + // it rebinds the session's own subject. A new navigation supersedes the + // switch's share restore, which then clears the selection. + const navigations = []; + const cesium = () => { + const made = createCesiumMoversEngine(); + made.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(-97.5, 30.3, 400_000), + }); + made.viewer.trackedEntityChanged.addEventListener((entity) => { + if (entity && !entity.gevRestoring) + navigations.push(entity.gevTrackedId); + }); + return made; + }; + const working = cesium(); + let display = null; + const layer = createApplicationFlights({ + surface: defaultSurface, + source: { + label: 'Fixture aircraft', + getSnapshot: async () => snapshotOf(['abc123', 'def456']), + }, + militaryRegistry, + renderer: { + create: ({ engine: given }) => + (display = createFlightsDisplay(given, { + drawAtlas: async () => ({ + canvas: { width: layout.width, height: layout.height }, + cells: layout.cells, + }), + })), + }, + }); + layer.init(working.viewer, { engine: working.engine }); + layer.enable(); + const candidate = createFakeEngine(); + let retried = null; + try { + await display.whenReady(); + await layer.update(null); + await new Promise((resolve) => setTimeout(resolve, 0)); + // The person's own selection is navigation. + assert.equal(layer.trackById('def456', { origin: 'user' }), true); + assert.deepEqual(navigations, ['flights:def456']); + navigations.length = 0; + // The switch: the working engine detaches; the candidate attaches the + // layer, then fails and releases what it attached. + layer.detachRenderer(working.viewer); + layer.attachRenderer(null, { engine: candidate }); + assert.equal(candidate.camera.following()?.label?.id, 'flights:def456'); + layer.detachRenderer(null); + // The rollback: the working engine attaches again and follows the same + // aircraft, announced as a restore. + layer.attachRenderer(working.viewer, { engine: working.engine }); + assert.equal(layer.getTrackedInfo()?.icao24, 'def456'); + assert.equal(working.viewer.trackedEntity?.gevTrackedId, 'flights:def456'); + assert.deepEqual(navigations, [], 'the rollback is not new navigation'); + // The retry onto a new Cesium engine. + layer.detachRenderer(working.viewer); + retried = cesium(); + layer.attachRenderer(retried.viewer, { engine: retried.engine }); + assert.equal(layer.getTrackedInfo()?.icao24, 'def456'); + assert.equal(retried.viewer.trackedEntity?.gevTrackedId, 'flights:def456'); + assert.equal( + retried.engine.camera.following()?.label?.id, + 'flights:def456', + ); + assert.deepEqual(navigations, [], 'the retry is not new navigation'); + } finally { + layer.disable(); + layer.destroy(retried?.viewer ?? working.viewer); + } + for (const engine of [working.engine, candidate, retried?.engine]) + engine?.destroy(); +});