From a49671a391a96e3c3f187d35afafc129716d982f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 04:36:49 +0400 Subject: [PATCH] Preserve satellite tracking, orbit solutions and mission replay across globes --- scripts/qa-space-engine-switch.mjs | 208 ++++++++++++++++++++++++++++ src/app/constructCatalog.js | 8 +- src/data/layerStateCoordinator.js | 2 +- src/layers/launches/ingestion.js | 1 + src/layers/launches/lifecycle.js | 68 +++++++-- src/layers/launches/rendering.js | 36 +++-- src/layers/launches/replay.js | 38 ++--- src/layers/launches/selection.js | 7 + src/layers/satellites/catalog.js | 2 + src/layers/satellites/controls.js | 5 + src/layers/satellites/ingestion.js | 4 + src/layers/satellites/lifecycle.js | 78 ++++++++++- src/layers/satellites/rendering.js | 1 + src/layers/satellites/tracking.js | 24 ++-- src/main.js | 5 +- src/ui/applicationShell.js | 12 +- src/ui/contextControls.js | 4 +- src/ui/navigationController.js | 4 +- src/ui/shareRestoration.js | 8 +- src/voice/gevActions.js | 1 - src/voice/gevActionsEngine.test.mjs | 1 - 21 files changed, 446 insertions(+), 71 deletions(-) create mode 100644 scripts/qa-space-engine-switch.mjs diff --git a/scripts/qa-space-engine-switch.mjs b/scripts/qa-space-engine-switch.mjs new file mode 100644 index 000000000..4a25c910b --- /dev/null +++ b/scripts/qa-space-engine-switch.mjs @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; +import { launchFixture } from '../src/bench/launchFixture.js'; +import { + satelliteFixture, + SATELLITE_FIXTURE_EPOCH, +} from '../src/bench/satelliteFixture.js'; +import { blockPaidQaMaps } from './qa-map-policy.mjs'; +import { switchGlobe } from './qa-switch-globe.mjs'; +const url = new URL(process.argv[2]), + missions = process.argv.includes('--missions'); +if ( + !['localhost', '127.0.0.1'].includes(url.hostname) || + ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( + url.port, + ) +) + throw Error('Supply local keyless preview URL'); +url.search = '?engine=navara&welcome=0'; +url.hash = 'v=2&lat=5&lon=5&alt=18000000&pitch=-90&heading=0&map=sentinel2&l='; +const b = await puppeteer.launch({ + headless: true, + args: ['--use-angle=metal', '--enable-gpu'], +}); +try { + const p = await b.newPage(); + await blockPaidQaMaps(p); + const errors = []; + p.on('pageerror', (e) => { + errors.push(e.message); + console.log('error', e.message); + }); + const groups = Object.fromEntries( + [ + 'stations', + 'visual', + 'gps-ops', + 'glo-ops', + 'galileo', + 'geo', + 'starlink', + ].map((g) => [g, satelliteFixture(g)]), + ); + await p.evaluateOnNewDocument( + (groups, launches, epoch) => { + const fetch = window.fetch.bind(window); + window.spaceReads = {}; + const mono = performance.now(); + Date.now = () => epoch + performance.now() - mono; + window.fetch = (input, opt) => { + const path = new URL(input.url ?? input, location.href).pathname; + if (path.startsWith('/api/celestrak/') || path === '/api/launches') { + window.spaceReads[path] = (window.spaceReads[path] || 0) + 1; + const g = path.split('/').pop(); + return Promise.resolve( + g === 'launches' + ? Response.json(launches) + : new Response(groups[g] ?? groups.stations), + ); + } + return fetch(input, opt); + }; + }, + groups, + launchFixture(), + SATELLITE_FIXTURE_EPOCH, + ); + await p.goto(url.href); + await p.waitForFunction( + () => + window.__godsEyeView?.engine.kind === 'navara' && + document.querySelector('#loading-screen.hidden'), + ); + await p.evaluate(async (missions) => { + const { dataManager, styleManager, tileset } = window.__godsEyeView; + if (tileset || window.__GOOGLE_MAPS_API_KEY__) + throw Error('Expected keyless preview'); + await styleManager.initialRestorePromise; + window.sa = dataManager.layers.get('satellites').module; + window.la = dataManager.layers.get('rocket-launches').module; + if (missions) { + const result = await styleManager.setContextMode('space-missions'); + if (!result.ok) throw Error('Missions mode unavailable: ' + result.error); + } else await dataManager.setEnabled('satellites', true, { origin: 'user' }); + }, missions); + if (!missions) { + await p.waitForFunction(() => window.sa.getStats().count === 839, { + timeout: 40000, + }); + console.log('core catalog ready'); + await p.evaluate(() => window.sa.setParams({ catalog: 'dense' })); + await p.waitForFunction(() => window.sa.getStats().count === 6839, { + timeout: 60000, + }); + console.log('dense catalog ready'); + assert.ok( + await p.evaluate(() => window.sa.trackById(25544, { origin: 'user' })), + ); + } else { + await p.waitForFunction(() => window.la.getStats().count === 3, { + timeout: 40000, + }); + await p.evaluate(() => { + window.la.selectMissionAt(0); + window.la.startSelectedReplay(); + window.la.setReplaySpeed(4); + }); + await p.waitForFunction(() => window.la.qaDrawn()?.replay?.active, { + timeout: 20000, + }); + await p.waitForFunction( + () => window.la.qaDrawn()?.replay?.phaseProgress > 0.001, + { timeout: 35000 }, + ); + await p.evaluate(() => window.la.toggleReplayPause()); + } + const before = await p.evaluate(() => ({ + reads: window.spaceReads, + stamp: (window.la.getStats().count ? window.la : window.sa).getStats() + .lastUpdate, + mission: window.la.qaDrawn()?.selected, + replay: window.la.qaDrawn()?.replay, + })); + console.log( + 'before switch', + before.reads, + before.mission, + before.replay?.active, + ); + for (const kind of ['cesium', 'navara']) { + await switchGlobe(p, kind); + const state = await p.evaluate(() => { + const { dataManager, engine, styleManager } = window.__godsEyeView; + return { + ids: [ + dataManager.layers.get('satellites').module === window.sa, + dataManager.layers.get('rocket-launches').module === window.la, + ], + reads: window.spaceReads, + sat: window.sa.getStats(), + params: window.sa.getParams(), + follow: !!engine.camera.following(), + ring: window.sa.qaDrawn()?.rings.length, + mission: window.la.qaDrawn(), + mode: styleManager.getContextModeState().mode, + missionStats: window.la.getStats(), + }; + }); + console.log( + kind, + state.ids, + state.sat.count, + state.params.selectedSatTrackingId, + state.follow, + state.mode, + state.mission?.selected, + state.mission?.replay?.phaseProgress, + ); + assert.ok(state.ids.every(Boolean)); + assert.deepEqual(state.reads, before.reads); + if (missions) { + assert.equal(state.mode, 'space-missions'); + assert.equal(state.mission.selected, before.mission); + assert.ok(state.mission.paused && state.mission.replay.active); + assert.equal(state.mission.speed, 4); + assert.equal(state.missionStats.lastUpdate, before.stamp); + assert.equal( + state.mission.replay.phaseProgress, + before.replay.phaseProgress, + ); + } else { + assert.equal(state.sat.count, 6839); + assert.equal(state.params.selectedSatTrackingId, 25544); + assert.ok(state.follow && state.ring > 0); + assert.equal(state.sat.lastUpdate, before.stamp); + } + } + if (missions) { + await p.evaluate(() => window.la.toggleReplayPause()); + await p.waitForFunction(() => window.la.qaDrawn()?.paused === false); + await p.waitForFunction( + (progress) => window.la.qaDrawn()?.replay?.phaseProgress > progress, + {}, + before.replay.phaseProgress, + ); + await switchGlobe(p, 'cesium'); + const moving = await p.evaluate(() => window.la.qaDrawn()); + assert.ok( + moving.replay?.active && + !moving.paused && + moving.replay.phaseProgress > before.replay.phaseProgress, + ); + const left = await p.evaluate(() => + window.__godsEyeView.styleManager.setContextMode(null), + ); + assert.ok(left.ok); + assert.equal( + await p.evaluate(() => + window.__godsEyeView.dataManager.isEnabled('rocket-launches'), + ), + false, + ); + } + assert.deepEqual(errors, []); + console.log('PASS retained space data and active camera across both planets'); +} finally { + await b.close(); +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index a454465d1..cfafac6e0 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -153,9 +153,9 @@ export function createApplicationCatalog({ ), ); const satellites = once(() => - createApplicationSatellites({ + sessionModel('satellites', () => createApplicationSatellites({ source: sources.satellites, - }), + })), ); // Catalog order: [layer id(s), construction]. Only the layers the // engine can show are constructed (renderers.js). @@ -235,10 +235,10 @@ export function createApplicationCatalog({ [ 'rocket-launches', () => - createApplicationLaunches({ + sessionModel('rocket-launches', () => createApplicationLaunches({ source: sources.launches, satellites: satellites(), - }), + })), ], [ 'traffic', diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index ad31b3169..351162895 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -491,7 +491,7 @@ export class LayerStateCoordinator { const model = this.dataManager.layers.get(selected.layerId)?.module; if ( model?.attachRenderer && - model.getParams?.()[selected.optionKey] === selected.targetId && + String(model.getParams?.()[selected.optionKey]) === String(selected.targetId) && model.hasContact?.(selected.targetId) === true ) return selected; return null; diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index bd1529c0b..b671bab48 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -179,6 +179,7 @@ export function createIngestion({ stale: false, }; const launches = parts.model.normalizeRocketLaunches(payload); + if (layerState._waitForRenderer) await layerState._waitForRenderer; if ( !layerState._enabled || token !== layerState._lifecycleToken || diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index dd90fa82e..6e53ee8a9 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -8,20 +8,22 @@ export function createLifecycle({ const { holdContinuousRender, releaseContinuousRender } = services.render; const methods = { - init(viewer, { engine = null } = {}) { - layerState._engine = engine; - layerState._renderer = services.renderer.create({ viewer, engine }); - layerState._viewer = layerState._renderer.view; - layerState._declutterOccluder = getRenderer().occluder(); - layerState._enabled = false; - parts.panel.createMissionPanel(); - parts.overlays.createReplayVehicleOverlay(); - layerState._dataSource = getRenderer().createDataSource(); + init(viewer, options) { layerState._count = 0; layerState._lastUpdate = null; layerState._stale = false; layerState._lastError = null; layerState._orbitMatches = 0; + this.attachRenderer(viewer, options); + }, + attachRenderer(viewer, { engine = null, waitForCameraRestore = false } = {}) { + layerState._engine = engine; + layerState._renderer = services.renderer.create({ viewer, engine }); + layerState._viewer = layerState._renderer.view; + layerState._declutterOccluder = getRenderer().occluder(); + parts.panel.createMissionPanel(); + parts.overlays.createReplayVehicleOverlay(); + layerState._dataSource = getRenderer().createDataSource(); parts.overlays.clearMissionOverlaySources(); const enabled = () => layerState._enabled && layerState._dataSource?.show; layerState._clickHandler = getRenderer().listenClick( @@ -53,6 +55,52 @@ export function createLifecycle({ parts.rendering.updateMissionFrame, ); parts.launchPad.initLaunchPadZonePrimitive(); + if (layerState._enabled) { + holdContinuousRender('rocket-launches'); + layerState._dataSource.show = true; + layerState._orbitMatches = 0; + for (const launch of layerState._launches) parts.rendering.addLaunchEntity(launch, layerState._renderedTleText); + parts.selection.showSelectedMission(); + } + layerState._restoringViewport = true; + if (!waitForCameraRestore) this.resumeViewport(); + }, + + resumeViewport() { + if (!layerState._restoringViewport) return; + layerState._restoringViewport = false; + if (layerState._enabled && layerState._replayCameraLaunchId) { + 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); + } + layerState._resumeRenderer?.(); + layerState._waitForRenderer = layerState._resumeRenderer = null; + }, + + detachRenderer() { + if (!layerState._renderer) return; + layerState._waitForRenderer ??= new Promise(resolve => { layerState._resumeRenderer = resolve; }); + layerState._replayCameraToken++; + layerState._replayCameraRemover?.(); + layerState._missionZoomAnchorRemover?.(); + layerState._replayCameraRemover = layerState._missionZoomAnchorRemover = null; + layerState._clickHandler?.destroy(); + layerState._moveHandler?.destroy(); + layerState._declutterHandler?.(); + layerState._clickHandler = layerState._moveHandler = layerState._declutterHandler = null; + parts.launchPad.destroyLaunchPadZonePrimitive(); + parts.orbitRendering.removeMissionOrbitPrimitives(); + parts.overlays.clearMissionOverlaySources(); + parts.overlays.destroyReplayVehicleOverlay(); + layerState._missionOverlayRecords.clear(); + layerState._satelliteTelemetry.clear(); + layerState._replayTracks.clear(); + layerState._renderer.removeDataSource(layerState._dataSource); + layerState._renderer.destroy(); + layerState._dataSource = layerState._renderer = layerState._engine = layerState._viewer = null; + if (layerState._enabled) releaseContinuousRender('rocket-launches'); }, async enable() { @@ -116,6 +164,8 @@ export function createLifecycle({ }, async destroy(viewer) { + layerState._resumeRenderer?.(); + layerState._waitForRenderer = layerState._resumeRenderer = null; layerState._sourceController.abort(); releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js index 57eee9abc..c826e043a 100644 --- a/src/layers/launches/rendering.js +++ b/src/layers/launches/rendering.js @@ -76,18 +76,12 @@ export function createRendering({ } } - function addLaunchEntity(launch, activeTleText = layerState._activeTleText) { - const position = geo.cartesian3.fromDegrees(launch.lon, launch.lat); - const overlayRecord = { - launch, - anchorPosition: position, - elementEntryFactories: [], - liveEventTime: null, - }; - layerState._missionOverlayRecords.set(launch.id, overlayRecord); - const orbitAllowed = parts.policyHelpers.launchStatusAllowsOrbit( - launch.status, - ); + // The source snapshot owns its ephemeris epoch. A renderer attached later + // must reconstruct the same ascent and orbit, including a paused replay. + const orbitSolutions = new WeakMap(); + function orbitForLaunch(launch, activeTleText, orbitAllowed) { + const known = orbitSolutions.get(launch); + if (known && known.activeTleText === activeTleText) return known; const coreTrack = orbitAllowed && launch.satelliteQuery ? getSatelliteOrbitTrack(launch.satelliteQuery, { @@ -106,6 +100,24 @@ export function createRendering({ : satelliteTrack?.orbitPath?.length > 1 ? satelliteTrack.orbitPath : parts.paths.approximateOrbitPath(launch); + const solution = {activeTleText, satelliteTrack, orbitPath}; + orbitSolutions.set(launch, solution); + return solution; + } + + function addLaunchEntity(launch, activeTleText = layerState._activeTleText) { + const position = geo.cartesian3.fromDegrees(launch.lon, launch.lat); + const overlayRecord = { + launch, + anchorPosition: position, + elementEntryFactories: [], + liveEventTime: null, + }; + layerState._missionOverlayRecords.set(launch.id, overlayRecord); + const orbitAllowed = parts.policyHelpers.launchStatusAllowsOrbit( + launch.status, + ); + const { satelliteTrack, orbitPath } = orbitForLaunch(launch, activeTleText, orbitAllowed); launch.recoveryStages.forEach((stage) => { stage.endpoint = parts.paths.landingEndpoint( stage, diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index edc65c202..5b2fae800 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -280,6 +280,7 @@ export function createReplay({ return false; layerState._replayPausedAtMs = appClock.now(); layerState._replayPaused = true; + layerState._replayTracks.get(layerState._replayCameraLaunchId)?.beginReplayFrame?.(); syncReplayButton(); return true; } @@ -329,20 +330,22 @@ export function createReplay({ parts.overlays.syncMissionOverlayEntries(); } - function startMissionReplay(launchId) { + 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; - if (layerState._replayCameraLaunchId === launchId) { + if (!resuming && layerState._replayCameraLaunchId === launchId) { stopMissionReplay(); return false; } - parts.selection.stopMissionZoomAnchor(); - stopMissionReplay(); - layerState._replayCameraLaunchId = launchId; - layerState._replayPaused = false; - layerState._replayPausedAtMs = null; + if (!resuming) { + parts.selection.stopMissionZoomAnchor(); + stopMissionReplay(); + layerState._replayCameraLaunchId = launchId; + layerState._replayPaused = false; + layerState._replayPausedAtMs = null; + } const token = ++layerState._replayCameraToken; const ascentDurationSec = track.ascentDurationSec; // Start broadside to the ascent/orbit direction so the launch profile is @@ -354,15 +357,17 @@ export function createReplay({ track.lastCameraHeading = initialHeading; syncReplayButton(); parts.overlays.syncMissionOverlayEntries(); - parts.policyHelpers.releaseAircraftTracking(layerState._dataManager); - layerState._viewer.trackedEntity = undefined; - layerState._viewer.camera.cancelFlight(); - layerState._animationStarts.set( - launchId, - appClock.now() + - (REPLAY_TILE_SETTLE_DELAY_SEC + REPLAY_COUNTDOWN_DURATION_SEC) * 1000, - ); - let cameraReady = false; + if (!resuming) { + parts.policyHelpers.releaseAircraftTracking(layerState._dataManager); + layerState._viewer.trackedEntity = undefined; + layerState._viewer.camera.cancelFlight(); + layerState._animationStarts.set( + launchId, + appClock.now() + + (REPLAY_TILE_SETTLE_DELAY_SEC + REPLAY_COUNTDOWN_DURATION_SEC) * 1000, + ); + } + let cameraReady = resuming; let lastCameraUpdateMs = null; // Move the chase camera before scene traversal. Mutating it in preRender @@ -486,6 +491,7 @@ export function createReplay({ } }); + if (resuming) return true; layerState._viewer.camera.flyToBoundingSphere( geo.boundingSphere.create(track.ascentPath[0], 0), { diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js index 3ab865e86..1cb064b21 100644 --- a/src/layers/launches/selection.js +++ b/src/layers/launches/selection.js @@ -75,7 +75,12 @@ export function createSelection({ stopMissionZoomAnchor(); layerState._viewer.selectedEntity = undefined; } + showSelectedMission(); + } + + function showSelectedMission() { if (!layerState._dataSource) return; + const launchId = layerState._selectedLaunchId; for (const entity of layerState._dataSource.entities.values) { const relatedId = entityLaunchId(entity); entity.show = launchId @@ -107,6 +112,7 @@ export function createSelection({ Number(initialRange) || MISSION_GLOBE_VIEW_RANGE_M, ); layerState._missionZoomAnchorId = launch.id; + layerState._missionZoomRange = range; layerState._viewer.camera.lookAt( target, getRenderer().range(0, parts.camera.missionZoomPitch(range), range), @@ -219,6 +225,7 @@ export function createSelection({ focusFullGlobe, entityLaunchId, setSelectedMission, + showSelectedMission, stopMissionZoomAnchor, startMissionZoomAnchor, focusMission, diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index 412ebba0e..615a370d7 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -61,6 +61,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { */ async function _loadDenseCatalog({ signal = null } = {}) { + if (layerState._waitForRenderer) await layerState._waitForRenderer; if (!layerState._viewer || !layerState._pointCollection) return { status: 'source-unavailable', reason: 'layer-unavailable' }; layerState._denseLoadController?.abort(); @@ -103,6 +104,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { let added = 0; for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) { + if (layerState._waitForRenderer) await layerState._waitForRenderer; loadSignal.throwIfAborted(); if ( token !== layerState._denseLoadToken || diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index 36227f8f1..d4c906969 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -253,6 +253,10 @@ export function createControls({ state: layerState, services, parts, source }) { * @param {string|number} noradId NORAD catalog number. * @returns {boolean} True if tracking started. */ + hasContact(id) { + return layerState._points.has(Number(id)); + }, + trackById(noradId, { origin = 'programmatic' } = {}) { const id = Number(noradId); if ( @@ -262,6 +266,7 @@ export function createControls({ state: layerState, services, parts, source }) { !layerState._points.has(id) ) return false; + if (layerState._restoringViewport && layerState._trackedNorad === id) return true; parts.tracking._cancelPendingTrackingRestore(); parts.tracking._trackSatellite(id, { origin }); return layerState._trackedNorad === id; diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 6c29fddaa..ee43fd5e7 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -86,6 +86,10 @@ export function createIngestion({ ? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable` : null; + if (layerState._waitForRenderer) { + await layerState._waitForRenderer; + updateSignal.throwIfAborted(); + } // Clear existing layerState._pointCollection.removeAll(); layerState._points.clear(); diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 6404c10e0..ed84eaf64 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -1,4 +1,4 @@ -import { ISS_OVERLAY_SOURCE_ID } from './policy.js'; +import { ISS_NORAD, ISS_OVERLAY_SOURCE_ID } from './policy.js'; export function createLifecycle({ state: layerState, @@ -64,6 +64,78 @@ export function createLifecycle({ console.log('[Data:Satellites] Initialized'); }, + suspendCamera() { + // Outgoing tools release their cameras during teardown. That release + // must not classify a retained follow as a user deselection. + layerState._trackedEntityChangedRemove?.(); + layerState._trackedEntityChangedRemove = null; + }, + detachRenderer() { + const renderer = layerState._renderer; + if (!renderer) return; + layerState._waitForRenderer ??= new Promise(resolve => { layerState._resumeRenderer = resolve; }); + layerState._clickHandler?.destroy(); + layerState._trackedEntityChangedRemove?.(); + layerState._preRenderListener?.(); + layerState._clickHandler = layerState._trackedEntityChangedRemove = layerState._preRenderListener = null; + document.removeEventListener('keydown', parts.interaction._onKeyDown); + unregisterPickOwner('satellites'); + if (layerState._trackedEntity) { + layerState._handoffOrigin = layerState._trackedEntity.gevSelectionOrigin; + renderer.follow.release(); + renderer.removeTracked(layerState._trackedEntity); + layerState._trackedEntity = null; + } + for (const [id, point] of layerState._points) { + const {position, show, pixelSize, color, outlineColor, outlineWidth, scaleByDistance, disableDepthTestDistance} = point; + layerState._points.set(id, {id, position: {...position}, show, pixelSize, color, outlineColor, outlineWidth, scaleByDistance, disableDepthTestDistance}); + } + for (const path of layerState._orbitPaths.values()) { + renderer.removeOrbit(path.primitive); + path.primitive = null; + } + layerState._detectionObjects.clear(); + renderer.removePoints(layerState._pointCollection); + renderer.destroy(); + layerState._engine = layerState._viewer = layerState._renderer = layerState._pointCollection = null; + layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); + if (layerState._enabled) releaseContinuousRender('satellites'); + }, + + attachRenderer(viewer, {engine = null, waitForCameraRestore = false} = {}) { + layerState._engine = engine; + const renderer = services.renderer.create({viewer,engine}); + layerState._renderer = renderer; + layerState._viewer = renderer.view; + layerState._pointCollection = renderer.createPoints(); + for (const [id, spec] of layerState._points) + layerState._points.set(id, layerState._pointCollection.add(spec)); + layerState._pointCollection.show = layerState._enabled && layerState._params.showPoints; + for (const [id, path] of layerState._orbitPaths) { + path.primitive = renderer.createOrbit({ positions: path.basePositions, width: id === ISS_NORAD ? 2.5 : 2, color: path.color }); + path.primitive.show = layerState._enabled && layerState._params.showOrbits; + } + // Source work can publish as soon as GPU collections exist. Restoring + // share-selected camera state may itself wait for that publication. + layerState._resumeRenderer?.(); + layerState._waitForRenderer = layerState._resumeRenderer = null; + // The restored camera runs before a retained follow gets its new native target. + layerState._restoringViewport = true; + if (!waitForCameraRestore) this.resumeViewport(); + }, + + resumeViewport() { + if (!layerState._restoringViewport || !layerState._renderer) return; + layerState._restoringViewport = false; + if (layerState._enabled) { + if (layerState._trackedNorad != null && !layerState._trackedEntity) + parts.tracking._trackSatellite(layerState._trackedNorad, {origin: layerState._handoffOrigin, resuming: true}); + this.enable(layerState._viewer); + } + layerState._resumeRenderer?.(); + layerState._waitForRenderer = layerState._resumeRenderer = null; + }, + enable(viewer) { layerState._enabled = true; holdContinuousRender('satellites'); // per-frame animator (perf wave 2) @@ -124,6 +196,8 @@ export function createLifecycle({ }, destroy(viewer) { + layerState._resumeRenderer?.(); + layerState._waitForRenderer = layerState._resumeRenderer = null; parts.catalog._abortActiveUpdates(); releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; @@ -151,7 +225,7 @@ export function createLifecycle({ } // Orbit ring primitives are removed (and destroyed) here for (const path of layerState._orbitPaths.values()) { - getRenderer().removeOrbit(path.primitive); + if (path.primitive) getRenderer().removeOrbit(path.primitive); } layerState._points.clear(); layerState._detectionObjects.clear(); diff --git a/src/layers/satellites/rendering.js b/src/layers/satellites/rendering.js index 358c7b5c5..84f925f94 100644 --- a/src/layers/satellites/rendering.js +++ b/src/layers/satellites/rendering.js @@ -60,6 +60,7 @@ export function createRendering({ layerState._orbitPaths.set(noradId, { primitive, basePositions, + color: pathColor, // Same Date object as computeOrbitPath's internal fixedGmst → identical // GMST value (gstime is pure), so delta starts at exactly 0 and the // initial identity modelMatrix is correct until the first ring tick. diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index 2da8d3fe2..78630c2e5 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -412,8 +412,8 @@ export function createTracking({ refreshTrackedReadout(layerState._trackedEntity); } - function _trackSatellite(noradId, { origin = 'programmatic' } = {}) { - _clearTracking(false, { origin }); + function _trackSatellite(noradId, { origin = 'programmatic', resuming = false } = {}) { + if (!resuming) _clearTracking(false, { origin }); const point = layerState._points.get(noradId); const sat = layerState._catalog.get(noradId); @@ -470,15 +470,17 @@ export function createTracking({ layerState._trackedEntity.gevDisplayPosition = _trackedDisplayCached; _updateTrackedSatelliteLabelModel(initialPos?.altitude ?? null); - _emitAwarenessEvent('gev:awareness-subject-selected', { - layerId: 'satellites', - id: noradId, - label: name, - position: geo.cartesian3.clone(point.position), - origin, - }); - selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos)); - layerState._contextRefreshedAtMs = Date.now(); + if (!resuming) { + _emitAwarenessEvent('gev:awareness-subject-selected', { + layerId: 'satellites', + id: noradId, + label: name, + position: geo.cartesian3.clone(point.position), + origin, + }); + selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos)); + layerState._contextRefreshedAtMs = Date.now(); + } getRenderer().follow.start(layerState._trackedEntity); console.log(`[Data:Satellites] Tracking ${name} (NORAD ${noradId})`); diff --git a/src/main.js b/src/main.js index 9b53871d4..88c6ec7ba 100644 --- a/src/main.js +++ b/src/main.js @@ -141,7 +141,10 @@ async function reconcile(remember = false) { id, { entry: entry.module.attachRenderer ? entry : null, enabled: manager.isEffectivelyEnabled(id) }, ])) : null; - if (layerSession.handoffLayers) manager.layers.get('directions')?.module.suspendCamera?.(); + if (layerSession.handoffLayers) { + manager.layers.get('directions')?.module.suspendCamera?.(); + manager.layers.get('satellites')?.module.suspendCamera?.(); + } renderers.retain(kind); await application.destroy(); await start(pageEngineKind(), true, cockpit); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 0272de33e..acd3e43c1 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -625,7 +625,7 @@ export class StyleManager extends ShellFacade { this._publishActiveStyle(); this._updateLocationMiniStatus(); - this._shareRestoration.start({ engineSwitch }); + this._shareRestoration.start({ engineSwitch, retainScene: Boolean(this._layerSession?.handoffLayers) }); // Keep the parameter panel from overlapping toggle controls. this._layoutRightPanels(); @@ -976,7 +976,7 @@ export class StyleManager extends ShellFacade { }, }); if (this._layerSession?.handoffLayers) - this._contextControls.resumeScene(this._layerSession.contacts); + this._contextControls.resumeScene(this._layerSession.contextScene); // The Contacts layer publishes its panel; the view calls its navigation. contextStore.connectAwareness(militaryAwarenessLayer); // A panel left by an earlier shell must not outlive its layer. @@ -1856,15 +1856,15 @@ export class StyleManager extends ShellFacade { // re-enable a mode's entry layer and republish `_contextMode` while the // rest of teardown is tearing those very layers down. this._contextControls.stop(); - this._navigation.destroy(); + this._navigation.destroy({ retainScene: Boolean(this._layerSession?.handoffLayers) }); // Close camera-entry seams synchronously. Context restoration may await // layer work, so leaving these listeners attached until afterward lets a // focus event release tracking or start a flight during teardown. if (this._layerSession && !this._layerSession.lifetime.signal.aborted && - this._contextControls.canRetainContacts()) { - this._layerSession.contacts = this._contextControls.captureScene(); + this._contextControls.canRetainScene()) { + this._layerSession.contextScene = this._contextControls.captureScene(); } else { - if (this._layerSession) this._layerSession.contacts = null; + if (this._layerSession) this._layerSession.contextScene = null; await this._contextControls.restoreForDisposal(); } // IR boost teardown BEFORE detaching the data manager: restore fog and diff --git a/src/ui/contextControls.js b/src/ui/contextControls.js index b6c4009c0..6105d107f 100644 --- a/src/ui/contextControls.js +++ b/src/ui/contextControls.js @@ -85,8 +85,8 @@ export class ContextControls { this[field] = null; } } - canRetainContacts() { - return this._contextMode === 'flights' && !this._contextModeChanging && + canRetainScene() { + return ['flights', 'space-missions'].includes(this._contextMode) && !this._contextModeChanging && !this._contextRestoreState && !this._clearSelectedLayersPromise && !this._contextLayerReactionPromises.size; } diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 7c64b0b20..be23c53c4 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -280,8 +280,8 @@ export class NavigationController { this._cameraHandoffs?.publish(); this._cameraHandoffs?.destroy(); } - destroy() { + destroy({ retainScene = false } = {}) { this.stop(); - this._stampNavigation(); + this._stampNavigation({cancelPendingSelection: !retainScene}); } } diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 0bd2e5299..0dd1e2a31 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -155,7 +155,7 @@ export class ShareRestoration { this.shareLinkManager = shareLinkManager; this._initialShareState = shareLinkManager.parseInitialHash(); } - start({ engineSwitch = false } = {}) { + start({ engineSwitch = false, retainScene = false } = {}) { // Restore from URL hash if present const savedState = this._initialShareState; this._initialShareRestorePromise = savedState @@ -181,12 +181,13 @@ export class ShareRestoration { Number.isInteger(generation) && this.navigation._reassertNavigationHandoff(generation, { trackingOrigin: engineSwitch ? 'share-restore' : 'tool', - keepTracking: Boolean(retained), + keepTracking: retainScene || Boolean(retained), }); void this._runShareRestore(savedState, { applyCamera, instant: engineSwitch, retained, + retainScene, navigationToken: generation, layersPromise: this._layerStateRestorePromise, settle: (result) => this._settleInitialShareRestore(result), @@ -245,7 +246,7 @@ export class ShareRestoration { */ async _runShareRestore( state, - { applyCamera, instant = false, retained, navigationToken, layersPromise, settle }, + { applyCamera, instant = false, retained, retainScene = false, navigationToken, layersPromise, settle }, ) { const generation = this._shareRestoreGeneration; const current = () => @@ -258,6 +259,7 @@ export class ShareRestoration { }); const layers = await (layersPromise || Promise.resolve([])); if (!current()) return; + if (retainScene && !retained) retained = this._layerStateCoordinator?.retainedTrackingTarget(); const tracking = share.camera === 'applied' ? (retained && this._layerStateCoordinator.resumeRetainedTracking(retained)) || diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 3dd7068bd..e2cf36f56 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -236,7 +236,6 @@ export const VIEWER_ONLY_ACTIONS = Object.freeze([ 'clear_annotations', 'fly_route', 'move_camera', - 'set_context_mode', ]); /** The refusal code of a viewer-only tool on an engine without a viewer. */ export const UNAVAILABLE_ON_ENGINE = 'UNAVAILABLE_ON_ENGINE'; diff --git a/src/voice/gevActionsEngine.test.mjs b/src/voice/gevActionsEngine.test.mjs index ccbbe2b81..ce5e2ee09 100644 --- a/src/voice/gevActionsEngine.test.mjs +++ b/src/voice/gevActionsEngine.test.mjs @@ -93,7 +93,6 @@ test('the viewer-only actions are one exported list of real tools', () => { 'clear_annotations', 'fly_route', 'move_camera', - 'set_context_mode', ]); assert.ok(Object.isFrozen(VIEWER_ONLY_ACTIONS)); const tools = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));