From 057dbe24afae1ac5b90bc7f281a165ec9014d483 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 20:31:49 +0400 Subject: [PATCH] Reframe a mission after a late active-satellite answer only while nobody has left its orbit view A flyover, a launch-site view, a cancelled flyover or another mission now clears the pending orbit framing, so a late TLE lookup followed by a data refresh never takes the camera back. --- src/layers/launches/edgeCases.test.mjs | 99 +++++++++++++++++++++++++- src/layers/launches/ingestion.js | 10 ++- src/layers/launches/lifecycle.js | 42 ++++++++--- src/layers/launches/replay.js | 8 ++- src/layers/launches/selection.js | 4 ++ src/layers/launches/state.js | 5 ++ 6 files changed, 153 insertions(+), 15 deletions(-) diff --git a/src/layers/launches/edgeCases.test.mjs b/src/layers/launches/edgeCases.test.mjs index 63f43dbba..232fc50a6 100644 --- a/src/layers/launches/edgeCases.test.mjs +++ b/src/layers/launches/edgeCases.test.mjs @@ -26,7 +26,7 @@ const VANDENBERG = { const pad = (launch, height = 0) => geo.cartesian3.fromDegrees(launch.lon, launch.lat, height); -function rig() { +function rig({ tle = async () => '1 25544U' } = {}) { const engine = createFakeEngine({ kind: 'navara' }); engine.fake.setPose({ longitude: geo.math.toRadians(-100), @@ -52,7 +52,7 @@ function rig() { let feed = [CAPE, VANDENBERG]; const source = { getLaunches: async () => feed, - getActiveTle: async () => '1 25544U', + getActiveTle: () => tle(), }; const parts = { panel: { @@ -119,6 +119,7 @@ function rig() { ].includes(method), ), cleanup() { + parts.ingestion.clearPostTleRetry(); state._sourceController.abort(); parts.replay.stopMissionReplay(); renderer.destroy(); @@ -183,3 +184,97 @@ test('edge:rocket-launches: a launch cancelled mid-flyover stops the flyover and r.cleanup(); } }); + +/** An active-satellite lookup that answers only when the test says so. */ +function lateTle() { + let answer; + const pending = new Promise((resolve) => (answer = resolve)); + return { tle: () => pending, answer: () => answer('1 25544U') }; +} +const flights = (r) => + r.cameraCommands().filter(({ method }) => method !== 'camera.lookAt'); + +test('edge:rocket-launches: a late TLE answer and a refresh never take the camera from a superseded flyover', async () => { + const lookup = lateTle(); + const r = rig({ tle: lookup.tle }); + try { + await r.parts.ingestion.requestMissionUpdate(); + r.parts.selection.setSelectedMission(CAPE.id); + r.parts.selection.focusMission(CAPE); + r.engine.fake.frames(200); + r.parts.replay.startMissionReplay(CAPE.id); + r.engine.fake.frames(20); + // The user moves on to the second mission's launch site. + r.parts.selection.setSelectedMission(VANDENBERG.id); + r.parts.selection.focusLaunchSite(VANDENBERG); + r.engine.fake.frames(400); + const parked = r.engine.camera.pose().position; + r.engine.fake.resetCalls(); + lookup.answer(); + await settle(); + await r.parts.ingestion.requestMissionUpdate(); + await settle(); + r.engine.fake.frames(400); + + assert.deepEqual(flights(r), [], 'no flight after the refresh'); + assert.ok(near(r.engine.camera.pose().position, parked, 1)); + assert.equal(r.state._selectedLaunchId, VANDENBERG.id); + } finally { + r.cleanup(); + } +}); + +test('edge:rocket-launches: a late TLE answer and a refresh never take the camera after a running or cancelled flyover', async () => { + for (const cancel of [false, true]) { + const lookup = lateTle(); + const r = rig({ tle: lookup.tle }); + try { + await r.parts.ingestion.requestMissionUpdate(); + r.parts.selection.setSelectedMission(CAPE.id); + r.parts.selection.focusMission(CAPE); + r.engine.fake.frames(200); + r.parts.replay.startMissionReplay(CAPE.id); + r.engine.fake.frames(20); + if (cancel) r.parts.replay.stopMissionReplay(); + lookup.answer(); + await settle(); + // The refresh ends a running flyover (its tracks are rebuilt) and must + // leave the camera where the flyover put it. + const parked = r.engine.camera.pose().position; + r.engine.fake.resetCalls(); + await r.parts.ingestion.requestMissionUpdate(); + await settle(); + r.engine.fake.frames(400); + + assert.deepEqual(flights(r), [], `no flight (cancelled: ${cancel})`); + assert.ok(near(r.engine.camera.pose().position, parked, 1)); + assert.equal(r.state._replayCameraLaunchId, null); + assert.equal(r.state._selectedLaunchId, CAPE.id, 'the card stays'); + } finally { + r.cleanup(); + } + } +}); + +test('edge:rocket-launches: a mission still framed when its late TLE arrives is framed again with the orbit', async () => { + const lookup = lateTle(); + const r = rig({ tle: lookup.tle }); + try { + await r.parts.ingestion.requestMissionUpdate(); + r.parts.selection.setSelectedMission(CAPE.id); + r.parts.selection.focusMission(CAPE); + r.engine.fake.frames(200); + r.engine.fake.resetCalls(); + lookup.answer(); + await settle(); + await r.parts.ingestion.requestMissionUpdate(); + await settle(); + assert.deepEqual( + flights(r).map(({ method }) => method), + ['camera.flyTo'], + 'the orbit view is framed once', + ); + } finally { + r.cleanup(); + } +}); diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index b671bab48..5579ecb9f 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -70,7 +70,8 @@ export function createIngestion({ return null; layerState._activeTleText = text; layerState._focusAfterActiveLookup = Boolean( - layerState._selectedLaunchId, + layerState._selectedLaunchId && + layerState._missionFocusIntentId === layerState._selectedLaunchId, ); schedulePostTleRetry(token); return text; @@ -210,7 +211,12 @@ export function createIngestion({ parts.selection.setSelectedMission(null, false); } else { parts.selection.setSelectedMission(layerState._selectedLaunchId, true); - if (layerState._focusAfterActiveLookup) { + // Only a mission view nobody has left since the lookup answered: a + // flyover, a launch-site view or another mission supersedes it. + if ( + layerState._focusAfterActiveLookup && + layerState._missionFocusIntentId === layerState._selectedLaunchId + ) { parts.selection.focusMission( launches.find( (launch) => launch.id === layerState._selectedLaunchId, diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 6e53ee8a9..1c4c1741f 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -16,7 +16,10 @@ export function createLifecycle({ layerState._orbitMatches = 0; this.attachRenderer(viewer, options); }, - attachRenderer(viewer, { engine = null, waitForCameraRestore = false } = {}) { + attachRenderer( + viewer, + { engine = null, waitForCameraRestore = false } = {}, + ) { layerState._engine = engine; layerState._renderer = services.renderer.create({ viewer, engine }); layerState._viewer = layerState._renderer.view; @@ -59,7 +62,8 @@ export function createLifecycle({ holdContinuousRender('rocket-launches'); layerState._dataSource.show = true; layerState._orbitMatches = 0; - for (const launch of layerState._launches) parts.rendering.addLaunchEntity(launch, layerState._renderedTleText); + for (const launch of layerState._launches) + parts.rendering.addLaunchEntity(launch, layerState._renderedTleText); parts.selection.showSelectedMission(); } layerState._restoringViewport = true; @@ -70,10 +74,18 @@ export function createLifecycle({ if (!layerState._restoringViewport) return; layerState._restoringViewport = false; if (layerState._enabled && layerState._replayCameraLaunchId) { - parts.replay.startMissionReplay(layerState._replayCameraLaunchId, { resuming: true }); + parts.replay.startMissionReplay(layerState._replayCameraLaunchId, { + resuming: true, + }); } else if (layerState._enabled && layerState._missionZoomAnchorId) { - const launch = layerState._launches.find(item => item.id === layerState._missionZoomAnchorId); - if (launch) parts.selection.startMissionZoomAnchor(launch, layerState._missionZoomRange); + const launch = layerState._launches.find( + (item) => item.id === layerState._missionZoomAnchorId, + ); + if (launch) + parts.selection.startMissionZoomAnchor( + launch, + layerState._missionZoomRange, + ); } layerState._resumeRenderer?.(); layerState._waitForRenderer = layerState._resumeRenderer = null; @@ -81,15 +93,21 @@ export function createLifecycle({ detachRenderer() { if (!layerState._renderer) return; - layerState._waitForRenderer ??= new Promise(resolve => { layerState._resumeRenderer = resolve; }); + layerState._waitForRenderer ??= new Promise((resolve) => { + layerState._resumeRenderer = resolve; + }); layerState._replayCameraToken++; layerState._replayCameraRemover?.(); layerState._missionZoomAnchorRemover?.(); - layerState._replayCameraRemover = layerState._missionZoomAnchorRemover = null; + layerState._replayCameraRemover = layerState._missionZoomAnchorRemover = + null; layerState._clickHandler?.destroy(); layerState._moveHandler?.destroy(); layerState._declutterHandler?.(); - layerState._clickHandler = layerState._moveHandler = layerState._declutterHandler = null; + layerState._clickHandler = + layerState._moveHandler = + layerState._declutterHandler = + null; parts.launchPad.destroyLaunchPadZonePrimitive(); parts.orbitRendering.removeMissionOrbitPrimitives(); parts.overlays.clearMissionOverlaySources(); @@ -99,7 +117,11 @@ export function createLifecycle({ layerState._replayTracks.clear(); layerState._renderer.removeDataSource(layerState._dataSource); layerState._renderer.destroy(); - layerState._dataSource = layerState._renderer = layerState._engine = layerState._viewer = null; + layerState._dataSource = + layerState._renderer = + layerState._engine = + layerState._viewer = + null; if (layerState._enabled) releaseContinuousRender('rocket-launches'); }, @@ -158,6 +180,8 @@ export function createLifecycle({ parts.launchPad.hideLaunchPadZone(); parts.overlays.hideReplayVehicleOverlay(); layerState._selectedLaunchId = null; + layerState._missionFocusIntentId = null; + layerState._focusAfterActiveLookup = false; parts.overlays.clearMissionOverlaySources(); await parts.ingestion.restoreSatelliteDependency(); parts.panel.renderMissionPanel(); diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index 2b5f95d9b..8653faea1 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -326,13 +326,16 @@ export function createReplay({ layerState._replayCameraLaunchId = null; layerState._replayPaused = false; layerState._replayPausedAtMs = null; - if (stoppedLaunchId) + if (stoppedLaunchId) { layerState._animationStarts.set(stoppedLaunchId, appClock.now()); + // The flyover moved the camera; the orbit view asked for before it is gone. + layerState._missionFocusIntentId = null; + } syncReplayButton(); parts.overlays.syncMissionOverlayEntries(); } - function startMissionReplay(launchId, {resuming = false} = {}) { + function startMissionReplay(launchId, { resuming = false } = {}) { const launch = layerState._launches.find((item) => item.id === launchId); const track = layerState._replayTracks.get(launchId); if (!layerState._viewer || !launch || !track) return false; @@ -345,6 +348,7 @@ export function createReplay({ parts.selection.stopMissionZoomAnchor(); stopMissionReplay(); layerState._replayCameraLaunchId = launchId; + layerState._missionFocusIntentId = null; layerState._replayPaused = false; layerState._replayPausedAtMs = null; } diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js index 1cb064b21..82be039a8 100644 --- a/src/layers/launches/selection.js +++ b/src/layers/launches/selection.js @@ -68,6 +68,8 @@ export function createSelection({ layerState._missionZoomAnchorId !== launchId ) stopMissionZoomAnchor(); + if (layerState._missionFocusIntentId !== launchId) + layerState._missionFocusIntentId = null; layerState._selectedLaunchId = launchId; layerState._explicitSelection = Boolean(launchId && isolate); if (launchId) layerState._animationStarts.set(launchId, appClock.now()); @@ -122,6 +124,7 @@ export function createSelection({ function focusMission(launch) { if (!layerState._viewer || !launch) return; stopMissionZoomAnchor(); + layerState._missionFocusIntentId = launch.id; layerState._viewer.selectedEntity = layerState._dataSource.entities.getById( `rocket-launch:${launch.id}`, ); @@ -198,6 +201,7 @@ export function createSelection({ function focusLaunchSite(launch) { if (!layerState._viewer || !launch) return; parts.replay.stopMissionReplay(); + layerState._missionFocusIntentId = null; stopMissionZoomAnchor(); const launchPosition = geo.cartesian3.fromDegrees(launch.lon, launch.lat); layerState._viewer.selectedEntity = layerState._dataSource.entities.getById( diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index d880e6063..de3f6f909 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -100,6 +100,11 @@ export function createState({ services }) { state._focusAfterActiveLookup = false; + // The mission whose orbit view the user asked for last; any later camera + // command of this layer (a flyover, a launch-site view) clears it, so a late + // active-satellite answer can reframe only a view nobody has left. + state._missionFocusIntentId = null; + state._satelliteStateBeforeMission = null; state._satelliteActivationPromise = null;