diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 62f692f4d..376f163ea 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1353,6 +1353,7 @@ "src/layers/flights/index.js", "src/layers/flights/ingestion.js", "src/layers/flights/lifecycle.js", + "src/layers/aircraft/sceneBinding.js", "src/layers/flights/motion.js", "src/layers/flights/policy.js", "src/layers/flights/queries.js", @@ -1433,6 +1434,7 @@ "src/layers/military/controller.js", "src/layers/military/index.js", "src/layers/military/ingestion.js", + "src/layers/aircraft/sceneBinding.js", "src/layers/military/lifecycle.js", "src/layers/military/motion.js", "src/layers/military/policy.js", @@ -2868,6 +2870,7 @@ "src/layers/flights/index.js", "src/layers/flights/ingestion.js", "src/layers/flights/lifecycle.js", + "src/layers/aircraft/sceneBinding.js", "src/layers/flights/motion.js", "src/layers/flights/policy.js", "src/layers/flights/queries.js", @@ -3927,6 +3930,7 @@ "src/layers/flights/index.js", "src/layers/flights/ingestion.js", "src/layers/flights/lifecycle.js", + "src/layers/aircraft/sceneBinding.js", "src/layers/flights/motion.js", "src/layers/flights/policy.js", "src/layers/flights/queries.js", diff --git a/scripts/qa-layer-handoff.mjs b/scripts/qa-layer-handoff.mjs index a38328b03..79c342c02 100644 --- a/scripts/qa-layer-handoff.mjs +++ b/scripts/qa-layer-handoff.mjs @@ -10,9 +10,13 @@ if ( ) ) throw new Error('Supply an unreserved local keyless preview URL'); +const military = process.argv.includes('--military'); +const layerId = military ? 'military' : 'flights'; +const aircraftId = military ? 'aaa052' : '71be26'; url.search = '?engine=navara&welcome=0'; url.hash = - 'v=2&lat=40.2539&lon=-125.5779&alt=21130&heading=360&pitch=-35&map=sentinel2&l=f&lo=f.e.1_f.t.71be26'; + 'v=2&lat=40.2539&lon=-125.5779&alt=21130&heading=360&pitch=-35&map=sentinel2' + + (military ? '&l=m&lo=f.e.1_f.u.aaa052' : '&l=f&lo=f.e.1_f.t.71be26'); const browser = await puppeteer.launch({ headless: true, args: ['--use-angle=metal', '--enable-gpu'], @@ -23,14 +27,24 @@ try { const errors = []; page.on('pageerror', (e) => errors.push(e.message)); await page.setViewport({ width: 1280, height: 720 }); - await page.evaluateOnNewDocument(() => { + await page.evaluateOnNewDocument((layerId, aircraftId) => { + window.__qaAircraft = {layerId, aircraftId}; const fetch = window.fetch.bind(window); window.__flightPolls = 0; + const interval = window.setInterval.bind(window); + window.setInterval = function (callback, delay, ...args) { + if (typeof callback !== 'function') return interval(callback, delay, ...args); + const id = interval(function (...values) { + if (window.__handoff?.timer === id) window.__handoff.ticks++; + return callback.apply(this, values); + }, delay, ...args); + return id; + }; window.fetch = (input, options) => { const path = new URL(input.url ?? input, document.baseURI).pathname; const now = Math.floor(Date.now() / 1000); if (path === '/api/flights') { - window.__flightPolls++; + if (layerId === 'flights') window.__flightPolls++; return Promise.resolve( Response.json({ time: now, @@ -58,15 +72,21 @@ try { }), ); } - if (path === '/api/military') - return Promise.resolve(Response.json({ now: Date.now(), ac: [] })); + if (path === '/api/military') { + if (layerId === 'military') window.__flightPolls++; + return Promise.resolve(Response.json({now: Date.now(), ac: [{ + hex: 'aaa052', flight: 'GEVMIL', lon: -125.5779, lat: 40.2539, + alt_geom: 31825, alt_baro: 31825, gs: 389, track: 45, + seen: 0, seen_pos: 0, dbFlags: 1, type: 'adsb_icao', + }]})); + } if (path === '/api/flights/track') return Promise.resolve(Response.json({ path: [] })); if (path.startsWith('/api/flights/') || path.startsWith('/api/openai/')) return Promise.resolve(Response.json({})); return fetch(input, options); }; - }); + }, layerId, aircraftId); await page.goto(url.href, { waitUntil: 'domcontentloaded' }); const ready = (kind) => page.waitForFunction( @@ -92,16 +112,16 @@ try { if (window.__GOOGLE_MAPS_API_KEY__ || tileset) throw Error('Keyless preview required'); styleManager.cockpitView.setVisionMode('thermal'); - const entry = dataManager.layers.get('flights'); + const entry = dataManager.layers.get(window.__qaAircraft.layerId); window.__handoff = { dataManager, entry, - row: document.querySelector('[data-layer-id="flights"] .data-toggle-btn'), + row: document.querySelector(`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`), timer: entry.intervalId, + ticks: 0, model: entry.module, pollCount: window.__flightPolls, timestamp: entry.module.getStats().lastUpdate, - started: Date.now(), }; }); // Refuse the first Cesium canvas. The application must roll back with the @@ -127,9 +147,9 @@ try { const {dataManager, styleManager} = window.__godsEyeView; return new URL(location.href).searchParams.get('engine') === 'navara' && dataManager === window.__handoff.dataManager && - dataManager.layers.get('flights') === window.__handoff.entry && + dataManager.layers.get(window.__qaAircraft.layerId) === window.__handoff.entry && styleManager.getCockpitState().active && - styleManager.getAircraftTrackingTarget()?.id === '71be26'; + styleManager.getAircraftTrackingTarget()?.id === window.__qaAircraft.aircraftId; }), 'a failed scene must leave the live session usable'); console.log('PASS failed renderer rolled back to the same flight and cockpit'); @@ -139,7 +159,7 @@ try { const result = await page.evaluate(() => { const { dataManager, engine, styleManager } = window.__godsEyeView; const before = window.__handoff; - const entry = dataManager.layers.get('flights'); + const entry = dataManager.layers.get(window.__qaAircraft.layerId); const cockpit = styleManager.getCockpitState(); return { sameOwner: @@ -147,18 +167,18 @@ try { before.entry === entry && before.model === entry.module, sameRow: Boolean(before.row) && - before.row === document.querySelector('[data-layer-id="flights"] .data-toggle-btn'), + before.row === document.querySelector(`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`), sameTimer: before.timer === entry.intervalId && entry.intervalId != null, sourcePolls: window.__flightPolls - before.pollCount, - elapsed: Date.now() - before.started, + timerTicks: before.ticks, stamp: entry.module.getStats().lastUpdate, initialStamp: before.timestamp, id: cockpit.subject?.id, active: cockpit.active, mode: cockpit.visionMode, hud: !document.querySelector('#cockpit-hud').hidden, - drawn: entry.module.qaDrawn().some((row) => row.id === '71be26'), + drawn: entry.module.qaDrawn().some((row) => row.id === window.__qaAircraft.aircraftId), height: engine.camera.height(), }; }); @@ -171,10 +191,10 @@ try { result.drawn, JSON.stringify(result), ); - assert.equal(result.id, '71be26'); + assert.equal(result.id, aircraftId); assert.equal(result.mode, 'thermal'); - if (result.elapsed < 30000) { - assert.equal(result.sourcePolls, 0); + assert.ok(result.sourcePolls <= result.timerTicks, 'switch must not add a source request'); + if (result.timerTicks === 0) { assert.equal(result.stamp, result.initialStamp); } await page.waitForFunction( diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 61cee9bb0..548fc255c 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -124,12 +124,12 @@ export function createApplicationCatalog({ ), ); const military = once(() => - createApplicationMilitary({ + sessionModel('military', () => createApplicationMilitary({ surface, source: sources.military, militaryRegistry, resolveAsset, - }), + })), ); const vessels = once(() => createApplicationVessels({ diff --git a/src/data/aircraftEmergencyAnalyst.test.mjs b/src/data/aircraftEmergencyAnalyst.test.mjs index ceea00e7b..9abbd12da 100644 --- a/src/data/aircraftEmergencyAnalyst.test.mjs +++ b/src/data/aircraftEmergencyAnalyst.test.mjs @@ -482,7 +482,8 @@ test('GERMAN: an existing untracked military billboard repaints each poll withou floorAltitudeM: (value) => value, }); flightState.records = records; - const apply = createMilitarySnapshotRenderer({ + flightState._renderer = { view: {}, surfaceExclusions: () => [] }; + const {applySnapshot: apply} = createMilitarySnapshotRenderer({ flightState, records, groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null }, diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index a06f450fb..6f8cfaaa3 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -1658,12 +1658,11 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol // disable() hides the collection but keeps the records, so a map lookup // alone would report a preserved subject as FRESH from hidden stale data. for (const [name, source, guard] of [ - // Flights asks its renderer whether the fleet is shown (phase 3). + // Both aircraft layers answer from enabled observation models. ['flights', readLayerSource(new URL('./flights.js', import.meta.url)), /hasContact\(\s*icao24,?\s*\)\s*\{\s*if\s*\(\s*!flightState\.enabled\s*\|\|\s*flightState\.records\.data\.size\s*===\s*0,?\s*\)\s*return\s*null;/], - // Military asks its renderer too (phase 3). ['militaryFlights', readLayerSource(new URL('./militaryFlights.js', import.meta.url)), - /hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_renderer\?\.fleet\.visible\(\)\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/], + /hasContact\(\s*icao24,?\s*\)\s*\{\s*if\s*\(\s*!flightState\.enabled\s*\|\|\s*flightState\.records\.data\.size\s*===\s*0,?\s*\)\s*return\s*null;/], ['aisLiveVessels', readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)), /hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byMmsi\s*\|\|\s*state\.records\.byMmsi\.size\s*===\s*0,?\s*\)\s*return\s*null;/], ]) { diff --git a/src/layers/aircraft/sceneBinding.js b/src/layers/aircraft/sceneBinding.js new file mode 100644 index 000000000..fb8530d54 --- /dev/null +++ b/src/layers/aircraft/sceneBinding.js @@ -0,0 +1,117 @@ +/** Scene-only ownership shared by the observed aircraft layers. Source + * requests, classification, selection and track history outlive this binding. */ +export function createAircraftSceneBinding({ + id, + flightState, + services, + parts, + resolveModel, + enable, +}) { + let resumeFollow = false; + function removeSceneListeners() { + parts.tracking._removeClickHandler(); + services.picking.unregisterPickOwner(id); + if (flightState._preRenderRemove) { + flightState._preRenderRemove(); + flightState._preRenderRemove = null; + } + if (flightState._trackedModelPreUpdateRemove) { + flightState._trackedModelPreUpdateRemove(); + flightState._trackedModelPreUpdateRemove = null; + } + if (flightState._moveEndRemove) { + flightState._moveEndRemove(); + flightState._moveEndRemove = null; + } + } + + const methods = { + attachRenderer(viewer, { engine = null } = {}) { + if (!flightState.initialized) + throw new Error('Initialize the flight layer before attaching'); + if (flightState._renderer) + throw new Error('Flight renderer already attached'); + const renderer = services.renderer.create({ viewer, engine }); + flightState._renderer = renderer; + try { + renderer.fleet.attach(); + // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed + // (keeps the retained instance referenced; never rendered). Captured against this epoch so a + // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. + if (!flightState._preloadModel) { + const epoch = flightState._modelEpoch; + renderer.models + .preload(resolveModel()) + .then((m) => { + if (epoch === flightState._modelEpoch) { + flightState._preloadModel = m; + flightState._planeModelLoaded = true; + } else { + renderer.models.destroy(m); + } + }) + .catch(() => { + /* tracked plane just stays a billboard a beat longer */ + }); + } + parts.snapshots.presentRecords(); + if (flightState._trackedIcao) { + parts.tracking._trackFlight(flightState._trackedIcao, { + resume: true, + follow: resumeFollow, + }); + } + renderer.fleet.setVisible(flightState.enabled); + renderer.models.setVisible(flightState.enabled); + renderer.fleet.restoreOrder(); + if (flightState.enabled) { + enable(); + parts.tracking._applyPendingTrackingRestore(); + } + } catch (error) { + methods.detachRenderer(viewer); + throw error; + } + }, + + detachRenderer(viewer) { + const renderer = flightState._renderer; + if (!renderer) return; + removeSceneListeners(); + services.render.releaseContinuousRender(id); + flightState._trackedCameraFrameStop?.(); + flightState._trackedCameraFrameStop = null; + if (flightState._trackedEntity) { + resumeFollow = renderer.follow.isFollowing(flightState._trackedEntity); + if (resumeFollow) renderer.follow.release(); + renderer.tracked.remove(flightState._trackedEntity); + flightState._trackedEntity = null; + } + parts.rendering._releaseTrackedModel(); + parts.tracking._detachTrail(); + parts.rendering._releaseModels(); + // Removing the model collection destroys it and its models. + renderer.fleet.detach(viewer); + flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) + flightState._modelPending.clear(); + flightState._modelGen.clear(); + if (flightState._preloadModel) { + renderer.models.destroy(flightState._preloadModel); + flightState._preloadModel = null; + } + flightState._planeModelLoaded = false; + flightState._billboards.clear(); + flightState._cullPositions.clear(); + flightState._detectionObjects.clear(); + flightState._displayFloorState?.clear(); + flightState._groundSnap.clear(); + parts.motion._resetTrackedDisplay(); + renderer.destroy(); + flightState._renderer = null; + }, + + removeSceneListeners, + }; + return methods; +} diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index 8768dfad0..4ef66df3d 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -1,3 +1,4 @@ +import { createAircraftSceneBinding } from '../aircraft/sceneBinding.js'; import { PLANE_MODEL_URL } from './policy.js'; export function createLifecycle({ @@ -11,32 +12,20 @@ export function createLifecycle({ const { onMilitaryLayerActiveChange } = services.militaryRegistry; const { holdContinuousRender, releaseContinuousRender } = services.render; const { ensureGeoidReady } = services.geoid; - const { registerPickOwner, unregisterPickOwner } = services.picking; + const { registerPickOwner } = services.picking; - let initialized = false; - let resumeFollow = false; - - function removeSceneListeners() { - parts.tracking._removeClickHandler(); - unregisterPickOwner('flights'); - if (flightState._preRenderRemove) { - flightState._preRenderRemove(); - flightState._preRenderRemove = null; - } - if (flightState._trackedModelPreUpdateRemove) { - flightState._trackedModelPreUpdateRemove(); - flightState._trackedModelPreUpdateRemove = null; - } - if (flightState._moveEndRemove) { - flightState._moveEndRemove(); - flightState._moveEndRemove = null; - } - } + const binding = createAircraftSceneBinding({ + id: 'flights', flightState, services, parts, + resolveModel: () => resolveAsset(PLANE_MODEL_URL), + enable: () => methods.enable(), + }); const methods = { + attachRenderer: binding.attachRenderer, + detachRenderer: binding.detachRenderer, /** Configure the source before initialization; an active layer keeps its owner. */ setSource(source) { - if (initialized) + if (flightState.initialized) throw new Error('Configure the source before layer initialization'); if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); @@ -55,7 +44,7 @@ export function createLifecycle({ * @param {{ engine?: object }} [context] - LayerLifecycle's context. */ init(viewer, { engine = null } = {}) { - if (initialized) throw new Error('Flight layer is already initialized'); + if (flightState.initialized) throw new Error('Flight layer is already initialized'); if (typeof flightState.feed._source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); if (flightState.lifetime.signal.aborted) @@ -106,7 +95,7 @@ export function createLifecycle({ ); } - initialized = true; + flightState.initialized = true; try { if (viewer || engine) methods.attachRenderer(viewer, { engine }); } catch (error) { @@ -115,90 +104,6 @@ export function createLifecycle({ } }, - attachRenderer(viewer, { engine = null } = {}) { - if (!initialized) - throw new Error('Initialize the flight layer before attaching'); - if (flightState._renderer) - throw new Error('Flight renderer already attached'); - const renderer = services.renderer.create({ viewer, engine }); - flightState._renderer = renderer; - try { - renderer.fleet.attach(); - // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed - // (keeps the retained instance referenced; never rendered). Captured against this epoch so a - // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. - if (!flightState._preloadModel) { - const epoch = flightState._modelEpoch; - renderer.models - .preload(resolveAsset(PLANE_MODEL_URL)) - .then((m) => { - if (epoch === flightState._modelEpoch) { - flightState._preloadModel = m; - flightState._planeModelLoaded = true; - } else { - renderer.models.destroy(m); - } - }) - .catch(() => { - /* tracked plane just stays a billboard a beat longer */ - }); - } - parts.snapshots.presentRecords(); - if (flightState._trackedIcao) { - parts.tracking._trackFlight(flightState._trackedIcao, { - resume: true, - follow: resumeFollow, - }); - } - renderer.fleet.setVisible(flightState.enabled); - renderer.models.setVisible(flightState.enabled); - renderer.fleet.restoreOrder(); - if (flightState.enabled) { - methods.enable(); - parts.tracking._applyPendingTrackingRestore(); - } - } catch (error) { - methods.detachRenderer(viewer); - throw error; - } - }, - - detachRenderer(viewer) { - const renderer = flightState._renderer; - if (!renderer) return; - removeSceneListeners(); - releaseContinuousRender('flights'); - flightState._trackedCameraFrameStop?.(); - flightState._trackedCameraFrameStop = null; - if (flightState._trackedEntity) { - resumeFollow = renderer.follow.isFollowing(flightState._trackedEntity); - if (resumeFollow) renderer.follow.release(); - renderer.tracked.remove(flightState._trackedEntity); - flightState._trackedEntity = null; - } - parts.rendering._releaseTrackedModel(); - parts.tracking._detachTrail(); - parts.rendering._releaseModels(); - // Removing the model collection destroys it and its models. - renderer.fleet.detach(viewer); - flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) - flightState._modelPending.clear(); - flightState._modelGen.clear(); - if (flightState._preloadModel) { - renderer.models.destroy(flightState._preloadModel); - flightState._preloadModel = null; - } - flightState._planeModelLoaded = false; - flightState._billboards.clear(); - flightState._cullPositions.clear(); - flightState._detectionObjects.clear(); - flightState._displayFloorState.clear(); - flightState._groundSnap.clear(); - parts.motion._resetTrackedDisplay(); - renderer.destroy(); - flightState._renderer = null; - }, - /** * Show the fleet and re-join the selection dispatcher. */ @@ -275,7 +180,7 @@ export function createLifecycle({ flightState._renderer?.models.setVisible(false); parts.tracking._clearTracking(); parts.tracking._destroyTrail(); - removeSceneListeners(); + binding.removeSceneListeners(); }, /** @@ -284,7 +189,7 @@ export function createLifecycle({ * @param {object|null} viewer - The viewer the layer was drawn on. */ destroy(viewer) { - initialized = false; + flightState.initialized = false; flightState.enabled = false; flightState.lifetime.abort(); flightState._enrichActive = 0; diff --git a/src/layers/military/index.js b/src/layers/military/index.js index 53a1a25b4..795f42ad3 100644 --- a/src/layers/military/index.js +++ b/src/layers/military/index.js @@ -25,7 +25,7 @@ export function createMilitaryFlightLayer({ parts.lifecycle = createLifecycle(context); parts.testing = createTesting(context); parts.queries = createQueries(context); - const applySnapshot = createMilitarySnapshotRenderer({ + parts.snapshots = createMilitarySnapshotRenderer({ flightState, records: flightState.records, groundFloor: services.groundFloor, @@ -38,7 +38,7 @@ export function createMilitaryFlightLayer({ }); parts.ingestion = createIngestion({ feed: flightState.feed, - applySnapshot, + applySnapshot: parts.snapshots.applySnapshot, setSourceLabel: (source) => { layer.source = source; }, diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index 5b60928ab..dbc239490 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -1,3 +1,4 @@ +import { createAircraftSceneBinding } from '../aircraft/sceneBinding.js'; import { JET_MODEL_URL } from './policy.js'; export function createLifecycle({ @@ -10,13 +11,20 @@ export function createLifecycle({ const { clearFocusTarget } = services.focus; const { holdContinuousRender, releaseContinuousRender } = services.render; const { ensureGeoidReady } = services.geoid; - const { registerPickOwner, unregisterPickOwner } = services.picking; + const { registerPickOwner } = services.picking; const { setMilitaryLayerActive } = services.militaryRegistry; + const binding = createAircraftSceneBinding({ + id: 'military', flightState, services, parts, + resolveModel: () => resolveAsset(JET_MODEL_URL), + enable: () => methods.enable(), + }); const methods = { + attachRenderer: binding.attachRenderer, + detachRenderer: binding.detachRenderer, /** Configure the source before initialization; an active layer keeps its owner. */ setSource(source) { - if (flightState._renderer) + if (flightState.initialized) throw new Error('Configure the source before layer initialization'); if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); @@ -35,35 +43,13 @@ export function createLifecycle({ * @param {{ engine?: object }} [context] - LayerLifecycle's context. */ init(viewer, { engine = null } = {}) { - if (flightState._renderer) + if (flightState.initialized) throw new Error('Flight layer is already initialized'); if (typeof flightState.feed._source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); if (flightState.lifetime.signal.aborted) flightState.lifetime = new AbortController(); clearFocusTarget('militaryFlights'); - const renderer = services.renderer.create({ viewer, engine }); - flightState._renderer = renderer; - renderer.fleet.attach(); - // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed - // (keeps the retained instance referenced; never rendered). Captured against this epoch so a - // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. - if (!flightState._preloadModel) { - const epoch = flightState._modelEpoch; - renderer.models - .preload(resolveAsset(JET_MODEL_URL)) - .then((m) => { - if (epoch === flightState._modelEpoch) { - flightState._preloadModel = m; - flightState._planeModelLoaded = true; - } else { - renderer.models.destroy(m); - } - }) - .catch(() => { - /* tracked plane just stays a billboard a beat longer */ - }); - } flightState._billboards = new Map(); flightState._cullPositions.clear(); flightState._detectionObjects = new Map(); @@ -82,9 +68,9 @@ export function createLifecycle({ flightState._trackedEntity = null; flightState._cockpitSubjectId = null; flightState._cockpitContactMode = - document.body.classList.contains('cockpit-mode'); + globalThis.document?.body.classList.contains('cockpit-mode') === true; flightState._cockpitNearContacts = new Set(); - if (!flightState._cockpitModeListener) { + if (globalThis.window && !flightState._cockpitModeListener) { flightState._cockpitModeListener = (event) => parts.tracking._applyCockpitState(event?.detail); window.addEventListener( @@ -93,11 +79,13 @@ export function createLifecycle({ ); } - parts.tracking._installClickHandler(); - - renderer.fleet.restoreOrder(); - - console.log('[Data:Military] Initialized with billboard icons'); + flightState.initialized = true; + try { + if (viewer || engine) binding.attachRenderer(viewer, { engine }); + } catch (error) { + methods.destroy(viewer); + throw error; + } }, /** @@ -105,11 +93,12 @@ export function createLifecycle({ */ enable() { const renderer = flightState._renderer; + flightState.enabled = true; renderer?.fleet.setVisible(true); - holdContinuousRender('military'); // per-frame animator (perf wave 2) + if (renderer) holdContinuousRender('military'); // per-frame animator (perf wave 2) renderer?.models.setVisible(true); parts.tracking._setCockpitContactMode( - document.body.classList.contains('cockpit-mode'), + globalThis.document?.body.classList.contains('cockpit-mode') === true, ); // Height-datum fix: warm the geoid grid once per layer-enable. The poll loop // only reads geoidHeight() synchronously after this resolves (guarded by @@ -124,12 +113,12 @@ export function createLifecycle({ /* geoid grid failed to load — baro path stays un-geoid-corrected until retried */ }); } + setMilitaryLayerActive(true); + if (!renderer) return; parts.tracking._installClickHandler(); registerPickOwner('military', (pickedId) => flightState._billboards.has(pickedId), ); - // The flights layer suppresses its military duplicates while we render them - setMilitaryLayerActive(true); // Force a fresh rotation pass on the first tick after re-enable flightState._lastCamPoseSig = ''; if (!flightState._preRenderRemove && renderer) { @@ -163,6 +152,7 @@ export function createLifecycle({ * dispatcher so clicks do not get intercepted while the layer is off. */ disable() { + flightState.enabled = false; parts.controller._abortActiveUpdates(); parts.tracking._cancelPendingTrackingRestore(); flightState._renderer?.fleet.setVisible(false); @@ -171,24 +161,8 @@ export function createLifecycle({ flightState._renderer?.models.setVisible(false); parts.tracking._clearTracking(); parts.tracking._destroyTrail(); - // Leave the selection dispatcher (clicks, Escape) while disabled to - // avoid intercepting input when the layer is off - parts.tracking._removeClickHandler(); - unregisterPickOwner('military'); - // Flights layer takes over rendering known-military aircraft (amber) + binding.removeSceneListeners(); setMilitaryLayerActive(false); - if (flightState._preRenderRemove) { - flightState._preRenderRemove(); - flightState._preRenderRemove = null; - } - if (flightState._trackedModelPreUpdateRemove) { - flightState._trackedModelPreUpdateRemove(); - flightState._trackedModelPreUpdateRemove = null; - } - if (flightState._moveEndRemove) { - flightState._moveEndRemove(); - flightState._moveEndRemove = null; - } }, /** @@ -197,6 +171,9 @@ export function createLifecycle({ * @param {object|null} viewer - The viewer the layer was drawn on. */ destroy(viewer) { + flightState.initialized = false; + flightState.enabled = false; + setMilitaryLayerActive(false); flightState.lifetime.abort(); parts.controller._abortActiveUpdates(); releaseContinuousRender('military'); // direct-destroy path (perf wave 2 fix) @@ -211,38 +188,13 @@ export function createLifecycle({ ); flightState._cockpitModeListener = null; } - unregisterPickOwner('military'); - if (flightState._preRenderRemove) { - flightState._preRenderRemove(); - flightState._preRenderRemove = null; - } - if (flightState._trackedModelPreUpdateRemove) { - flightState._trackedModelPreUpdateRemove(); - flightState._trackedModelPreUpdateRemove = null; - } - if (flightState._moveEndRemove) { - flightState._moveEndRemove(); - flightState._moveEndRemove = null; - } - parts.rendering._releaseModels(); - // Removing the model collection destroys it and its models. - flightState._renderer?.fleet.detach(viewer); - flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) - flightState._modelPending.clear(); - flightState._modelGen.clear(); - if (flightState._preloadModel) { - flightState._renderer?.models.destroy(flightState._preloadModel); - flightState._preloadModel = null; - } - flightState._planeModelLoaded = false; - flightState._billboards.clear(); - flightState._cullPositions.clear(); - flightState._detectionObjects.clear(); + binding.detachRenderer(viewer); flightState.records.data.clear(); flightState._positionHistory.clear(); flightState._displayCourse.clear(); flightState._groundSnap.clear(); flightState.records.missingPolls.clear(); + flightState.records.geoidNCache.clear(); flightState.feed._count = 0; flightState.feed._lastUpdate = null; flightState._cockpitContactMode = false; @@ -256,8 +208,6 @@ export function createLifecycle({ source: flightState.feed._lastSource, }; parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling - flightState._renderer?.destroy(); - flightState._renderer = null; }, }; diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 3375cb26d..de175718f 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -353,7 +353,7 @@ export function createQueries({ * @returns {Array} Sorted array of nearby aircraft descriptors */ getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) { - if (!center || !flightState._renderer?.fleet.visible()) return []; + if (!center || !flightState.enabled) return []; const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) @@ -363,11 +363,14 @@ export function createQueries({ const nearby = []; - for (const [icao24, bb] of flightState._billboards) { + for (const [icao24, info] of flightState.records.data) { + const bb = flightState._billboards.get(icao24); + if (!bb && flightState._renderer) continue; const isTracked = icao24 === flightState._trackedIcao; // Keep planes rendered as a 3D model (billboard hidden) so proximity counts // don't drop to zero on the 2D→3D handoff; bb.position stays current while hidden. if ( + bb && !aircraftIncludedInNearby({ isTracked, billboardShown: bb.show, @@ -380,13 +383,12 @@ export function createQueries({ const trackedPos = isTracked ? parts.motion._trackedDisplayCached() : null; // cached, no recompute (anti-jitter) - const pos = trackedPos || bb.position; + const pos = trackedPos || bb?.position || parts.motion._deadReckon(icao24); if (!pos) continue; const distance = geo.cartesian3.distance(center, pos); if (distance > maxRange) continue; - const info = flightState.records.data.get(icao24); nearby.push({ id: info?.callsign?.trim() || info?.registration?.trim() || icao24, icao24, @@ -581,16 +583,13 @@ export function createQueries({ * holds no data and therefore cannot answer. */ hasContact(icao24) { - if ( - !flightState._renderer?.fleet.visible() || - flightState._billboards.size === 0 - ) + if (!flightState.enabled || flightState.records.data.size === 0) return null; if (!icao24) return false; const id = String(icao24).trim(); return ( - flightState._billboards.has(id) || - flightState._billboards.has(id.toLowerCase()) + flightState.records.data.has(id) || + flightState.records.data.has(id.toLowerCase()) ); }, @@ -642,10 +641,7 @@ export function createQueries({ * @returns {Array} See mapAnalystRecord for the record shape. */ getAnalystRecords(maxCount = 2000) { - if ( - !flightState._renderer?.fleet.visible() || - flightState.records.data.size === 0 - ) + if (!flightState.enabled || flightState.records.data.size === 0) return []; const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) diff --git a/src/layers/military/renderer.test.mjs b/src/layers/military/renderer.test.mjs index c5e0c795a..bf2824b96 100644 --- a/src/layers/military/renderer.test.mjs +++ b/src/layers/military/renderer.test.mjs @@ -292,3 +292,37 @@ test('the military logic draws and follows on the Navara renderer; the follow pa assert.equal(engine.movers.list().length, 0, 'no mover set left behind'); engine.destroy(); }); + +test('military classification and the selected aircraft survive an update between scenes', async () => { + const first = createFakeEngine(), second = createFakeEngine(); + let polls = 0, binding; + const layer = createApplicationMilitary({ + surface: defaultSurface, militaryRegistry, + source: { getSnapshot: async () => militarySnapshotOf(['ae0001'], 1700000000000 + (++polls)*1000) }, + renderer: {create: ({engine}) => (binding = recordingAircraftRenderer(engine))}, + }); + try { + layer.init(null); + layer.enable(); + await layer.update(null); + assert.equal(layer.hasContact('AE0001'), true); + assert.equal(layer.getAnalystRecords().length, 1); + layer.attachRenderer(null, { engine: first }); + assert.equal(layer.trackById('ae0001'), true); + layer.detachRenderer(); + assert.equal(binding.follow.current(), undefined); + assert.equal(militaryRegistry.isMilitaryLayerActive(), true); + assert.equal(layer.getTrackedInfo().icao24, 'ae0001'); + await layer.update(null); + layer.attachRenderer(null, {engine: second}); + assert.ok(binding.follow.current()); + assert.equal(layer.getStats().lastUpdate, 1700000002000); + assert.equal(polls, 2); + layer.disable(); + assert.equal(militaryRegistry.isMilitaryLayerActive(), false); + assert.equal(layer.hasContact('ae0001'), null); + } finally { + layer.destroy(null); + first.destroy(); second.destroy(); + } +}); diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 1c1eee636..ff06290d1 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -31,7 +31,74 @@ export function createMilitarySnapshotRenderer({ groundFloor; const { sampleMeshFloorCells } = meshFloor; const { registerMilitaryIcaos } = militaryRegistry; - return function applySnapshot(snapshot, viewer) { + function presentContact(icao24, meta, position, prevMeta, groundFlipped) { + const isTracked = icao24 === flightState._trackedIcao; + if (flightState._billboards.has(icao24)) { + const bb = flightState._billboards.get(icao24); + if (!isTracked) { + bb.color = rendering + ._fleetBillboardColor(icao24, meta) + .withAlpha(cyberSonarBaseAlpha(bb)); + } + // Position AND rotation are owned by the fleet pass (_fleetTick); + // course changes land on the next rotation pass (forced below). + // Ground flips (landing/takeoff) restyle this SAME billboard in + // place — the transition is never a removal. + if ( + prevMeta?.klass !== meta.klass || + groundFlipped || + flightState._cockpitContactMode + ) { + rendering._applyFleetBillboardPresentation(icao24, bb); + } + // Per-class GLBs: a class change can mean a different asset OR scale — + // resync the live model, any in-flight load, and the tracked + // standalone (mirror of flights.js). + if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24); + // Round 5: depth policy is uniform (always depth-test-free, see + // _groundDepthDistance) — nothing to flip on landing/takeoff. + } else { + // Start where the fleet pass will draw it: the fleet renders behind + // real time, so a fresh contact's displayed position is its fix + // projected back to that delayed time. Creating it at the raw fix drew + // it ahead until the next fleet tick snapped it back. + const displayed = + motion._deadReckon(icao24, geo.cartesian3.create()) || position; + const bb = flightState._renderer.fleet.add({ + position: displayed, + image: aircraftIcon(rendering._iconKind(icao24, meta.klass)), + width: isTracked ? 24 : 20, + height: isTracked ? 24 : 20, + scale: + BILLBOARD_SCALE * + (CLASS_SCALE_2D[meta.klass] || 1) * + (meta.onGround ? GROUND_SCALE : 1), + // Screen-projected rotation lands on the next fleet tick. + rotation: 0, + alignedAxis: geo.cartesian3.ZERO, + color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR, + sizeInMeters: false, + scaleByDistance: rendering._normalBillboardScaleByDistance(), + // Grounded/near-surface planes sit at/below the photoreal tile + // surface — render them depth-test-free so they never vanish up + // close (_groundDepthDistance). + disableDepthTestDistance: rendering._groundDepthDistance(), + id: icao24, + show: !isTracked, // hidden if currently tracked (entity replaces it) + }); + flightState._billboards.set(icao24, bb); + rendering._applyFleetBillboardPresentation(icao24, bb); + } + } + + function presentRecords() { + for (const [id, meta] of records.data) { + const position = geo.cartesian3.fromDegrees(meta.rawLon, meta.rawLat, meta.renderAltitudeM); + presentContact(id, meta, position, null, false); + } + } + + function applySnapshot(snapshot) { const currentIcaos = new Set(); const receiptNowMs = snapshot.observedAtMs; // Field-test fix (RS46): coarse floor cells to warm for the below-ground @@ -128,63 +195,7 @@ export function createMilitarySnapshotRenderer({ } } - if (flightState._billboards.has(icao24)) { - const bb = flightState._billboards.get(icao24); - if (!isTracked) { - bb.color = rendering - ._fleetBillboardColor(icao24, meta) - .withAlpha(cyberSonarBaseAlpha(bb)); - } - // Position AND rotation are owned by the fleet pass (_fleetTick); - // course changes land on the next rotation pass (forced below). - // Ground flips (landing/takeoff) restyle this SAME billboard in - // place — the transition is never a removal. - if ( - prevMeta?.klass !== meta.klass || - groundFlipped || - flightState._cockpitContactMode - ) { - rendering._applyFleetBillboardPresentation(icao24, bb); - } - // Per-class GLBs: a class change can mean a different asset OR scale — - // resync the live model, any in-flight load, and the tracked - // standalone (mirror of flights.js). - if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24); - // Round 5: depth policy is uniform (always depth-test-free, see - // _groundDepthDistance) — nothing to flip on landing/takeoff. - } else { - // Start where the fleet pass will draw it: the fleet renders behind - // real time, so a fresh contact's displayed position is its fix - // projected back to that delayed time. Creating it at the raw fix drew - // it ahead until the next fleet tick snapped it back. - const displayed = - motion._deadReckon(icao24, geo.cartesian3.create()) || position; - const bb = flightState._renderer.fleet.add({ - position: displayed, - image: aircraftIcon(rendering._iconKind(icao24, meta.klass)), - width: isTracked ? 24 : 20, - height: isTracked ? 24 : 20, - scale: - BILLBOARD_SCALE * - (CLASS_SCALE_2D[meta.klass] || 1) * - (meta.onGround ? GROUND_SCALE : 1), - // Screen-projected rotation lands on the next fleet tick. - rotation: 0, - alignedAxis: geo.cartesian3.ZERO, - color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR, - sizeInMeters: false, - scaleByDistance: rendering._normalBillboardScaleByDistance(), - // Grounded/near-surface planes sit at/below the photoreal tile - // surface — render them depth-test-free so they never vanish up - // close (_groundDepthDistance). - disableDepthTestDistance: rendering._groundDepthDistance(), - id: icao24, - show: !isTracked, // hidden if currently tracked (entity replaces it) - }); - flightState._billboards.set(icao24, bb); - rendering._applyFleetBillboardPresentation(icao24, bb); - } - + if (flightState._renderer) presentContact(icao24, meta, position, prevMeta, groundFlipped); // Takeoff while TRACKED: ground traffic drew no trail, so start one // from the fresh airborne history (touchdown needs no action — the // append gate above simply freezes the existing trail). @@ -238,7 +249,7 @@ export function createMilitarySnapshotRenderer({ // Round 4: sample the RENDERED mesh for those cells (one-shot per cell, // budget-capped, viewer-proximate, google-3d regime only), excluding // this layer's own billboards/models from the probe. - { + if (flightState._renderer) { const viewerCarto = flightState._renderer?.view.camera?.positionCartographic || null; sampleMeshFloorCells( @@ -269,7 +280,7 @@ export function createMilitarySnapshotRenderer({ // EXCEPTION: likely-landed planes (last fix low + slow) get only // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a // feed gap, and the full grace left phantom planes parked at airports. - for (const [icao24, bb] of flightState._billboards) { + for (const icao24 of records.data.keys()) { if (currentIcaos.has(icao24)) continue; const absence = records.absence(icao24, { complete: snapshot.complete, @@ -297,7 +308,8 @@ export function createMilitarySnapshotRenderer({ tracking._clearTracking(false, { evicted: true }); } - flightState._renderer.fleet.remove(bb); + const bb = flightState._billboards.get(icao24); + if (bb) flightState._renderer.fleet.remove(bb); flightState._billboards.delete(icao24); rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak) records.forget(icao24); @@ -313,6 +325,7 @@ export function createMilitarySnapshotRenderer({ // Feed the shared registry so the flights layer can classify/suppress registerMilitaryIcaos(currentIcaos); - return { count: flightState._billboards.size, ids: currentIcaos }; - }; + return { count: records.data.size, ids: currentIcaos }; + } + return { applySnapshot, presentRecords }; } diff --git a/src/layers/military/state.js b/src/layers/military/state.js index ceee9f833..274b6e4b0 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -4,7 +4,7 @@ import * as geo from '../../geo/index.js'; export function createFlightState({ source, services }) { const { createGroundSnap } = services.groundSnap; - const flightState = { lifetime: new AbortController() }; + const flightState = { lifetime: new AbortController(), enabled: false }; flightState.feed = createMilitaryFeed(source); flightState.records = new MilitaryFlightRecords({ ...services.geoid, diff --git a/src/layers/military/testing.js b/src/layers/military/testing.js index dbb54e0b9..c91858322 100644 --- a/src/layers/military/testing.js +++ b/src/layers/military/testing.js @@ -36,6 +36,7 @@ export function createTesting({ models = [], modelCollection = null, }) { + flightState.enabled = billboardCollection?.show !== false; // The production renderer over the test's viewer and (fake) collections. flightState._renderer = services.renderer.create({ viewer, diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 13382a227..7608ec61d 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -502,6 +502,14 @@ export function createTracking({ ); } } + _attachTrail(); + const oldestFixEpochSec = history.length + ? history[0].timeMs / 1000 + : Infinity; + _backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec); + } + + function _attachTrail() { if (!flightState._trail && flightState._renderer) { flightState._trail = flightState._renderer.trails.create({ color: TRAIL_COLOR, @@ -568,11 +576,6 @@ export function createTracking({ }); } _refreshTrailDisplay(); - - const oldestFixEpochSec = history.length - ? history[0].timeMs / 1000 - : Infinity; - _backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec); } /** @@ -703,6 +706,13 @@ export function createTracking({ function _destroyTrail() { _clearTrail(); + _detachTrail(); + } + + function _detachTrail() { + if (flightState._trailHeadEntity) + flightState._renderer?.trails.removeHead(flightState._trailHeadEntity); + flightState._trailHeadEntity = null; if (flightState._trail) { flightState._trail.destroy(); flightState._trail = null; @@ -830,8 +840,8 @@ export function createTracking({ * @param {string} icao24 - ICAO hex identifier of the aircraft to track */ - function _trackFlight(icao24, { origin = 'programmatic' } = {}) { - _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over + function _trackFlight(icao24, { origin = 'programmatic', resume = false, follow = true } = {}) { + if (!resume) _clearTracking(false, { origin }); // switching planes — the new follow-camera takes over const bb = flightState._billboards.get(icao24); const info = flightState.records.data.get(icao24); @@ -958,9 +968,15 @@ export function createTracking({ refreshTrackedReadout(flightState._trackedEntity); // Camera follows the tracked entity (the renderer cancels any flight // first and applies the one exact tracked frame). - flightState._trackedCameraFrameStop = flightState._renderer.follow.start( - flightState._trackedEntity, - ); + if (follow) { + flightState._trackedCameraFrameStop = flightState._renderer.follow.start( + flightState._trackedEntity, + ); + } + if (resume) { + _attachTrail(); + return; + } // Track-history trail (PRD F2): seed from local history + async backfill. // Ground traffic draws NO trail — if the plane takes off while tracked, the @@ -1108,6 +1124,8 @@ export function createTracking({ _appendTrailFix, _refreshTrailDisplay, _startTrail, + _attachTrail, + _detachTrail, _backfillTrail, _clearTrail, _destroyTrail,