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.
This commit is contained in:
German Khokhlov committed 2026-11-03 23:30:10 +04:00
1 parent cbe1d841ea
commit 90d64e6916
7 files changed
+176 -41

No files matched your search

+1 -1
View File
@@ -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.
+6 -1
View File
@@ -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);
+129 -25
View File
@@ -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');
});
+13 -3
View File
@@ -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.
+13 -3
View File
@@ -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();
+10 -1
View File
@@ -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 {
+4 -7
View File
@@ -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,