From 90d64e6916c12b8435f353a580919677cc2a98d8 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 23:30:10 +0400 Subject: [PATCH] Mark a resumed or share-restored follow only while it starts so a later follow of the same subject is navigation again The aircraft scene binding re-tracked with origin share-restore, which stayed on the entity: a later trackedEntityChanged for it (cockpit exit, refocus) never stamped navigation, and the aircraft lost its selection origin. Flights, military and satellites now set a transient gevRestoring mark around follow.start, and the aircraft binding resumes with the origin the aircraft was selected by. --- src/cameraHandoff.test.mjs | 2 +- src/layers/aircraft/sceneBinding.js | 7 +- src/layers/aircraft/sceneBinding.test.mjs | 154 ++++++++++++++++++---- src/layers/flights/tracking.js | 16 ++- src/layers/military/tracking.js | 16 ++- src/layers/satellites/tracking.js | 11 +- src/ui/layerBindings.js | 11 +- 7 files changed, 176 insertions(+), 41 deletions(-) diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 79497fd1a..746e77944 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -117,7 +117,7 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () => // Seam 1: a new tracker handoff stamps; restoring the session does not. assert.match( ui, - /viewer\.trackedEntityChanged\.addEventListener\(\s*\(entity\) => \{[\s\S]*?if \(\s*entity &&\s*!this\._disposed &&\s*entity\.gevSelectionOrigin !== 'share-restore'\s*\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/, + /viewer\.trackedEntityChanged\.addEventListener\(\s*\(entity\) => \{[\s\S]*?if \(entity && !this\._disposed && !entity\.gevRestoring\)\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\);/, 'tracker handoff must stamp', ); // Seam 2 is pinned by "Cockpit takeover invalidates deferred work" above. diff --git a/src/layers/aircraft/sceneBinding.js b/src/layers/aircraft/sceneBinding.js index d6ffaf1bd..59339bbfc 100644 --- a/src/layers/aircraft/sceneBinding.js +++ b/src/layers/aircraft/sceneBinding.js @@ -9,6 +9,9 @@ export function createAircraftSceneBinding({ enable, }) { let resumeFollow = false; + // The tracked aircraft's selection origin outlives the renderer, as the + // tracking itself does. + let resumeOrigin = 'programmatic'; function removeSceneListeners() { parts.tracking._removeClickHandler(); services.picking.unregisterPickOwner(id); @@ -58,7 +61,7 @@ export function createAircraftSceneBinding({ parts.snapshots.presentRecords(); if (flightState._trackedIcao) { parts.tracking._trackFlight(flightState._trackedIcao, { - origin: 'share-restore', + origin: resumeOrigin, resume: true, follow: resumeFollow, }); @@ -84,6 +87,8 @@ export function createAircraftSceneBinding({ flightState._trackedCameraFrameStop?.(); flightState._trackedCameraFrameStop = null; if (flightState._trackedEntity) { + resumeOrigin = + flightState._trackedEntity.gevSelectionOrigin ?? 'programmatic'; resumeFollow = renderer.follow.isFollowing(flightState._trackedEntity); if (resumeFollow) renderer.follow.release(); renderer.tracked.remove(flightState._trackedEntity); diff --git a/src/layers/aircraft/sceneBinding.test.mjs b/src/layers/aircraft/sceneBinding.test.mjs index ac5d81748..936e2498b 100644 --- a/src/layers/aircraft/sceneBinding.test.mjs +++ b/src/layers/aircraft/sceneBinding.test.mjs @@ -3,61 +3,165 @@ import test from 'node:test'; import { createAircraftSceneBinding } from './sceneBinding.js'; import { LayerBindings } from '../../ui/layerBindings.js'; import { createEngineEvent } from '../../globe/contract.js'; +import { readLayerSource } from '../../testSupport/readLayerSource.mjs'; -test('resuming an aircraft on Cesium preserves the pending share navigation', (t) => { +function observedShell(t) { const previous = globalThis.window; globalThis.window = new EventTarget(); t.after(() => { globalThis.window = previous; }); const trackedEntityChanged = createEngineEvent(); - let generation = 1; - const shell = new LayerBindings({ + const shell = { + generation: 1, + trackedEntityChanged, + }; + const bindings = new LayerBindings({ viewer: { trackedEntityChanged }, services: {}, operations: { _stampNavigation() { - generation++; + shell.generation++; }, }, }); - shell.observeCamera(); - t.after(() => shell.stop()); + bindings.observeCamera(); + t.after(() => bindings.stop()); + return shell; +} + +// What the tracking modules do around follow.start on a resume or a share +// restore (flights, military and satellites tracking.js). +function follow(trackedEntityChanged, entity, restoring) { + if (restoring) entity.gevRestoring = true; + try { + trackedEntityChanged.raiseEvent(entity); + } finally { + if (restoring) delete entity.gevRestoring; + } +} + +function binding(flightState, tracked) { const renderer = { - fleet: { attach() {}, setVisible() {}, restoreOrder() {} }, - models: { setVisible() {} }, + fleet: { + attach() {}, + detach() {}, + setVisible() {}, + restoreOrder() {}, + }, + models: { setVisible() {}, destroy() {} }, + follow: { + isFollowing: () => true, + release() {}, + }, + tracked: { remove() {} }, + destroy() {}, }; - let restored; - const binding = createAircraftSceneBinding({ + return createAircraftSceneBinding({ id: 'flights', - flightState: { - initialized: true, - _trackedIcao: 'a00000', - _preloadModel: {}, + flightState, + services: { + renderer: { create: () => renderer }, + render: { releaseContinuousRender() {} }, + picking: { unregisterPickOwner() {} }, }, - services: { renderer: { create: () => renderer } }, parts: { snapshots: { presentRecords() {} }, tracking: { - _trackFlight(id, options) { - restored = { id, ...options }; - trackedEntityChanged.raiseEvent({ - gevSelectionOrigin: options.origin, - }); - }, + _trackFlight: tracked, + _removeClickHandler() {}, + _detachTrail() {}, }, + rendering: { _releaseTrackedModel() {}, _releaseModels() {} }, + motion: { _resetTrackedDisplay() {} }, }, }); - binding.attachRenderer({}); +} + +test('resuming an aircraft on Cesium preserves the pending share navigation', (t) => { + const shell = observedShell(t); + let restored; + binding( + { initialized: true, _trackedIcao: 'a00000', _preloadModel: {} }, + (id, options) => { + restored = { id, ...options }; + follow( + shell.trackedEntityChanged, + { gevSelectionOrigin: options.origin }, + options.resume, + ); + }, + ).attachRenderer({}); assert.equal(restored.id, 'a00000'); assert.equal(restored.resume, true); assert.equal( - generation, + shell.generation, 1, 'restored tracking cannot supersede and clear itself', ); for (const origin of ['user', 'voice', 'programmatic']) { - trackedEntityChanged.raiseEvent({ gevSelectionOrigin: origin }); + follow(shell.trackedEntityChanged, { gevSelectionOrigin: origin }, false); + } + assert.equal( + shell.generation, + 4, + 'new tracking still takes navigation authority', + ); +}); + +test('a restored aircraft followed again later is new navigation', (t) => { + const shell = observedShell(t); + const entity = { gevSelectionOrigin: 'share-restore' }; + follow(shell.trackedEntityChanged, entity, true); + assert.equal(shell.generation, 1); + assert.equal('gevRestoring' in entity, false, 'the mark does not stick'); + // Cockpit exit or a refocus hands the same aircraft back to the camera. + shell.trackedEntityChanged.raiseEvent(undefined); + shell.trackedEntityChanged.raiseEvent(entity); + assert.equal(shell.generation, 2); +}); + +test('an engine switch resumes the aircraft with the origin it was selected by', () => { + const calls = []; + const flightState = { + initialized: true, + _trackedIcao: 'a00000', + _preloadModel: null, + _modelEpoch: 0, + _modelPending: new Map(), + _modelGen: new Map(), + _billboards: new Map(), + _cullPositions: new Map(), + _detectionObjects: new Set(), + _groundSnap: new Map(), + }; + const scene = binding(flightState, (id, options) => calls.push(options)); + flightState._preloadModel = {}; + scene.attachRenderer({}); + assert.equal(calls[0].origin, 'programmatic'); + flightState._trackedEntity = { gevSelectionOrigin: 'voice' }; + scene.detachRenderer({}); + flightState._preloadModel = {}; + scene.attachRenderer({}); + assert.equal(calls[1].origin, 'voice'); + assert.equal(calls[1].resume, true); + assert.equal(calls[1].follow, true); +}); + +test('every follow of a resumed or share-restored subject is marked while it starts', () => { + for (const [file, resume] of [ + ['flights/tracking.js', 'resume'], + ['military/tracking.js', 'resume'], + ['satellites/tracking.js', 'resuming'], + ]) { + const source = readLayerSource(new URL(`../${file}`, import.meta.url)); + assert.match( + source, + new RegExp( + `const restoring = ${resume} \\|\\| origin === 'share-restore';\\s*if \\(restoring\\) entity\\.gevRestoring = true;\\s*try \\{[\\s\\S]*?follow\\.start\\(entity\\);?\\s*\\} finally \\{\\s*if \\(restoring\\) delete entity\\.gevRestoring;`, + ), + file, + ); + assert.equal(source.match(/follow\.start\(/g).length, 1, file); } - assert.equal(generation, 4, 'new tracking still takes navigation authority'); }); diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index 177ebde18..a58feeabe 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -1011,10 +1011,20 @@ export function createTracking({ refreshTrackedReadout(flightState._trackedEntity); // Camera follows the tracked entity (the renderer cancels any flight // first and applies the one exact tracked frame). + // A resumed binding or a share restore re-follows the session's own + // subject: marked only while the follow starts, so the shell does not + // read it as new navigation (LayerBindings.observeCamera) yet any later + // follow of the same aircraft still is. if (follow) { - flightState._trackedCameraFrameStop = flightState._renderer.follow.start( - flightState._trackedEntity, - ); + const entity = flightState._trackedEntity; + const restoring = resume || origin === 'share-restore'; + if (restoring) entity.gevRestoring = true; + try { + flightState._trackedCameraFrameStop = + flightState._renderer.follow.start(entity); + } finally { + if (restoring) delete entity.gevRestoring; + } } // Track-history trail (PRD F1): seed from local history + async backfill. diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 77cab5257..86db24c8f 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -971,10 +971,20 @@ export function createTracking({ refreshTrackedReadout(flightState._trackedEntity); // Camera follows the tracked entity (the renderer cancels any flight // first and applies the one exact tracked frame). + // A resumed binding or a share restore re-follows the session's own + // subject: marked only while the follow starts, so the shell does not + // read it as new navigation (LayerBindings.observeCamera) yet any later + // follow of the same aircraft still is. if (follow) { - flightState._trackedCameraFrameStop = flightState._renderer.follow.start( - flightState._trackedEntity, - ); + const entity = flightState._trackedEntity; + const restoring = resume || origin === 'share-restore'; + if (restoring) entity.gevRestoring = true; + try { + flightState._trackedCameraFrameStop = + flightState._renderer.follow.start(entity); + } finally { + if (restoring) delete entity.gevRestoring; + } } if (resume) { _attachTrail(); diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index 65face939..aaf160c84 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -485,7 +485,16 @@ export function createTracking({ layerState._contextRefreshedAtMs = Date.now(); } - getRenderer().follow.start(layerState._trackedEntity); + // Resumed or share-restored: not new navigation, only while the follow + // starts (see flights/tracking.js). + const entity = layerState._trackedEntity; + const restoring = resuming || origin === 'share-restore'; + if (restoring) entity.gevRestoring = true; + try { + getRenderer().follow.start(entity); + } finally { + if (restoring) delete entity.gevRestoring; + } console.log(`[Data:Satellites] Tracking ${name} (NORAD ${noradId})`); } return { diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index ee8a84637..4eb73d7c6 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -86,13 +86,10 @@ export class LayerBindings { if (this.viewer) this._navigationOwnerChangedRemover = this.viewer.trackedEntityChanged.addEventListener((entity) => { - // Rebinding the session's aircraft is part of the pending share - // restoration, not a new navigation that supersedes that restore. - if ( - entity && - !this._disposed && - entity.gevSelectionOrigin !== 'share-restore' - ) + // Rebinding the session's subject (a resumed renderer, a share + // restore) is part of the pending restoration, not a new navigation + // that supersedes it. The mark lasts only while the follow starts. + if (entity && !this._disposed && !entity.gevRestoring) this._stampNavigation({ cancelPendingSelection: false }); }); // Vessel/installation focus flies without ever assigning a tracked entity,