From 7c0891deda3cde2f4cb2f6e0f6c21571876c2364 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 17 Oct 2026 04:52:15 +0400 Subject: [PATCH] refactor(military): Cesium rendering behind a renderer interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 11a. The military flights logic no longer imports Cesium: it draws through the shared aircraft renderer (src/layers/aircraft/render/cesium.js) with the military's parameters in src/layers/military/render/cesium.js (sprite-order slot 'military', jet.glb's 180° nose offset), registered for 'cesium' in the renderer registry and created at init(viewer, {engine}). The same pure move as flights in 10a: - Fleet: the BillboardCollection and its sprite-order slot (enable keeps restoreSpriteOrder, as before); sprites stay Billboard handles. - Models: fleet/tracked/preload glTF loads (asynchronous:false, MIX tint at MODEL_COLOR_BLEND_AMOUNT, IR boost as `unlit` → the UNLIT CustomShader), the model collection, setPose. The per-class specs keep their own nose offset: setPose takes an optional offsetDeg (default the layer's), on both aircraft renderers (same arithmetic, test added). - Tracked: the ENU billboard Entity with its position/colour/rotation callbacks (#FFD166, transparent amber once the model owns the visual), viewFrom, followed through viewer.trackedEntity + the one exact tracked frame; refocusTrackedById through follow.refocus. - Trail: the entity trail and the floored live head segment. - View: camera/scene reads, frame number, frustum and horizon tests, render requests, frame events. - Clicks and Escape go through the engine's selection dispatcher (the ScreenSpaceEventHandler and keydown listener are gone); the cross-layer untrack watches the renderer's follow. Same rules: cockpit clicks are inert, the tracked visual/model is a no-op, another layer's pick is not empty space, an unowned pick is. - Colours are engine-neutral Rgba (MIL_ICON_COLOR #FFB800, TRACKED_ICON_COLOR #FFD166, AMBER_TRANSPARENT), NearFarScalar → nearFarScale; Cesium math → src/geo. - check-import-directions: src/layers/military/ joins the rendered layers (cesium only in render/cesium*.js). - The military registry and the flights' duplicate suppression are untouched. Source-text tests updated with the moved code, same meaning: - militaryAwareness.test: military's hasContact guard reads fleet.visible(). - trackingClickGesture.test: the military gesture rules are the dispatcher's (selection gesture before select, click classifier before the empty-space clear), military answers the empty click with a user-origin clear — the same assertion flights already had. - ownership.test: the services fixture names selection and renderer; the app-composed services build the layer's Cesium renderer. - renderers.test: flights and military have extracted Cesium renderers. New tests: the military logic over a plain-data renderer and a fake engine (amber fleet, click-select, #FFD166 tracked glyph, no-op on the tracked visual, empty-click and Escape release, the keyboard left on destroy); the plain-data renderer moved to src/testSupport for both aircraft layers; setPose with a per-model offset. W1 replay vs 7c7422e2 (the 10a reference run): every layer's counts, known objects, screen points, tracking pose after 2 s, requests and the city/global/orbit shots (sha256) identical. qa-military-names (it checks the installations layer): the same verdict as 7c7422e2 (the zoom-markers assertion fails on both). Main chunk 2,890,763 B (−2.9 KB). --- scripts/check-import-directions.mjs | 1 + scripts/format-scope.json | 3 + scripts/package-boundaries.json | 5 +- src/app/layers/militaryFlights.js | 22 +- src/app/layers/renderers.js | 2 + src/app/layers/renderers.test.mjs | 10 +- src/data/militaryAwareness.test.mjs | 3 +- src/data/trackingClickGesture.test.mjs | 17 +- src/layers/aircraft/render/cesium.js | 9 +- src/layers/aircraft/render/cesium.test.mjs | 17 + src/layers/aircraft/render/contract.js | 3 +- src/layers/aircraft/render/navara.js | 9 +- src/layers/flights/renderer.test.mjs | 118 +--- src/layers/military/lifecycle.js | 147 ++--- src/layers/military/motion.js | 29 +- src/layers/military/ownership.test.mjs | 19 +- src/layers/military/policy.js | 24 +- src/layers/military/queries.js | 38 +- src/layers/military/render/cesium.js | 24 + src/layers/military/renderer.test.mjs | 142 +++++ src/layers/military/rendering.js | 227 +++----- src/layers/military/snapshotRenderer.js | 41 +- src/layers/military/state.js | 77 +-- src/layers/military/testing.js | 8 +- src/layers/military/tracking.js | 515 ++++++++---------- src/testSupport/aircraftRecordingRenderer.mjs | 122 +++++ 26 files changed, 829 insertions(+), 803 deletions(-) create mode 100644 src/layers/military/render/cesium.js create mode 100644 src/layers/military/renderer.test.mjs create mode 100644 src/testSupport/aircraftRecordingRenderer.mjs diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 394e9f70b..630c7bb49 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -35,6 +35,7 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/; // Navara only in render/navara*.js. The list grows with each moved layer. const RENDERED_LAYER_DIRS = [ 'src/layers/flights/', + 'src/layers/military/', 'src/layers/aircraft/', 'src/layers/shared/', ]; diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 0559e8e57..d851aebc7 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -326,6 +326,8 @@ "src/layers/aircraft/render/navara.test.mjs", "src/layers/aircraft/render/navara.js", "src/layers/flights/renderer.test.mjs", + "src/layers/military/renderer.test.mjs", + "src/testSupport/aircraftRecordingRenderer.mjs", "src/layers/aircraft/render/cesium.test.mjs", "src/layers/aircraft/render/cesium.js", "src/layers/aircraft/render/contract.js", @@ -348,6 +350,7 @@ "src/layers/military/ingestion.js", "src/layers/military/queries.js", "src/layers/military/index.js", + "src/layers/military/render/cesium.js", "src/layers/aircraft/index.js", "src/layers/aircraft/classification.js", "src/layers/military/ownership.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7ae5e19ce..95855dce0 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -935,6 +935,7 @@ "src/layers/military/records.js", "src/layers/military/recordPolicy.js", "src/layers/military/snapshotRenderer.js", + "src/layers/shared/color.js", "src/data/inputOwnership.js", "src/data/modelEligibility.js", "src/geo/index.js", @@ -2591,6 +2592,7 @@ "src/app/layers/renderers.js", "src/layers/aircraft/render/cesium.js", "src/layers/flights/render/cesium.js", + "src/layers/military/render/cesium.js", "src/data/selectionDispatcher.js", "src/layers/shared/color.js", "src/layers/aircraft/render/navara.js", @@ -3169,7 +3171,8 @@ "src/layers/aircraft/render/cesium.js", "src/data/selectionDispatcher.js", "src/layers/shared/color.js", - "src/layers/flights/render/cesium.js" + "src/layers/flights/render/cesium.js", + "src/layers/military/render/cesium.js" ], "external": [ "hls.js", diff --git a/src/app/layers/militaryFlights.js b/src/app/layers/militaryFlights.js index f66689562..d58e40459 100644 --- a/src/app/layers/militaryFlights.js +++ b/src/app/layers/militaryFlights.js @@ -1,10 +1,10 @@ import { createMilitaryFlightLayer } from '../../layers/military/index.js'; import * as picking from '../../data/pickRegistry.js'; -import * as sprites from '../../data/spriteOrder.js'; -import * as trails from '../../data/trailRenderer.js'; +import * as selection from '../../data/selectionDispatcher.js'; import * as aircraftPresentation from '../../data/tr3bRegistry.js'; import * as trackedCamera from '../../data/trackedCamera.js'; -import { applyTrackedCameraFrame } from '../../globe/cesium/follow.js'; +import { rendererFor } from './renderers.js'; +import { createMilitaryCesiumRenderer } from '../../layers/military/render/cesium.js'; import * as labels from '../../data/detectionDraw.js'; import * as geoid from '../../data/geoid.js'; import * as focus from '../../data/focusDeemphasis.js'; @@ -20,6 +20,13 @@ export function createApplicationMilitary({ militaryRegistry, resolveAsset = (url) => `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, + // Test seam: the renderer factory (default: the engine's, from the registry). + renderer = { + create: ({ viewer, engine = null, adopt }) => + engine + ? rendererFor('military', engine, { viewer, adopt }) + : createMilitaryCesiumRenderer(null, { viewer, adopt }), + }, }) { const { groundFloor, meshFloor, groundSnap } = surface; return createMilitaryFlightLayer({ @@ -27,10 +34,13 @@ export function createApplicationMilitary({ resolveAsset, services: { picking, - sprites, - trails, + selection, + // The engine's military renderer from the registry (render/cesium.js + // on Cesium); a bare viewer without an engine (unit tests) draws on + // Cesium. + renderer, aircraftPresentation, - camera: { ...trackedCamera, applyTrackedCameraFrame }, + camera: trackedCamera, militaryRegistry, labels, groundFloor, diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 67cda281a..95fb252a8 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -25,6 +25,7 @@ import { APPLICATION_LAYER_DIRECTORY } from './directory.js'; import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js'; +import { createMilitaryCesiumRenderer } from '../../layers/military/render/cesium.js'; const ENGINE_KINDS = new Set(['cesium', 'navara']); @@ -85,6 +86,7 @@ export const layerAvailableOn = (layerId, engineKind) => /** Cesium renderers extracted from their layers (render/cesium.js). */ export const CESIUM_LAYER_RENDERERS = Object.freeze({ flights: createFlightsCesiumRenderer, + military: createMilitaryCesiumRenderer, }); // The other application layers still draw themselves on Cesium. diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index 10f8ebc19..a45784824 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -56,12 +56,18 @@ test('the default registry: every layer on Cesium, none yet on Navara', () => { } }); -test('flights has an extracted Cesium renderer; the others still draw themselves', () => { - assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), ['flights']); +test('flights and military have extracted Cesium renderers; the others still draw themselves', () => { + assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [ + 'flights', + 'military', + ]); const viewer = { scene: {}, camera: {} }; const renderer = rendererFor('flights', { kind: 'cesium' }, { viewer }); assert.equal(renderer.kind, 'cesium'); assert.equal(renderer.viewer, viewer); + const military = rendererFor('military', { kind: 'cesium' }, { viewer }); + assert.equal(military.kind, 'cesium'); + assert.equal(military.viewer, viewer); assert.equal(rendererFor('satellites', { kind: 'cesium' }), null); }); diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 7abe91c1c..fe20262b6 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -1369,8 +1369,9 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol // Flights asks its renderer whether the fleet is shown (phase 3). ['flights', readLayerSource(new URL('./flights.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;/], + // 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\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/], + /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;/], ['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/data/trackingClickGesture.test.mjs b/src/data/trackingClickGesture.test.mjs index b71b9ba9f..c38041382 100644 --- a/src/data/trackingClickGesture.test.mjs +++ b/src/data/trackingClickGesture.test.mjs @@ -114,18 +114,17 @@ test('civilian and military click handlers apply duration only at the deselect b readLayerSource(new URL('./flights.js', import.meta.url)), readLayerSource(new URL('./militaryFlights.js', import.meta.url)), ]; - // Military still owns its handler; flights moved to the selection - // dispatcher (phase 3), whose source applies the same two rules: the - // selection gesture before a select, the full click classifier before the - // empty-space deselect, which flights answers with a user-origin clear. - const military = sources[1]; - assert.match(military, /isTrackingSelectionGesture\(gesture\)[\s\S]+scene\.pick/); - assert.match(military, /isTrackingClickGesture\(gesture\)[\s\S]+(?:parts\.\w+\.)?_clearTracking\([^)]*\{ origin: 'user' \}\)/); + // Flights and military moved to the selection dispatcher (phase 3), whose + // source applies the same two rules: the selection gesture before a + // select, the full click classifier before the empty-space deselect, which + // each layer answers with a user-origin clear. const dispatcher = readFileSync(new URL('./selectionDispatcher.js', import.meta.url), 'utf8'); assert.match(dispatcher, /if \(!isTrackingSelectionGesture\(gesture\)\) return;[\s\S]+onSelect/); assert.match(dispatcher, /if \(!isTrackingClickGesture\(gesture\)\) return;\s*for \(const owner of \[\.\.\.owners\.values\(\)\]\) owner\.onEmptyClick/); - assert.match(sources[0], /dispatcher\.register\('flights', \{[\s\S]+onEmptyClick: _onEmptyClick/); - assert.match(sources[0], /function _onEmptyClick\(\) \{[\s\S]+?_clearTracking\(false, \{ origin: 'user' \}\)/); + for (const [layer, source] of [['flights', sources[0]], ['military', sources[1]]]) { + assert.match(source, new RegExp(`dispatcher\\.register\\('${layer}', \\{[\\s\\S]+onEmptyClick: _onEmptyClick`)); + assert.match(source, /function _onEmptyClick\(\) \{[\s\S]+?_clearTracking\(false, \{ origin: 'user' \}\)/); + } assert.doesNotMatch( sources[0], /(?:flightState\.)?_trackedEntity = (?:flightState\.)?_viewer\.entities\.add\(\{\s*id:/, diff --git a/src/layers/aircraft/render/cesium.js b/src/layers/aircraft/render/cesium.js index 9bd4bf3bb..b7d5f2c0b 100644 --- a/src/layers/aircraft/render/cesium.js +++ b/src/layers/aircraft/render/cesium.js @@ -263,12 +263,11 @@ export function createAircraftCesiumRenderer({ * written into the model's OWN modelMatrix: Model.modelMatrix is a plain * field Cesium clones per frame, so models never share one mutable * matrix (a shared scratch stacked every model on the last-written - * plane: the "flickering like mad" bug). + * plane: the "flickering like mad" bug). `offsetDeg`: this model's nose + * offset when the layer's models differ (default: the layer's). */ - setPose(model, position, headingDeg) { - scratchHpr.heading = Cesium.Math.toRadians( - (headingDeg || 0) + headingOffsetDeg, - ); + setPose(model, position, headingDeg, offsetDeg = headingOffsetDeg) { + scratchHpr.heading = Cesium.Math.toRadians((headingDeg || 0) + offsetDeg); scratchHpr.pitch = 0; scratchHpr.roll = 0; return Cesium.Transforms.headingPitchRollToFixedFrame( diff --git a/src/layers/aircraft/render/cesium.test.mjs b/src/layers/aircraft/render/cesium.test.mjs index 8de3654ae..9f3f4126b 100644 --- a/src/layers/aircraft/render/cesium.test.mjs +++ b/src/layers/aircraft/render/cesium.test.mjs @@ -110,6 +110,23 @@ test('a model pose is the old heading-pitch-roll frame with the nose offset', () ); }); +test('a model pose takes a per-model nose offset (military per-class specs)', () => { + const { viewer } = stubViewer(); + const r = renderer(viewer); + const model = { modelMatrix: new Cesium.Matrix4() }; + const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000); + r.models.setPose(model, position, 75, 90); + const expected = Cesium.Transforms.headingPitchRollToFixedFrame( + position, + new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(75 + 90), 0, 0), + Cesium.Ellipsoid.WGS84, + ); + assert.deepEqual( + [...Cesium.Matrix4.toArray(model.modelMatrix)], + [...Cesium.Matrix4.toArray(expected)], + ); +}); + test('model loads carry the layer spec; the IR boost loads an UNLIT model', async () => { const { viewer } = stubViewer(); const r = renderer(viewer); diff --git a/src/layers/aircraft/render/contract.js b/src/layers/aircraft/render/contract.js index f80d224cf..d0ce7d791 100644 --- a/src/layers/aircraft/render/contract.js +++ b/src/layers/aircraft/render/contract.js @@ -71,7 +71,8 @@ * restoreOrder(), restoreOrderOnEnable(). * @property {object} models live(), setVisible(bool), loadFleet(spec), * loadTracked(spec), preload(url) → Promise, add(model), - * remove(model), destroy(model), setPose(model, position, headingDeg). + * remove(model), destroy(model), setPose(model, position, headingDeg, + * offsetDeg?) (offsetDeg: the model's nose offset, default the layer's). * spec: { url, minimumPixelSize, scale, color, blendAmount, unlit, id }. * @property {object} tracked create(spec) → TrackedHandle, remove(handle), * isLive(handle), setImage(handle, image), setViewFrom(handle, enu), diff --git a/src/layers/aircraft/render/navara.js b/src/layers/aircraft/render/navara.js index 825e26725..5d77f2a76 100644 --- a/src/layers/aircraft/render/navara.js +++ b/src/layers/aircraft/render/navara.js @@ -719,11 +719,10 @@ export function createAircraftNavaraRenderer({ model?.gevBank?.remove(model); }, /** The same HPR frame as Cesium (modelMatrix); the instance turned by - * Navara's glTF axes (NAVARA_GLTF_AXIS). */ - setPose(model, position, headingDeg) { - scratchHpr.heading = geo.math.toRadians( - (headingDeg || 0) + headingOffsetDeg, - ); + * Navara's glTF axes (NAVARA_GLTF_AXIS). `offsetDeg`: this model's + * nose offset when the layer's models differ (default: the layer's). */ + setPose(model, position, headingDeg, offsetDeg = headingOffsetDeg) { + scratchHpr.heading = geo.math.toRadians((headingDeg || 0) + offsetDeg); geo.transforms.headingPitchRollToFixedFrame( position, scratchHpr, diff --git a/src/layers/flights/renderer.test.mjs b/src/layers/flights/renderer.test.mjs index 194b2ca33..32f3f4780 100644 --- a/src/layers/flights/renderer.test.mjs +++ b/src/layers/flights/renderer.test.mjs @@ -14,6 +14,7 @@ import * as militaryRegistry from '../../data/militaryRegistry.js'; import { openSkySnapshot } from '../../sources/live/aircraft.js'; import { missingAircraftRendererMembers } from '../aircraft/render/contract.js'; import { Rgba } from '../shared/color.js'; +import { recordingAircraftRenderer as recordingRenderer } from '../../testSupport/aircraftRecordingRenderer.mjs'; globalThis.window ||= {}; globalThis.window.dispatchEvent ||= () => true; @@ -31,123 +32,6 @@ globalThis.document ||= { removeEventListener() {}, }; -/** A renderer of plain records, for the logic alone. */ -function recordingRenderer(engine) { - const calls = []; - const sprites = new Set(); - const camera = { - positionWC: geo.cartesian3.fromDegrees(-97.7, 30.2, 400_000), - positionCartographic: geo.cartographic.fromDegrees(-97.7, 30.2, 400_000), - rightWC: geo.cartesian3.create(1, 0, 0), - upWC: geo.cartesian3.create(0, 0, 1), - directionWC: geo.cartesian3.create(0, -1, 0), - heading: 0, - pitch: -Math.PI / 2, - roll: 0, - frustum: { fovy: 1 }, - }; - const scene = { - camera, - canvas: { clientHeight: 768 }, - frameState: { frameNumber: 1 }, - }; - let trackedSeq = 0; - let following; - const renderer = { - kind: 'record', - engine, - calls, - sprites, - view: { - camera, - scene, - surfaceOwner: engine, - surfaceScene: engine, - frameNumber: () => 1, - viewportHeightPx: () => 768, - frustumTester: () => () => true, - horizon: () => ({ isPointVisible: () => true }), - worldToWindow: () => ({ x: 0, y: 0 }), - requestRender: () => calls.push(['requestRender']), - on: () => () => {}, - holdFrames() {}, - closeFrameLatch() {}, - releaseFrames() {}, - }, - fleet: { - shown: false, - attach: () => calls.push(['attach']), - detach: () => calls.push(['detach']), - attached: () => true, - visible: () => renderer.fleet.shown, - setVisible(visible) { - renderer.fleet.shown = visible; - }, - add(spec) { - const sprite = { ...spec }; - sprites.add(sprite); - return sprite; - }, - remove: (sprite) => sprites.delete(sprite), - restoreOrder() {}, - restoreOrderOnEnable() {}, - }, - models: { - live: () => true, - setVisible() {}, - loadFleet: () => Promise.reject(new Error('no models here')), - loadTracked: () => Promise.reject(new Error('no models here')), - preload: () => Promise.reject(new Error('no models here')), - add() {}, - remove() {}, - destroy() {}, - setPose() {}, - }, - tracked: { - create(spec) { - const handle = { id: `tracked-${++trackedSeq}`, spec }; - calls.push(['tracked.create', handle.id]); - return handle; - }, - remove: (handle) => calls.push(['tracked.remove', handle.id]), - isLive: () => true, - setImage() {}, - setViewFrom(handle, offset) { - handle.viewFrom = offset; - }, - billboardSize: () => null, - }, - follow: { - start(handle) { - following = handle; - calls.push(['follow.start', handle.id]); - return () => calls.push(['follow.frameStop']); - }, - refocus: (handle) => renderer.follow.start(handle), - release() { - following = undefined; - calls.push(['follow.release']); - }, - isFollowing: (handle) => following === handle, - current: () => following, - onChanged: () => () => {}, - }, - trails: { - create: () => ({ - setPositions() {}, - setVisible() {}, - clear() {}, - destroy() {}, - }), - createHead: ({ id }) => ({ id, show: true }), - removeHead() {}, - }, - surfaceExclusions: () => [], - destroy: () => calls.push(['destroy']), - }; - return renderer; -} - function snapshotOf(ids) { const nowSec = Math.floor(Date.now() / 1000); return openSkySnapshot({ diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index c9e658e98..5b60928ab 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { JET_MODEL_URL } from './policy.js'; export function createLifecycle({ @@ -9,7 +8,6 @@ export function createLifecycle({ resolveAsset, }) { const { clearFocusTarget } = services.focus; - const { registerSpriteCollection, restoreSpriteOrder } = services.sprites; const { holdContinuousRender, releaseContinuousRender } = services.render; const { ensureGeoidReady } = services.geoid; const { registerPickOwner, unregisterPickOwner } = services.picking; @@ -18,7 +16,7 @@ export function createLifecycle({ const methods = { /** Configure the source before initialization; an active layer keeps its owner. */ setSource(source) { - if (flightState._viewer) + if (flightState._renderer) throw new Error('Configure the source before layer initialization'); if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); @@ -29,43 +27,37 @@ export function createLifecycle({ }, /** - * Initialize the layer: create the billboard collection, reset all state, - * and install the click-to-track handler. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance + * Initialize the layer: create the engine's aircraft renderer (fleet + * sprites, models), reset all state, and join the engine's selection + * dispatcher. + * @param {object|null} viewer - The Cesium viewer (null on an engine + * without one). + * @param {{ engine?: object }} [context] - LayerLifecycle's context. */ - init(viewer) { - if (flightState._viewer) + init(viewer, { engine = null } = {}) { + if (flightState._renderer) 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'); - flightState._viewer = viewer; - flightState._billboardCollection = new Cesium.BillboardCollection(); - viewer.scene.primitives.add(flightState._billboardCollection); - registerSpriteCollection('military', flightState._billboardCollection); - flightState._modelCollection = new Cesium.PrimitiveCollection(); - viewer.scene.primitives.add(flightState._modelCollection); + 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; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(JET_MODEL_URL), - asynchronous: false, - }) + renderer.models + .preload(resolveAsset(JET_MODEL_URL)) .then((m) => { if (epoch === flightState._modelEpoch) { flightState._preloadModel = m; flightState._planeModelLoaded = true; } else { - try { - m.destroy(); - } catch { - /* gone */ - } + renderer.models.destroy(m); } }) .catch(() => { @@ -101,23 +93,21 @@ export function createLifecycle({ ); } - parts.tracking._installClickHandler(viewer); + parts.tracking._installClickHandler(); - restoreSpriteOrder(viewer); + renderer.fleet.restoreOrder(); console.log('[Data:Military] Initialized with billboard icons'); }, /** - * Show the layer and re-install the click handler. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance + * Show the layer and re-join the selection dispatcher. */ - enable(viewer) { - if (flightState._billboardCollection) - flightState._billboardCollection.show = true; + enable() { + const renderer = flightState._renderer; + renderer?.fleet.setVisible(true); holdContinuousRender('military'); // per-frame animator (perf wave 2) - if (flightState._modelCollection) - flightState._modelCollection.show = true; + renderer?.models.setVisible(true); parts.tracking._setCockpitContactMode( document.body.classList.contains('cockpit-mode'), ); @@ -134,7 +124,7 @@ export function createLifecycle({ /* geoid grid failed to load — baro path stays un-geoid-corrected until retried */ }); } - parts.tracking._installClickHandler(viewer); + parts.tracking._installClickHandler(); registerPickOwner('military', (pickedId) => flightState._billboards.has(pickedId), ); @@ -142,60 +132,48 @@ export function createLifecycle({ setMilitaryLayerActive(true); // Force a fresh rotation pass on the first tick after re-enable flightState._lastCamPoseSig = ''; - if (!flightState._preRenderRemove && viewer?.scene) { - flightState._preRenderRemove = viewer.scene.preRender.addEventListener( + if (!flightState._preRenderRemove && renderer) { + flightState._preRenderRemove = renderer.view.on( + 'preRender', parts.rendering._fleetTick, ); } - if (!flightState._trackedModelPreUpdateRemove && viewer?.scene) { - flightState._trackedModelPreUpdateRemove = - viewer.scene.preUpdate.addEventListener( - parts.rendering._updateTrackedModel, - ); + if (!flightState._trackedModelPreUpdateRemove && renderer) { + flightState._trackedModelPreUpdateRemove = renderer.view.on( + 'preUpdate', + parts.rendering._updateTrackedModel, + ); } - if (!flightState._moveEndRemove && viewer?.camera) { + if (!flightState._moveEndRemove && renderer) { // Arrival polish (mirror of flights.js — see the comment there): a settled // camera move forces a full rotation pass on the very next frame, because // the pose-signature gate can eat the settle (final easing frames land // inside one quantization bucket) and leave stale noses for up to // ROTATION_REFRESH_MS. One extra pass per gesture — nothing per-frame. - flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener( - () => { - flightState._lastCamPoseSig = ''; - flightState._lastFleetTickMs = 0; - }, - ); + flightState._moveEndRemove = renderer.view.on('moveEnd', () => { + flightState._lastCamPoseSig = ''; + flightState._lastFleetTickMs = 0; + }); } - restoreSpriteOrder(viewer); + renderer?.fleet.restoreOrder(); }, /** - * Hide the layer, clear any active tracking, and remove input handlers - * so clicks do not get intercepted while the layer is off. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance + * Hide the layer, clear any active tracking, and leave the selection + * dispatcher so clicks do not get intercepted while the layer is off. */ - disable(viewer) { + disable() { parts.controller._abortActiveUpdates(); parts.tracking._cancelPendingTrackingRestore(); - if (flightState._billboardCollection) - flightState._billboardCollection.show = false; + flightState._renderer?.fleet.setVisible(false); releaseContinuousRender('military'); parts.rendering._releaseModels(); - if (flightState._modelCollection) - flightState._modelCollection.show = false; + flightState._renderer?.models.setVisible(false); parts.tracking._clearTracking(); parts.tracking._destroyTrail(); - // Remove click handler + keydown listener while disabled to avoid - // intercepting input when the layer is off - if (flightState._clickHandler) { - flightState._clickHandler.destroy(); - flightState._clickHandler = null; - } - if (flightState._trackedEntityChangedRemove) { - flightState._trackedEntityChangedRemove(); - flightState._trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', parts.tracking._onKeyDown); + // 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) setMilitaryLayerActive(false); @@ -214,9 +192,9 @@ export function createLifecycle({ }, /** - * Tear down the layer: clear tracking, remove handlers, remove the billboard - * collection from the scene, and release all state. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance + * Tear down the layer: clear tracking, remove handlers, remove its + * drawings, and release all state. + * @param {object|null} viewer - The viewer the layer was drawn on. */ destroy(viewer) { flightState.lifetime.abort(); @@ -225,15 +203,7 @@ export function createLifecycle({ parts.tracking._clearTracking(); parts.tracking._destroyTrail(); parts.tracking._cancelPendingTrackingRestore(); - if (flightState._clickHandler) { - flightState._clickHandler.destroy(); - flightState._clickHandler = null; - } - if (flightState._trackedEntityChangedRemove) { - flightState._trackedEntityChangedRemove(); - flightState._trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', parts.tracking._onKeyDown); + parts.tracking._removeClickHandler(); if (flightState._cockpitModeListener) { window.removeEventListener( 'gev:cockpit-mode-changed', @@ -255,23 +225,13 @@ export function createLifecycle({ flightState._moveEndRemove = null; } parts.rendering._releaseModels(); - if (flightState._billboardCollection) { - viewer.scene.primitives.remove(flightState._billboardCollection); - flightState._billboardCollection = null; - } - if (flightState._modelCollection) { - viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models - flightState._modelCollection = null; - } + // 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) { - try { - flightState._preloadModel.destroy(); - } catch { - /* gone */ - } + flightState._renderer?.models.destroy(flightState._preloadModel); flightState._preloadModel = null; } flightState._planeModelLoaded = false; @@ -296,7 +256,8 @@ export function createLifecycle({ source: flightState.feed._lastSource, }; parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling - flightState._viewer = null; + flightState._renderer?.destroy(); + flightState._renderer = null; }, }; diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index 74ff953ed..9610cbc09 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -203,8 +203,9 @@ export function createMotion({ /** Resolve the selected aircraft's actual rendered square extent this frame. */ function _trackedFocusSizePx(icao24, position) { - const camera = flightState._viewer?.camera; - const scene = flightState._viewer?.scene; + const view = flightState._renderer?.view; + const camera = view?.camera; + const scene = view?.scene; if (!camera?.positionWC || !position || !scene) return 28; const rangeM = geo.cartesian3.distance(camera.positionWC, position); if (parts.rendering._modelOwnsVisual(icao24)) { @@ -215,12 +216,12 @@ export function createMotion({ baseScale: spec.scale, nativeRadiusM: spec.nativeRadiusM, rangeM, - viewportHeightPx: scene.canvas.clientHeight, + viewportHeightPx: view.viewportHeightPx(), fovyRad: camera.frustum.fovy, maximumPixelSize: TRACKED_MODEL_MAX_PX, }); const focalLengthPx = - scene.canvas.clientHeight / (2 * Math.tan(camera.frustum.fovy / 2)); + view.viewportHeightPx() / (2 * Math.tan(camera.frustum.fovy / 2)); const projectedDiameterPx = (2 * spec.nativeRadiusM * scale * focalLengthPx) / rangeM; return Math.max( @@ -229,17 +230,17 @@ export function createMotion({ ); } - const billboard = flightState._trackedEntity?.billboard; - const time = flightState._viewer.clock.currentTime; - const width = billboard?.width?.getValue(time) ?? 28; - const height = billboard?.height?.getValue(time) ?? 28; + const billboard = flightState._renderer.tracked.billboardSize( + flightState._trackedEntity, + ); + const width = billboard?.width ?? 28; + const height = billboard?.height ?? 28; const scale = - billboard?.scale?.getValue(time) ?? + billboard?.scale ?? BILLBOARD_SCALE * (CLASS_SCALE_2D[flightState.records.data.get(icao24)?.klass] || 1); const scaleByDistance = - billboard?.scaleByDistance?.getValue(time) ?? - TRACKED_BILLBOARD_SCALE_BY_DISTANCE; + billboard?.scaleByDistance ?? TRACKED_BILLBOARD_SCALE_BY_DISTANCE; const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM); return Math.max(width, height) * scale * distanceScale; } @@ -254,7 +255,7 @@ export function createMotion({ */ function _trackedDisplayPosition(icao24) { - const frame = flightState._viewer?.scene?.frameState?.frameNumber ?? -1; + const frame = flightState._renderer?.view.frameNumber() ?? -1; if ( frame === flightState._cachedDRFrame && icao24 === flightState._drReconcileIcao @@ -324,8 +325,8 @@ export function createMotion({ publishFocusTargetFromCachedPosition({ ownerLayer: 'militaryFlights', id: icao24, - scene: flightState._viewer?.scene, - camera: flightState._viewer?.camera, + scene: flightState._renderer?.view.scene, + camera: flightState._renderer?.view.camera, displayPosition: flightState._cachedDRPosition, widthPx: focusSizePx, heightPx: focusSizePx, diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs index 15ae70e44..48d7876c6 100644 --- a/src/layers/military/ownership.test.mjs +++ b/src/layers/military/ownership.test.mjs @@ -6,8 +6,8 @@ import { createFlightState } from './state.js'; function services() { const names = [ 'picking', - 'sprites', - 'trails', + 'selection', + 'renderer', 'aircraftPresentation', 'camera', 'militaryRegistry', @@ -119,18 +119,19 @@ test('a normalized source can retain its stale reason without changing standalon async function firstPlacement(appClockLagMs) { const Cesium = await import('cesium'); const { RENDER_DELAY_SEC } = await import('./policy.js'); + const { createMilitaryCesiumRenderer } = await import('./render/cesium.js'); // The application's own scene services, as src/app/layers/militaryFlights.js // composes them; only the surface samplers and registry are stubbed. const supplied = { picking: await import('../../data/pickRegistry.js'), - sprites: await import('../../data/spriteOrder.js'), - trails: await import('../../data/trailRenderer.js'), - aircraftPresentation: await import('../../data/tr3bRegistry.js'), - camera: { - ...(await import('../../data/trackedCamera.js')), - applyTrackedCameraFrame: (await import('../../globe/cesium/follow.js')) - .applyTrackedCameraFrame, + selection: await import('../../data/selectionDispatcher.js'), + // The layer's Cesium renderer, as the app creates it without an engine. + renderer: { + create: ({ viewer, adopt }) => + createMilitaryCesiumRenderer(null, { viewer, adopt }), }, + aircraftPresentation: await import('../../data/tr3bRegistry.js'), + camera: await import('../../data/trackedCamera.js'), labels: await import('../../data/detectionDraw.js'), geoid: await import('../../data/geoid.js'), focus: await import('../../data/focusDeemphasis.js'), diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index 36d5ebde4..e645513dc 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -5,7 +5,7 @@ export { MISSING_POLL_LIMIT, ERROR_BACKOFF_INTERVAL, } from './recordPolicy.js'; -import * as Cesium from 'cesium'; +import { constantRgba, nearFarScale, rgbaFromCss } from '../shared/color.js'; /** * @module militaryFlights @@ -32,13 +32,13 @@ import * as Cesium from 'cesium'; export const BILLBOARD_SCALE = 0.7; -/** @constant {Cesium.Color} Default amber tint for untracked military billboards */ +/** @constant {import('../shared/color.js').Rgba} Default amber tint for untracked military billboards */ -export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800'); +export const MIL_ICON_COLOR = Object.freeze(rgbaFromCss('#FFB800')); -/** @constant {Cesium.Color} Lighter amber tint applied to the actively tracked aircraft */ +/** @constant {import('../shared/color.js').Rgba} Lighter amber tint applied to the actively tracked aircraft */ -export const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166'); +export const TRACKED_ICON_COLOR = Object.freeze(rgbaFromCss('#FFD166')); // --- Ground traffic (owner reversal 2026-07-03; mirror of flights.js) --------------- // adsb.lol/readsb flags ground traffic with alt_baro === "ground" (no separate @@ -149,15 +149,17 @@ export const MODEL_BELLY_OFFSET_NATIVE = 5.631; /** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */ -export const AMBER_TRANSPARENT = MIL_ICON_COLOR.withAlpha(0); +export const AMBER_TRANSPARENT = constantRgba( + MIL_ICON_COLOR.red, + MIL_ICON_COLOR.green, + MIL_ICON_COLOR.blue, + 0, +); export const COCKPIT_CONTACT_SIZE_PX = 6; -export const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar( - 1000, - 3.0, - 8000000, - 0.5, +export const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = Object.freeze( + nearFarScale(1000, 3.0, 8000000, 0.5), ); // --------------------------------------------------------------------------- diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index bd202435f..2b03ae9f3 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -19,7 +19,6 @@ export function createQueries({ }) { const { tr3bAircraftClass, tr3bTypeLabel } = services.aircraftPresentation; const { formatFlightLevel } = services.labels; - const { applyTrackedCameraFrame } = services.camera; /** * Coerce a value to a finite number, returning null if not possible. @@ -241,7 +240,7 @@ export function createQueries({ parts.tracking._syncTracked2dRotation(); // Restore fleet billboards (horizon-cull re-asserts next tick), but NEVER the tracked // plane's own fleet billboard — its tracked entity is the visual (avoids a double-image). - if (flightState._billboardCollection) + if (flightState._renderer?.fleet.attached()) for (const [icao, bb] of flightState._billboards) { if (icao !== flightState._trackedIcao) bb.show = true; } @@ -324,12 +323,7 @@ export function createQueries({ * @returns {Array} Sorted array of nearby aircraft descriptors */ getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) { - if ( - !center || - !flightState._billboardCollection || - !flightState._billboardCollection.show - ) - return []; + if (!center || !flightState._renderer?.fleet.visible()) return []; const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) @@ -401,11 +395,7 @@ export function createQueries({ * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} */ getDetectableObjects(options = {}) { - if ( - !flightState._billboardCollection || - !flightState._billboardCollection.show - ) - return []; + if (!flightState._renderer?.fleet.visible()) return []; const maxCount = Number.isFinite(options.maxCount) ? Math.max(1, Math.floor(options.maxCount)) : flightState._billboards.size; @@ -562,8 +552,7 @@ export function createQueries({ */ hasContact(icao24) { if ( - !flightState._billboardCollection || - !flightState._billboardCollection.show || + !flightState._renderer?.fleet.visible() || flightState._billboards.size === 0 ) return null; @@ -577,7 +566,7 @@ export function createQueries({ getAllPositions(maxCount = 500) { if ( - !flightState._billboardCollection || + !flightState._renderer?.fleet.attached() || flightState._billboards.size === 0 ) return []; @@ -624,8 +613,7 @@ export function createQueries({ */ getAnalystRecords(maxCount = 2000) { if ( - !flightState._billboardCollection || - !flightState._billboardCollection.show || + !flightState._renderer?.fleet.visible() || flightState.records.data.size === 0 ) return []; @@ -713,7 +701,7 @@ export function createQueries({ if ( !icao24 || flightState._cockpitContactMode || - !flightState._viewer || + !flightState._renderer || !flightState._trackedEntity ) return false; @@ -721,19 +709,13 @@ export function createQueries({ if (!flightState._billboards.has(id)) id = id.toLowerCase(); if ( id !== flightState._trackedIcao || - !flightState._viewer.entities?.contains?.(flightState._trackedEntity) || - flightState._viewer.trackedEntity !== flightState._trackedEntity + !flightState._renderer.tracked.isLive(flightState._trackedEntity) || + !flightState._renderer.follow.isFollowing(flightState._trackedEntity) ) return false; flightState._trackedCameraFrameStop?.(); - flightState._viewer.camera.cancelFlight(); - flightState._viewer.trackedEntity = flightState._trackedEntity; flightState._trackedCameraFrameStop = - applyTrackedCameraFrame( - flightState._viewer, - flightState._trackedEntity, - flightState._trackedEntity.viewFrom, - ) || null; + flightState._renderer.follow.refocus(flightState._trackedEntity); parts.tracking._publishTrackedSelection(id, origin); return true; }, diff --git a/src/layers/military/render/cesium.js b/src/layers/military/render/cesium.js new file mode 100644 index 000000000..ebd3c4042 --- /dev/null +++ b/src/layers/military/render/cesium.js @@ -0,0 +1,24 @@ +// The military flights layer's renderer on Cesium: the shared aircraft +// renderer (src/layers/aircraft/render/cesium.js) with the military's +// sprite-order slot and jet.glb's nose offset (the per-class specs pass +// their own offset to setPose). The military's presentation choices (amber +// #FFB800 fleet, #FFD166 tracked, the IR boost as an unlit white model) are +// the layer's logic and reach the renderer as plain data. + +import { createAircraftCesiumRenderer } from '../../aircraft/render/cesium.js'; +import { MODEL_HEADING_OFFSET_DEG } from '../policy.js'; + +/** + * Registry factory (src/app/layers/renderers.js): `(engine, options)`. + * @param {object|null} engine The Cesium globe engine (null in unit tests). + * @param {{ viewer: object, adopt?: object }} options + */ +export function createMilitaryCesiumRenderer(engine, { viewer, adopt } = {}) { + return createAircraftCesiumRenderer({ + viewer, + engine, + spriteLayer: 'military', + headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, + adopt, + }); +} diff --git a/src/layers/military/renderer.test.mjs b/src/layers/military/renderer.test.mjs new file mode 100644 index 000000000..9e002594a --- /dev/null +++ b/src/layers/military/renderer.test.mjs @@ -0,0 +1,142 @@ +// The military logic draws only through its renderer (phase 3 Task 11a): with +// a renderer that is plain data (no Cesium), the layer builds its amber +// fleet, selects a contact on a click through the engine's selection +// dispatcher, tracks and follows it, and releases it on an empty click or +// Escape. This is the seam the Navara renderer (Task 11b) plugs into. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { createApplicationMilitary } from '../../app/layers/militaryFlights.js'; +import { defaultSurface } from '../../data/surfaceServices.js'; +import * as militaryRegistry from '../../data/militaryRegistry.js'; +import { missingAircraftRendererMembers } from '../aircraft/render/contract.js'; +import { Rgba } from '../shared/color.js'; +import { MIL_ICON_COLOR, TRACKED_ICON_COLOR } from './policy.js'; +import { recordingAircraftRenderer } from '../../testSupport/aircraftRecordingRenderer.mjs'; + +const keyListeners = new Set(); +globalThis.window ||= {}; +globalThis.window.dispatchEvent ||= () => true; +globalThis.window.addEventListener ||= () => {}; +globalThis.window.removeEventListener ||= () => {}; +globalThis.CustomEvent ||= class CustomEvent extends Event { + constructor(type, init) { + super(type); + this.detail = init?.detail; + } +}; +globalThis.document ||= { + body: { classList: { contains: () => false } }, + addEventListener(type, listener) { + if (type === 'keydown') keyListeners.add(listener); + }, + removeEventListener(type, listener) { + if (type === 'keydown') keyListeners.delete(listener); + }, +}; + +/** A military snapshot (the source's record shape) of `ids`, now. */ +function militarySnapshotOf(ids, nowMs = Date.now()) { + return { + source: 'Fixture military', + complete: true, + observedAtMs: nowMs, + records: ids.map((id, index) => ({ + id, + callsign: `RCH${index}`, + latitude: 30.3, + longitude: -97.6 + index * 0.1, + onGround: false, + baroAltitudeM: 9_000, + courseDeg: 90, + speedMps: 200, + positionTimeMs: nowMs - 2_000, + contactTimeMs: nowMs - 2_000, + })), + }; +} + +const click = (engine) => { + engine.fake.input('down', { position: { x: 10, y: 10 } }); + engine.fake.input('up', { position: { x: 10, y: 10 } }); + engine.fake.input('click', { position: { x: 10, y: 10 } }); +}; + +test('the military logic draws, selects and follows only through its renderer', async () => { + const engine = createFakeEngine(); + let renderer = null; + const layer = createApplicationMilitary({ + surface: defaultSurface, + source: { + label: 'Fixture military', + getSnapshot: async () => militarySnapshotOf(['ae0001', 'ae0002']), + }, + militaryRegistry, + renderer: { + create: ({ viewer, engine: given }) => { + assert.equal(viewer, null, 'no viewer on an engine without one'); + renderer = recordingAircraftRenderer(given); + return renderer; + }, + }, + }); + layer.init(null, { engine }); + assert.deepEqual(missingAircraftRendererMembers(renderer), []); + layer.enable(); + try { + await layer.update(null); + assert.equal(renderer.sprites.size, 2, 'one sprite per contact'); + const sprite = [...renderer.sprites].find(({ id }) => id === 'ae0001'); + assert.ok(sprite.color instanceof Rgba, 'colours are engine-neutral'); + assert.ok(sprite.color.equals(MIL_ICON_COLOR), 'the fleet is amber'); + assert.equal(layer.getAllPositions().length, 2); + + // A click on a contact: the engine's pick, through the dispatcher. + engine.fake.script('pick', () => ({ id: 'ae0002' })); + engine.fake.script('drillPick', () => [{ id: 'ae0002' }]); + click(engine); + assert.equal(layer.getTrackedInfo()?.icao24, 'ae0002'); + const created = renderer.calls.filter( + ([name]) => name.startsWith('tracked') || name.startsWith('follow'), + ); + assert.deepEqual(created, [ + ['tracked.create', 'tracked-1'], + ['follow.start', 'tracked-1'], + ]); + // The tracked glyph is the lighter amber. + const trackedSpec = renderer.follow.current().spec; + assert.ok(trackedSpec.color().equals(TRACKED_ICON_COLOR)); + + // Clicking the tracked visual itself is a no-op. + engine.fake.script('pick', () => ({ id: 'tracked-1' })); + engine.fake.script('drillPick', () => [{ id: 'tracked-1' }]); + click(engine); + assert.equal(layer.getTrackedInfo()?.icao24, 'ae0002'); + + // Empty space releases the follow and removes the tracked visual. + engine.fake.script('pick', () => undefined); + engine.fake.script('drillPick', () => []); + click(engine); + assert.equal(layer.getTrackedInfo(), null); + assert.ok( + renderer.calls.some(([name]) => name === 'follow.release') && + renderer.calls.some( + ([name, id]) => name === 'tracked.remove' && id === 'tracked-1', + ), + ); + + // Escape releases too. + engine.fake.script('pick', () => ({ id: 'ae0001' })); + engine.fake.script('drillPick', () => [{ id: 'ae0001' }]); + click(engine); + assert.equal(layer.getTrackedInfo()?.icao24, 'ae0001'); + for (const listener of keyListeners) listener({ key: 'Escape' }); + assert.equal(layer.getTrackedInfo(), null); + } finally { + layer.disable(); + layer.destroy(null); + } + assert.ok(renderer.calls.some(([name]) => name === 'destroy')); + assert.equal(keyListeners.size, 0, 'the dispatcher left the keyboard'); +}); diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 9ebd03180..8ecb0eb9a 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -7,14 +7,14 @@ import { visualCenterForModel, trailAnchorForModel, } from '../../data/modelVisualAnchor.js'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { WHITE, nearFarScale } from '../shared/color.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; import { cameraPoseSignature, - horizonOccluder, screenProjectedRotation, } from '../../data/iconOrientation.js'; import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; @@ -127,11 +127,11 @@ export function createRendering({ function _normalBillboardScaleByDistance() { // Match commercial flights and preserve the established close-range 3× // default. Smaller owner-visible scaling must be proposed separately. - return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5); + return nearFarScale(1000, 3.0, 8000000, 0.5); } function _cockpitBillboardScaleByDistance() { - return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65); + return nearFarScale(1000, 1.15, 8000000, 0.65); } function _militaryBillboardScale(icao24) { @@ -204,7 +204,8 @@ export function createRendering({ function _modelRegimeActive() { if (!flightState._models3dEnabled) return false; const h = - flightState._viewer?.camera?.positionCartographic?.height ?? Infinity; + flightState._renderer?.view.camera?.positionCartographic?.height ?? + Infinity; return h < MODEL_ALT_CEIL_M; } @@ -235,28 +236,23 @@ export function createRendering({ : MODEL_PROX_KEEP_M; } - /** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into - * `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one mutable - * matrix object (see flights.js for the full rationale: sharing one scratch stacked every model on - * the last-written transform, and the per-frame tracked write made it flicker). */ + /** World model transform from a position + course heading (pitch/roll 0; ENU frame), written by + * the renderer into the model's OWN transform — never a shared scratch (see the renderer's + * setPose and flights.js for the full rationale: sharing one scratch stacked every model on the + * last-written transform, and the per-frame tracked write made it flicker). `offsetDeg` is the + * model's nose offset (its spec's headingOffsetDeg). */ function _modelMatrix( pos, headingDeg, - result = flightState._scratchModelMtx, + model, offsetDeg = MODEL_HEADING_OFFSET_DEG, ) { - flightState._scratchModelHpr.heading = Cesium.Math.toRadians( - (headingDeg || 0) + offsetDeg, - ); - flightState._scratchModelHpr.pitch = 0; - flightState._scratchModelHpr.roll = 0; - return Cesium.Transforms.headingPitchRollToFixedFrame( + return flightState._renderer.models.setPose( + model, pos, - flightState._scratchModelHpr, - Cesium.Ellipsoid.WGS84, - undefined, - result, + headingDeg, + offsetDeg, ); } @@ -285,23 +281,23 @@ export function createRendering({ const meta = flightState.records.data.get(icao24); if (!meta || !meta.onGround) return pos; const h = flightState._groundSnap.heightFor( - flightState._viewer, + flightState._renderer?.view.surfaceOwner, icao24, pos, _groundSampleExclusions, ); if (h == null) return null; - const carto = Cesium.Cartographic.fromCartesian( + const carto = geo.cartographic.fromCartesian( pos, - Cesium.Ellipsoid.WGS84, + geo.ellipsoid.WGS84, flightState._scratchGroundCarto, ); carto.height = h + _modelSpec(meta.klass).bellyM; - return Cesium.Cartesian3.fromRadians( + return geo.cartesian3.fromRadians( carto.longitude, carto.latitude, carto.height, - Cesium.Ellipsoid.WGS84, + geo.ellipsoid.WGS84, result, ); } @@ -333,7 +329,7 @@ export function createRendering({ _modelMatrix( displayPos, course, - model.modelMatrix, + model, _modelSpec(flightState.records.data.get(icao24)?.klass).headingOffsetDeg, ); if (!model.ready) { @@ -454,21 +450,21 @@ export function createRendering({ // Boost state likewise — creation options bake it in. const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass); const loadIrBoost = flightState._irBoost; + const renderer = flightState._renderer; try { const spec = _modelSpec(flightState.records.data.get(icao24)?.klass); - model = await Cesium.Model.fromGltfAsync({ + model = await renderer.models.loadFleet({ url: resolveAsset(spec.url), - asynchronous: false, minimumPixelSize: MODEL_MIN_PX, scale: spec.scale, - color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - colorBlendMode: Cesium.ColorBlendMode.MIX, + // Bounding radius per scale unit (an engine that keeps + // minimumPixelSize on the CPU reports the drawn scale with it). + radiusM: spec.nativeRadiusM, + color: flightState._irBoost ? WHITE : _modelColor(icao24), // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) - colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, - customShader: flightState._irBoost - ? flightState._IR_UNLIT_SHADER - : undefined, - id: icao24, // so scene.pick returns the icao for click-to-track + blendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, + unlit: flightState._irBoost, + id: icao24, // so a pick returns the icao for click-to-track }); } catch { // asset/decode fail — stay billboard. Only touch this lifecycle's state if still current @@ -482,11 +478,7 @@ export function createRendering({ // A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new // epoch's _modelPending/_modelGen or add to the new collection — just drop its model. if (epoch !== flightState._modelEpoch) { - try { - model.destroy(); - } catch { - /* gone */ - } + renderer.models.destroy(model); return; } flightState._modelPending.delete(icao24); @@ -498,8 +490,7 @@ export function createRendering({ const stale = (flightState._modelGen.get(icao24) || 0) !== gen || !_modelRegimeActive() || - !flightState._modelCollection || - flightState._modelCollection.isDestroyed() || + !flightState._renderer?.models.live() || !flightState.records.data.has(icao24) || icao24 === flightState._trackedIcao || flightState._models.has(icao24) || @@ -509,11 +500,7 @@ export function createRendering({ // IR boost flipped mid-load → this model baked the wrong shader/tint. flightState._irBoost !== loadIrBoost; if (stale) { - try { - model.destroy(); - } catch { - /* already gone */ - } + renderer.models.destroy(model); _cleanupModelGen(icao24); // bound the map return; } @@ -527,7 +514,7 @@ export function createRendering({ // between admission and the next fleet tick (and draw at the identity matrix, // i.e. the Earth's centre). The handoff turns it on once it has a matrix. model.show = false; - flightState._modelCollection.add(model); + flightState._renderer.models.add(model); flightState._models.set(icao24, model); flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard } @@ -547,16 +534,7 @@ export function createRendering({ ); } if (m) { - if ( - flightState._modelCollection && - !flightState._modelCollection.isDestroyed() - ) { - try { - flightState._modelCollection.remove(m); - } catch { - /* gone */ - } - } + flightState._renderer?.models.remove(m); flightState._models.delete(icao24); } // Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping @@ -586,17 +564,9 @@ export function createRendering({ icao, (flightState._modelGen.get(icao) || 0) + 1, ); - if ( - flightState._modelCollection && - !flightState._modelCollection.isDestroyed() - ) { - for (const m of flightState._models.values()) { - try { - flightState._modelCollection.remove(m); - } catch { - /* gone */ - } - } + if (flightState._renderer?.models.live()) { + for (const m of flightState._models.values()) + flightState._renderer.models.remove(m); } flightState._models.clear(); } @@ -607,16 +577,7 @@ export function createRendering({ flightState._trackedModelGen++; flightState._trackedModelLoading = false; if (flightState._trackedModel) { - if ( - flightState._modelCollection && - !flightState._modelCollection.isDestroyed() - ) { - try { - flightState._modelCollection.remove(flightState._trackedModel); - } catch { - /* gone */ - } - } + flightState._renderer?.models.remove(flightState._trackedModel); flightState._trackedModel = null; } } @@ -628,8 +589,7 @@ export function createRendering({ const active = flightState._trackedIcao && parts.tracking._trackedModelRegimeActive() && - flightState._modelCollection && - !flightState._modelCollection.isDestroyed(); + flightState._renderer?.models.live(); if (!active) { if (flightState._trackedModel) flightState._trackedModel.show = false; return; @@ -659,34 +619,28 @@ export function createRendering({ flightState.records.data.get(flightState._trackedIcao)?.klass, ); const trackedIrBoost = flightState._irBoost; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(trackedSpec.url), - asynchronous: false, - minimumPixelSize: TRACKED_MODEL_MIN_PX, - scale: trackedSpec.scale, - color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR, - colorBlendMode: Cesium.ColorBlendMode.MIX, - // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) - colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, - customShader: flightState._irBoost - ? flightState._IR_UNLIT_SHADER - : undefined, - // Pick id (H1): without it, clicking the very plane being tracked read as - // EMPTY SPACE (scene.pick → primitive with no id) → an unintended - // deselect. With the icao, the click handler recognizes it as ours. - id: flightState._trackedIcao, - }) + const renderer = flightState._renderer; + renderer.models + .loadTracked({ + url: resolveAsset(trackedSpec.url), + minimumPixelSize: TRACKED_MODEL_MIN_PX, + scale: trackedSpec.scale, + radiusM: trackedSpec.nativeRadiusM, + color: flightState._irBoost ? WHITE : TRACKED_ICON_COLOR, + // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) + blendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, + unlit: flightState._irBoost, + // Pick id (H1): without it, clicking the very plane being tracked read as + // EMPTY SPACE (a pick with no id) → an unintended deselect. With the + // icao, the click handler recognizes it as ours. + id: flightState._trackedIcao, + }) .then((m) => { if ( gen !== flightState._trackedModelGen || - !flightState._modelCollection || - flightState._modelCollection.isDestroyed() + !flightState._renderer?.models.live() ) { - try { - m.destroy(); - } catch { - /* gone */ - } + renderer.models.destroy(m); return; } // Class reclassified OR boost flipped mid-load (no release ran — @@ -697,11 +651,7 @@ export function createRendering({ ) !== trackedKey || flightState._irBoost !== trackedIrBoost ) { - try { - m.destroy(); - } catch { - /* gone */ - } + renderer.models.destroy(m); flightState._trackedModelLoading = false; return; } @@ -726,7 +676,7 @@ export function createRendering({ _modelMatrix( displayPos, parts.motion._trackedDisplayCourse(), - m.modelMatrix, + m, trackedSpec.headingOffsetDeg, ); } @@ -738,7 +688,7 @@ export function createRendering({ flightState._trackedModelFailIcao = null; flightState._trackedModelFailCount = 0; flightState._trackedModelRetryAtMs = 0; - flightState._modelCollection.add(m); + flightState._renderer.models.add(m); flightState._planeModelLoaded = true; }) .catch((err) => { @@ -767,19 +717,17 @@ export function createRendering({ _modelMatrix( displayPos, parts.motion._trackedDisplayCourse(), - flightState._trackedModel.modelMatrix, + flightState._trackedModel, spec.headingOffsetDeg, ); if (!flightState._trackedModel.ready) return; + const view = flightState._renderer.view; flightState._trackedModel.scale = trackedModelScaleForPixelCap({ baseScale: spec.scale, nativeRadiusM: spec.nativeRadiusM, - rangeM: Cesium.Cartesian3.distance( - flightState._viewer.camera.positionWC, - displayPos, - ), - viewportHeightPx: flightState._viewer.scene.canvas.clientHeight, - fovyRad: flightState._viewer.camera.frustum.fovy, + rangeM: geo.cartesian3.distance(view.camera.positionWC, displayPos), + viewportHeightPx: view.viewportHeightPx(), + fovyRad: view.camera.frustum.fovy, maximumPixelSize: TRACKED_MODEL_MAX_PX, }); flightState._trackedModel.show = true; @@ -797,14 +745,10 @@ export function createRendering({ */ function _fleetTick() { - if ( - !flightState._viewer || - !flightState._billboardCollection || - !flightState._billboardCollection.show - ) - return; - const scene = flightState._viewer.scene; - const camera = flightState._viewer.camera; + if (!flightState._renderer?.fleet.visible()) return; + const view = flightState._renderer.view; + const scene = view.scene; + const camera = view.camera; const nowMs = focusNowMs(Date.now()); // (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz @@ -833,7 +777,7 @@ export function createRendering({ flightState._lastRotPassMs = nowMs; } - const occluder = horizonOccluder(camera); + const occluder = view.horizon(); const focusTarget = getFocusTarget(); // 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling. @@ -859,11 +803,7 @@ export function createRendering({ const addDistSq = addM * addM; const keepM = _modelKeepDistM(); const keepDistSq = keepM * keepM; - const cull = camera.frustum.computeCullingVolume( - camPos, - camera.directionWC, - camera.upWC, - ); + const inFrustum = view.frustumTester(); const cand = []; for (const [icao, bb] of flightState._billboards) { if (icao === flightState._trackedIcao) continue; @@ -874,18 +814,9 @@ export function createRendering({ // Ground planes compete for model slots like everyone else (owner decision // 2026-07-03, mirror of flights.js — no air/ground distinction; grounded // placement is handled by the one-shot ground snap in _modelDisplayPosition). - const d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position); + const d2 = geo.cartesian3.distanceSquared(camPos, bb.position); if (d2 > keepDistSq) continue; // beyond keep radius → never eligible - Cesium.Cartesian3.clone( - bb.position, - flightState._scratchModelBS.center, - ); - cand.push([ - icao, - d2, - cull.computeVisibility(flightState._scratchModelBS) !== - Cesium.Intersect.OUTSIDE, - ]); + cand.push([icao, d2, inFrustum(bb.position)]); } cand.sort((a, b) => a[1] - b[1]); if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) { @@ -913,7 +844,7 @@ export function createRendering({ const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos); // Gate the write — assigning Billboard.position dirties the whole // collection's vertex buffer, so skip sub-meter moves. - if (dr && Cesium.Cartesian3.distanceSquared(dr, bb.position) > 1.0) { + if (dr && geo.cartesian3.distanceSquared(dr, bb.position) > 1.0) { bb.position = dr; } @@ -943,7 +874,7 @@ export function createRendering({ // One sprite-owned write site composes freshness × focus × limb haze and // base class/ground scale × limb taper. The locked NearFarScalar remains // untouched and multiplicative; there is no cull or zero-alpha path. - const cameraDistanceM = Cesium.Cartesian3.distance( + const cameraDistanceM = geo.cartesian3.distance( camera.positionWC, bb.position, ); @@ -1047,9 +978,7 @@ export function createRendering({ // IR boost must survive the per-tick treatment write; boosted // models also skip the recession fade (mirror of flights.js — // billboards keep their normal fade, hot MODELS stay full-strength). - baseColor: flightState._irBoost - ? Cesium.Color.WHITE - : _modelColor(icao24), + baseColor: flightState._irBoost ? WHITE : _modelColor(icao24), alpha: flightState._irBoost ? 1 : treatment.alpha, }); }, diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 819286a03..235b38caf 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -14,7 +14,7 @@ import { MIL_ICON_COLOR, } from './policy.js'; -/** Apply military record changes to Cesium resources and existing follow operations. */ +/** Apply military record changes to the renderer's fleet and existing follow operations. */ export function createMilitarySnapshotRenderer({ flightState, records, @@ -153,7 +153,7 @@ export function createMilitarySnapshotRenderer({ // it ahead until the next fleet tick snapped it back. const displayed = motion._deadReckon(icao24, geo.cartesian3.create()) || position; - const bb = flightState._billboardCollection.add({ + const bb = flightState._renderer.fleet.add({ position: displayed, image: aircraftIcon(rendering._iconKind(icao24, meta.klass)), width: isTracked ? 24 : 20, @@ -235,20 +235,27 @@ export function createMilitarySnapshotRenderer({ // this layer's own billboards/models from the probe. { const viewerCarto = - flightState._viewer?.camera?.positionCartographic || null; - sampleMeshFloorCells(flightState._viewer?.scene, _floorWarmPoints, { - excludeObjects: [ - ...flightState._billboards.values(), - ...flightState._models.values(), - flightState._trackedModel, - ].filter(Boolean), - viewerLat: viewerCarto - ? geo.math.toDegrees(viewerCarto.latitude) - : undefined, - viewerLon: viewerCarto - ? geo.math.toDegrees(viewerCarto.longitude) - : undefined, - }); + flightState._renderer?.view.camera?.positionCartographic || null; + sampleMeshFloorCells( + flightState._renderer?.view.surfaceScene, + _floorWarmPoints, + { + excludeObjects: + flightState._renderer?.surfaceExclusions({ + sprites: flightState._billboards.values(), + models: [ + ...flightState._models.values(), + flightState._trackedModel, + ], + }) ?? [], + viewerLat: viewerCarto + ? geo.math.toDegrees(viewerCarto.latitude) + : undefined, + viewerLon: viewerCarto + ? geo.math.toDegrees(viewerCarto.longitude) + : undefined, + }, + ); } // Remove aircraft only after MISSING_POLL_LIMIT consecutive absences. @@ -285,7 +292,7 @@ export function createMilitarySnapshotRenderer({ tracking._clearTracking(false, { evicted: true }); } - flightState._billboardCollection.remove(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); diff --git a/src/layers/military/state.js b/src/layers/military/state.js index 273e5a283..ceee9f833 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -1,6 +1,5 @@ import { MilitaryFlightRecords } from './records.js'; import { createMilitaryFeed } from './ingestion.js'; -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; export function createFlightState({ source, services }) { @@ -33,11 +32,14 @@ export function createFlightState({ source, services }) { flightState._scratchGroundPos = geo.cartesian3.create(); - /** @type {Cesium.PrimitiveCollection|null} */ + /** @type {import('../aircraft/render/contract.js').AircraftRenderer|null} + * The engine's aircraft renderer for this lifecycle (render/cesium.js on + * Cesium): the fleet sprites, the models and their collection, the + * tracked visual, the trail, the follow camera and the view reads. */ - flightState._modelCollection = null; + flightState._renderer = null; - /** @type {Map} icao24 → model */ + /** @type {Map} icao24 → model handle */ flightState._models = new Map(); @@ -76,19 +78,10 @@ export function createFlightState({ source, services }) { flightState._planeModelLoaded = false; - /** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ + /** @type {object|null} retained preload that keeps the glTF cache warm */ flightState._preloadModel = null; - flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); - - flightState._scratchModelMtx = geo.matrix4.create(); - - flightState._scratchModelBS = new Cesium.BoundingSphere( - geo.cartesian3.create(), - 1.0, - ); - // frustum-visibility test /** Last limb taper per billboard, retained across class/ground/cockpit repaints. */ flightState._billboardLimbScale = new WeakMap(); @@ -97,11 +90,8 @@ export function createFlightState({ source, services }) { // Module-level state // --------------------------------------------------------------------------- - /** @type {Cesium.BillboardCollection|null} Single GPU-batched collection for all aircraft */ - - flightState._billboardCollection = null; - - /** @type {Map} ICAO hex -> billboard primitive */ + /** @type {Map} ICAO hex -> fleet sprite handle (the + * renderer's single GPU-batched fleet) */ flightState._billboards = new Map(); @@ -109,7 +99,7 @@ export function createFlightState({ source, services }) { flightState._detectionObjects = new Map(); - /** @type {Map>} ICAO hex -> recent position samples for dead reckoning (timeMs: whole ms since the epoch, the app clock's unit) */ + /** @type {Map>} ICAO hex -> recent position samples for dead reckoning (timeMs: whole ms since the epoch, the app clock's unit) */ flightState._positionHistory = new Map(); @@ -122,7 +112,7 @@ export function createFlightState({ source, services }) { flightState._trackingIntentGeneration = 0; - /** @type {Cesium.Entity|null} Entity created for the tracked aircraft (camera follows this) */ + /** @type {object|null} The renderer's tracked handle (the follow target) */ flightState._trackedEntity = null; @@ -130,7 +120,7 @@ export function createFlightState({ source, services }) { flightState._trackedCameraFrameStop = null; - /** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft — NOT a graphic on + /** @type {object|null} Standalone 3D model for the tracked aircraft — NOT a graphic on * _trackedEntity, so viewer.trackedEntity's follow-camera always has a ready bounding sphere * (a model graphic reports PENDING until loaded, which freezes the centering on 3D-toggle). */ @@ -140,13 +130,10 @@ export function createFlightState({ source, services }) { flightState._trackedModelLoading = false; - /** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for selecting aircraft */ + /** @type {(() => void)|null} Unregisters this layer from the engine's + * selection dispatcher (clicks and Escape). */ - flightState._clickHandler = null; - - /** @type {Cesium.Viewer|null} Cached viewer reference */ - - flightState._viewer = null; + flightState._selectionRemove = null; /** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */ @@ -168,7 +155,7 @@ export function createFlightState({ source, services }) { flightState._trail = null; - /** @type {Cesium.Entity|null} Per-frame head segment: last body point → live icon */ + /** @type {object|null} Per-frame head segment: last body point → live icon */ flightState._trailHeadEntity = null; @@ -176,7 +163,7 @@ export function createFlightState({ source, services }) { flightState._trailHeadSeq = 0; - /** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */ + /** @type {object[]} Chronological tracked-aircraft fixes (oldest first) */ flightState._trailPositions = []; @@ -200,17 +187,17 @@ export function createFlightState({ source, services }) { flightState._lastTrackedRotation = 0; - /** @type {Cesium.Event.RemoveCallback|null} preRender listener disposer */ + /** @type {(() => void)|null} preRender listener disposer */ flightState._preRenderRemove = null; flightState._trackedModelPreUpdateRemove = null; - /** @type {Cesium.Event.RemoveCallback|null} camera.moveEnd listener disposer (arrival rotation pass) */ + /** @type {(() => void)|null} camera.moveEnd listener disposer (arrival rotation pass) */ flightState._moveEndRemove = null; - /** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ + /** @type {(() => void)|null} follow-change listener disposer (cross-layer untrack) */ flightState._trackedEntityChangedRemove = null; @@ -233,7 +220,7 @@ export function createFlightState({ source, services }) { flightState._trackedPosHolder = geo.cartesian3.create(); // -- Per-frame dead-reckoning cache for the tracked entity -- - /** @type {Cesium.Cartesian3|null} Cached DR position for the current render frame */ + /** @type {object|null} Cached DR position for the current render frame */ flightState._cachedDRPosition = null; @@ -298,7 +285,7 @@ export function createFlightState({ source, services }) { flightState._drReconcileIcao = null; - /** @type {Cesium.Cartesian3} Scratch for the tracked model's rendered translation. */ + /** @type {object} Scratch for the tracked model's rendered translation. */ flightState._trackedVisualPos = geo.cartesian3.create(); @@ -328,24 +315,18 @@ export function createFlightState({ source, services }) { flightState._trackedModelRetryAtMs = 0; /** IR hot-target mode — mirror of flights.js: under the luminance-mapped - * NVG/FLIR post-styles every model renders flat white (hottest); the dominant - * amber/tracked tint restores on style exit. */ + * NVG/FLIR post-styles every model renders flat white (hottest) and UNLIT + * (`unlit` in the model spec: material-stage tint still sun-shades, so + * side-on planes read near-black under a high sun); the dominant + * amber/tracked tint restores on style exit. The shader is baked at + * creation (assigning it to a ready Cesium model is a silent no-op), so the + * boost flips by release-and-reload. */ flightState._irBoost = false; - /** Boosted models render UNLIT — mirror of flights.js (owner cockpit-FLIR - * field rounds): material-stage tint still sun-shades, so side-on planes - * read near-black under a high sun; UNLIT emits flat white regardless. - * customShader on a READY model is a silent no-op (field-verified), so the - * boost flips by release-and-reload — creation-time options carry it. */ - - flightState._IR_UNLIT_SHADER = new Cesium.CustomShader({ - lightingModel: Cesium.LightingModel.UNLIT, - }); - flightState._irReloadQueue = null; - /** @type {Cesium.Cartographic} Scratch for _trailFloorPosition (per-frame safe). */ + /** @type {object} Scratch (Cartographic) for _trailFloorPosition (per-frame safe). */ flightState._scratchTrailCarto = geo.cartographic.create(); return flightState; diff --git a/src/layers/military/testing.js b/src/layers/military/testing.js index 15ab14050..dbb54e0b9 100644 --- a/src/layers/military/testing.js +++ b/src/layers/military/testing.js @@ -36,9 +36,11 @@ export function createTesting({ models = [], modelCollection = null, }) { - flightState._viewer = viewer; - flightState._modelCollection = modelCollection; - flightState._billboardCollection = billboardCollection; + // The production renderer over the test's viewer and (fake) collections. + flightState._renderer = services.renderer.create({ + viewer, + adopt: { billboardCollection, modelCollection }, + }); flightState._billboards = new Map([[icao24, billboard]]); flightState._cullPositions.clear(); flightState._models.clear(); diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 391342e3e..66cc31eb8 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -1,4 +1,5 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { nearFarScale } from '../shared/color.js'; import { appClock } from '../../time/clock.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; @@ -9,11 +10,6 @@ import { } from '../../data/iconOrientation.js'; import { trailHeadStart } from '../../data/modelVisualAnchor.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; -import { - bindTrackingClickGesture, - isTrackingSelectionGesture, - isTrackingClickGesture, -} from '../../data/trackingClickGesture.js'; import { TRACKED_MODEL_MAX_LOAD_FAILS, TRACKED_MODEL_RETRY_BACKOFF_MS, @@ -44,11 +40,9 @@ export function createTracking({ const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; const { floorAltitudeM, cachedGroundFloor, resolveGroundFloorCellsBounded } = services.groundFloor; - const { createTrail } = services.trails; const { ensureGeoidReady, geoidHeight } = services.geoid; const { clearFocusTarget } = services.focus; - const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { selectionDispatcherFor } = services.selection; function _emitAwarenessEvent(type, detail) { if ( @@ -73,7 +67,7 @@ export function createTracking({ parts.queries._toCleanText(info.callsign) || parts.queries._toCleanText(info.registration) || icao24, - position: Cesium.Cartesian3.clone(bb.position), + position: geo.cartesian3.clone(bb.position), origin, }); selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); @@ -138,10 +132,8 @@ export function createTracking({ /** Refresh the AIR-only Cockpit near band independently from model admission. */ function _refreshCockpitNearContacts() { - if ( - !flightState._cockpitContactMode || - !flightState._viewer?.camera?.positionWC - ) { + const camera = flightState._renderer?.view.camera; + if (!flightState._cockpitContactMode || !camera?.positionWC) { if (flightState._cockpitNearContacts.size) flightState._cockpitNearContacts = new Set(); return; @@ -152,10 +144,7 @@ export function createTracking({ if (icao24 === flightState._trackedIcao || !bb?.position) continue; distancesSquared.push([ icao24, - Cesium.Cartesian3.distanceSquared( - flightState._viewer.camera.positionWC, - bb.position, - ), + geo.cartesian3.distanceSquared(camera.positionWC, bb.position), ]); } const next = nextCockpitNearContacts( @@ -185,7 +174,7 @@ export function createTracking({ // The collection stays visible in cockpit. Near AIR contacts retain their // aircraft silhouette until a ready model takes over; far contacts are pips. // Never bulk-destroy here: that stalls the renderer exactly as cockpit begins. - if (flightState._modelCollection) flightState._modelCollection.show = true; + flightState._renderer?.models.setVisible(true); flightState._trail?.setVisible(!next); if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next; for (const [icao24, bb] of flightState._billboards) @@ -264,14 +253,16 @@ export function createTracking({ /** Re-image the tracked entity's billboard from the current class/conversion. */ function _syncTrackedBillboardImage() { - if (!flightState._trackedIcao || !flightState._trackedEntity?.billboard) - return; - flightState._trackedEntity.billboard.image = aircraftIcon( - parts.rendering._iconKind( - flightState._trackedIcao, - flightState.records.data.get(flightState._trackedIcao)?.klass, + if (!flightState._trackedIcao || !flightState._trackedEntity) return; + flightState._renderer.tracked.setImage( + flightState._trackedEntity, + aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState.records.data.get(flightState._trackedIcao)?.klass, + ), + TRACKED_ICON_PX, ), - TRACKED_ICON_PX, ); } @@ -304,7 +295,7 @@ export function createTracking({ _syncTrackedBillboardImage(); _updateTrackedLabelModel(id); } - flightState._viewer?.scene?.requestRender?.(); + flightState._renderer?.view.requestRender(); return flightState._billboards.has(id) || id === flightState._trackedIcao; } @@ -396,7 +387,7 @@ export function createTracking({ return false; } flightState._trackedZoomLatched = trackedModelZoomActive( - flightState._viewer?.camera?.positionCartographic?.height, + flightState._renderer?.view.camera?.positionCartographic?.height, flightState._trackedZoomLatched, ); return flightState._trackedZoomLatched; @@ -405,13 +396,13 @@ export function createTracking({ /** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */ function _syncTracked2dRotation() { - if (!flightState._trackedIcao || !flightState._viewer) return; + if (!flightState._trackedIcao || !flightState._renderer) return; const pos = parts.motion._trackedDisplayCached() || flightState._billboards.get(flightState._trackedIcao)?.position; if (!pos) return; const projected = screenProjectedRotation( - flightState._viewer.scene, + flightState._renderer.view.scene, pos, parts.motion._trackedDisplayCourse(), flightState._lastTrackedRotation, @@ -432,31 +423,31 @@ export function createTracking({ * invariant), so every position entering the TRAIL subsystem (seed, append, * per-frame head) goes through this instead — the visible taxi history sits * on the surface without touching the billboard/model machinery. - * @param {Cesium.Cartesian3} position - Owned position (mutated/replaced freely). - * @returns {Cesium.Cartesian3} The same or a lifted position. + * @param {object} position - Owned position (Cartesian3; mutated/replaced freely). + * @returns {object} The same or a lifted position. */ function _trailFloorPosition(position) { - const carto = Cesium.Cartographic.fromCartesian( + const carto = geo.cartographic.fromCartesian( position, - Cesium.Ellipsoid.WGS84, + geo.ellipsoid.WGS84, flightState._scratchTrailCarto, ); if (!carto) return position; - const latDeg = Cesium.Math.toDegrees(carto.latitude); - const lonDeg = Cesium.Math.toDegrees(carto.longitude); + const latDeg = geo.math.toDegrees(carto.latitude); + const lonDeg = geo.math.toDegrees(carto.longitude); const floored = floorAltitudeM( carto.height, cachedGroundFloor(latDeg, lonDeg), ); if (floored == null || floored === carto.height) return position; - return Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, floored); + return geo.cartesian3.fromDegrees(lonDeg, latDeg, floored); } /** * Append one fix to the tracked aircraft's trail accumulation and refresh * the rendered trail. Caller passes an owned (cloned) Cartesian3. - * @param {Cesium.Cartesian3} position - New fix position, appended at the head. + * @param {object} position - New fix position (Cartesian3), appended at the head. */ function _appendTrailFix(position) { @@ -505,12 +496,12 @@ export function createTracking({ // fixes carry the pre-datum default height — the trail copy sits on // the surface instead (pure lift; airborne fixes pass through). flightState._trailPositions.push( - _trailFloorPosition(Cesium.Cartesian3.clone(fix.position)), + _trailFloorPosition(geo.cartesian3.clone(fix.position)), ); } } - if (!flightState._trail && flightState._viewer) { - flightState._trail = createTrail(flightState._viewer, { + if (!flightState._trail && flightState._renderer) { + flightState._trail = flightState._renderer.trails.create({ color: TRAIL_COLOR, width: 2.5, }); @@ -520,64 +511,58 @@ export function createTracking({ // every frame via a CallbackProperty (Cesium updates entity-polyline positions cheaply, // unlike the trail primitive which fully rebuilds on setPositions). Keeps the head glued // to the 12 Hz icon instead of lagging ~1 s behind it. - if (!flightState._trailHeadEntity && flightState._viewer) { - flightState._trailHeadEntity = flightState._viewer.entities.add({ + if (!flightState._trailHeadEntity && flightState._renderer) { + flightState._trailHeadEntity = flightState._renderer.trails.createHead({ // 'gev-trail' namespace (round 6): claimed by trailRenderer's pick // owner so a click on the head segment never reads as empty space. id: `gev-trail:mil-head-${++flightState._trailHeadSeq}`, show: !flightState._cockpitContactMode, - polyline: { - positions: new Cesium.CallbackProperty(() => { - // Need ≥2 accumulated points: the body draws all-but-newest, so the head must - // start at the last DISPLAYED body point (index n−2). With a single fix that - // point would be the sole raw fix — ~now, AHEAD of the delayed icon — so the - // segment would draw IN FRONT of the plane. Likewise during warm-up the icon - // predates all real history, so there is no valid body point behind it. - if ( - !flightState._trackedIcao || - flightState._trailPositions.length < 2 || - parts.motion._isTrackWarmingUp() - ) - return []; - const head = - parts.motion._trackedTrailCached() || - parts.motion._trackedDisplayPosition(flightState._trackedIcao); - if (!head) return []; - // body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a - // backward/reversing segment. - const start = - flightState._trailPositions[ - flightState._trailPositions.length - 2 - ]; - // Floor the head too (round 2): a grounded tracked contact's DR - // display height is the pre-datum default — without this the last - // segment dives underground while taxiing. - const end = _trailFloorPosition(Cesium.Cartesian3.clone(head)); - // On a contact that has not moved this segment runs from inside the - // model out to its own anchor — a line through the fuselage. The END - // never gives, so a moving trail still terminates on the tail; the - // START is what slides, from nothing on a parked contact out to the - // whole segment once it has cleared its own envelope. Measured against - // the FLOORED endpoint, which is the one actually drawn. - // See trailHeadStart. - const from = trailHeadStart( - start, - end, - parts.motion._trackedModelCenterWorld(), - parts.motion._trackedModelEnvelopeM(), - flightState._scratchTrailHead, - ); - if (!from) return []; - return [from, end]; - }, false), - width: 2.5, - material: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.9), - // Round 4: the head must never vanish into the mesh either (dimmed - // when occluded so depth still reads). - depthFailMaterial: - Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.45), - arcType: Cesium.ArcType.GEODESIC, // round 8: consistent with the trail body (no chords) + positions: () => { + // Need ≥2 accumulated points: the body draws all-but-newest, so the head must + // start at the last DISPLAYED body point (index n−2). With a single fix that + // point would be the sole raw fix — ~now, AHEAD of the delayed icon — so the + // segment would draw IN FRONT of the plane. Likewise during warm-up the icon + // predates all real history, so there is no valid body point behind it. + if ( + !flightState._trackedIcao || + flightState._trailPositions.length < 2 || + parts.motion._isTrackWarmingUp() + ) + return []; + const head = + parts.motion._trackedTrailCached() || + parts.motion._trackedDisplayPosition(flightState._trackedIcao); + if (!head) return []; + // body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a + // backward/reversing segment. + const start = + flightState._trailPositions[flightState._trailPositions.length - 2]; + // Floor the head too (round 2): a grounded tracked contact's DR + // display height is the pre-datum default — without this the last + // segment dives underground while taxiing. + const end = _trailFloorPosition(geo.cartesian3.clone(head)); + // On a contact that has not moved this segment runs from inside the + // model out to its own anchor — a line through the fuselage. The END + // never gives, so a moving trail still terminates on the tail; the + // START is what slides, from nothing on a parked contact out to the + // whole segment once it has cleared its own envelope. Measured against + // the FLOORED endpoint, which is the one actually drawn. + // See trailHeadStart. + const from = trailHeadStart( + start, + end, + parts.motion._trackedModelCenterWorld(), + parts.motion._trackedModelEnvelopeM(), + flightState._scratchTrailHead, + ); + if (!from) return []; + return [from, end]; }, + width: 2.5, + // Round 4: the head must never vanish into the mesh either (dimmed + // when occluded so depth still reads); round 8: geodesic, consistent + // with the trail body (no chords). + color: TRAIL_COLOR, }); } _refreshTrailDisplay(); @@ -673,7 +658,7 @@ export function createTracking({ // sentinel (an arbitrary placeholder, never a reported altitude). if (altM == null) altM = lastAltM != null ? lastAltM : 50; lastAltM = altM; - older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM)); + older.push(geo.cartesian3.fromDegrees(lon, lat, altM)); } // Stride-thin the backfill so it plus the live fine segment fit the cap. @@ -705,17 +690,8 @@ export function createTracking({ flightState._trailBackfillToken += 1; flightState._trailPositions = []; if (flightState._trail) flightState._trail.clear(); - if ( - flightState._trailHeadEntity && - flightState._viewer && - !flightState._viewer.isDestroyed() - ) { - try { - flightState._viewer.entities.remove(flightState._trailHeadEntity); - } catch { - /* already gone */ - } - } + if (flightState._trailHeadEntity) + flightState._renderer?.trails.removeHead(flightState._trailHeadEntity); flightState._trailHeadEntity = null; } @@ -791,11 +767,11 @@ export function createTracking({ // follow-camera, so tear down our state but DON'T clear viewer.trackedEntity (they own it now). // Releasing trackedEntity does NOT move the camera: Cesium resets the lookAt transform in // place, so the view stays where the follow left it and the user can immediately orbit/zoom. - if (flightState._viewer && !skipViewerUntrack) { - flightState._viewer.trackedEntity = undefined; + if (flightState._renderer && !skipViewerUntrack) { + flightState._renderer.follow.release(); } if (flightState._trackedEntity) { - flightState._viewer.entities.remove(flightState._trackedEntity); + flightState._renderer.tracked.remove(flightState._trackedEntity); flightState._trackedEntity = null; } parts.rendering._releaseTrackedModel(); @@ -826,7 +802,7 @@ export function createTracking({ ) return false; if ( - !flightState._billboardCollection?.show || + !flightState._renderer?.fleet.visible() || !flightState._billboards.has(pending.id) ) return false; @@ -874,12 +850,6 @@ export function createTracking({ const getTrackedPosition = () => parts.motion._trackedDisplayPosition(icao24) || bb.position; - // Dead-reckoning position property evaluated every render frame. - // isConstant = false so CesiumJS re-evaluates each frame. - const positionProperty = new Cesium.CallbackProperty(() => { - return getTrackedPosition(); - }, false); - // World-space orientation for the 3D model: nose along the course heading (ENU, pitch/roll 0). // Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the // TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the @@ -896,65 +866,56 @@ export function createTracking({ // The tracked entity is a PURE BILLBOARD — no label or model graphic. The tracked plane's 3D // model is the standalone primitive driven by _updateTrackedModel(); keeping it off the entity is // what makes the follow-camera's bounding sphere always ready (see _trackedModel). - flightState._trackedEntity = flightState._viewer.entities.add({ - position: positionProperty, - // Keep Cesium's EntityView in the same ENU frame as the close-range - // camera guard; AUTO may otherwise alternate with a velocity frame. - trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, - billboard: { - image: aircraftIcon( - parts.rendering._iconKind( - flightState._trackedIcao, - flightState.records.data.get(flightState._trackedIcao)?.klass, - ), - TRACKED_ICON_PX, + // The renderer keeps Cesium's EntityView in the same ENU frame as the + // close-range camera guard (AUTO may otherwise alternate with a velocity frame). + flightState._trackedEntity = flightState._renderer.tracked.create({ + // Dead-reckoning position, evaluated every render frame. + position: getTrackedPosition, + image: aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState.records.data.get(flightState._trackedIcao)?.klass, ), - width: 28, - height: 28, - scale: - BILLBOARD_SCALE * - (CLASS_SCALE_2D[ - flightState.records.data.get(flightState._trackedIcao)?.klass - ] || 1), - // Solid amber when the billboard is the visual (zoomed out, 3D off, or model still loading); - // transparent once the STANDALONE tracked model is actually up (ready + shown). - color: new Cesium.CallbackProperty( - () => - parts.rendering._modelOwnsVisual(flightState._trackedIcao) - ? AMBER_TRANSPARENT - : TRACKED_ICON_COLOR, - false, - ), - sizeInMeters: false, - scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5), - alignedAxis: Cesium.Cartesian3.ZERO, - // The tracked target must never vanish into tile geometry — tracking a - // taxiing plane at street level would otherwise bury the amber icon inside - // the runway skin exactly like the fleet ground icons (_groundDepthDistance); - // its shared-host tracked card is top-composited separately. - disableDepthTestDistance: Number.POSITIVE_INFINITY, - // Screen-projected rotation, evaluated per frame: exact in tracked-orbit - // mode where camera.heading lives in the entity's reference frame. - rotation: new Cesium.CallbackProperty(() => { - const tracked = flightState.records.data.get( - flightState._trackedIcao, - ); - const pos = getTrackedPosition(); - if (!tracked || !pos || !flightState._viewer) - return flightState._lastTrackedRotation; - const projected = screenProjectedRotation( - flightState._viewer.scene, - pos, - parts.motion._trackedDisplayCourse(), - flightState._lastTrackedRotation, - ); - const rot = stabilizeScreenRotation( - flightState._lastTrackedRotation, - projected, - ); - if (rot !== null) flightState._lastTrackedRotation = rot; + TRACKED_ICON_PX, + ), + width: 28, + height: 28, + scale: + BILLBOARD_SCALE * + (CLASS_SCALE_2D[ + flightState.records.data.get(flightState._trackedIcao)?.klass + ] || 1), + // Solid amber when the billboard is the visual (zoomed out, 3D off, or model still loading); + // transparent once the STANDALONE tracked model is actually up (ready + shown). + color: () => + parts.rendering._modelOwnsVisual(flightState._trackedIcao) + ? AMBER_TRANSPARENT + : TRACKED_ICON_COLOR, + scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), + // The tracked target must never vanish into tile geometry — tracking a + // taxiing plane at street level would otherwise bury the amber icon inside + // the runway skin exactly like the fleet ground icons (_groundDepthDistance); + // its shared-host tracked card is top-composited separately. + disableDepthTestDistance: Number.POSITIVE_INFINITY, + // Screen-projected rotation, evaluated per frame: exact in tracked-orbit + // mode where camera.heading lives in the entity's reference frame. + rotation: () => { + const tracked = flightState.records.data.get(flightState._trackedIcao); + const pos = getTrackedPosition(); + if (!tracked || !pos || !flightState._renderer) return flightState._lastTrackedRotation; - }, false), + const projected = screenProjectedRotation( + flightState._renderer.view.scene, + pos, + parts.motion._trackedDisplayCourse(), + flightState._lastTrackedRotation, + ); + const rot = stabilizeScreenRotation( + flightState._lastTrackedRotation, + projected, + ); + if (rot !== null) flightState._lastTrackedRotation = rot; + return flightState._lastTrackedRotation; }, }); flightState._trackedEntity.gevSelectionOrigin = origin; @@ -970,11 +931,11 @@ export function createTracking({ // initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.) const altM = info?.altitudeFt ? info.altitudeFt * 0.3048 : 1500; const followRange = Math.min(Math.max(altM * 1.1 + 2500, 3000), 30000); - flightState._trackedEntity.viewFrom = new Cesium.Cartesian3( - 0, - -followRange * 0.8, - followRange * 0.55, - ); + flightState._renderer.tracked.setViewFrom(flightState._trackedEntity, { + x: 0, + y: -followRange * 0.8, + z: followRange * 0.55, + }); // Cancel any in-progress camera flight first — otherwise Cesium won't apply the tracked // entity's viewFrom on the first frame, so voice-initiated tracking (which often fires @@ -989,15 +950,11 @@ export function createTracking({ flightState._trackedEntity.gevVisualPosition = parts.motion._trackedVisualCached; refreshTrackedReadout(flightState._trackedEntity); - flightState._viewer.camera.cancelFlight(); - // Camera follows the tracked entity - flightState._viewer.trackedEntity = flightState._trackedEntity; - flightState._trackedCameraFrameStop = - applyTrackedCameraFrame( - flightState._viewer, - flightState._trackedEntity, - flightState._trackedEntity.viewFrom, - ) || null; + // 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, + ); // Track-history trail (PRD F2): seed from local history + async backfill. // Ground traffic draws NO trail — if the plane takes off while tracked, the @@ -1016,125 +973,112 @@ export function createTracking({ } /** - * Global keydown handler: pressing Escape clears the active aircraft tracking. - * @param {KeyboardEvent} e - The keyboard event + * Escape (through the selection dispatcher) clears the active aircraft tracking. */ - function _onKeyDown(e) { - if (e.key === 'Escape' && flightState._trackedIcao) { + function _onEscape() { + if (flightState._trackedIcao) { _cancelPendingTrackingRestore(); _clearTracking(false, { origin: 'user' }); } } /** - * Install a left-click handler on the scene canvas for aircraft selection. - * Clicking a military billboard starts tracking that aircraft; clicking empty - * space or a non-military object deselects. Also attaches the Escape key - * listener via {@link _onKeyDown}. Idempotent -- no-ops if already installed. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance + * A click the selection dispatcher hands this layer: one of its aircraft + * (a fleet sprite or model, or the tracked plane's own visual). The + * dispatcher has already applied the gesture rules (a drag selects + * nothing; isTrackingSelectionGesture) and the pointer claim. + * @param {string} id - The picked id this layer owns. */ - function _installClickHandler(viewer) { - if (flightState._clickHandler) return; // already installed + function _onSelect(id) { + // Cockpit mode owns the camera and keeps the current aircraft as its + // first-person reference: a globe click selects nothing here. + if (document.body.classList.contains('cockpit-mode')) return; + // Clicking the tracked plane itself — its tracked visual or its + // standalone 3D model (pick id = its icao, H1) — is a no-op, not a + // deselect and not a re-track. + if ( + id === flightState._trackedEntity?.id || + (flightState._trackedIcao && id === flightState._trackedIcao) + ) + return; + if (flightState._billboards.has(id)) { + _cancelPendingTrackingRestore(); + _trackFlight(id, { origin: 'user' }); + } + } + + /** + * Empty space (or a pick no layer owns). The dispatcher calls this only + * for a clean, short click (isTrackingClickGesture): a slow stationary + * press may select, but cannot release existing tracking. A pick another + * layer owns (commercial flight, satellite, vessel, station, CCTV + * camera…) never reaches here. + */ + + function _onEmptyClick() { + if (document.body.classList.contains('cockpit-mode')) return; + if (flightState._trackedIcao) { + _cancelPendingTrackingRestore(); + _clearTracking(false, { origin: 'user' }); + } + } + + /** + * Join the engine's selection dispatcher (clicks with picks, Escape) and + * watch the follow camera for another layer taking it. + * Idempotent -- no-ops if already joined. + */ + + function _installClickHandler() { + const renderer = flightState._renderer; + if (!renderer) return; // Cross-layer untrack (mirror of flights): if another layer grabs the follow-camera, drop our - // tracking without touching viewer.trackedEntity (the new owner controls it). + // tracking without touching the follow target (the new owner controls it). if (!flightState._trackedEntityChangedRemove) { - flightState._trackedEntityChangedRemove = - viewer.trackedEntityChanged.addEventListener(() => { + flightState._trackedEntityChangedRemove = renderer.follow.onChanged( + () => { + const current = flightState._renderer?.follow.current(); if ( flightState._trackedIcao && - flightState._viewer && - flightState._viewer.trackedEntity && - flightState._viewer.trackedEntity !== flightState._trackedEntity + current && + current !== flightState._trackedEntity ) { _clearTracking(true, { - origin: - flightState._viewer.trackedEntity?.gevSelectionOrigin || - 'programmatic', + origin: current?.gevSelectionOrigin || 'programmatic', }); } - }); + }, + ); } - flightState._clickHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); - bindTrackingClickGesture(flightState._clickHandler, (click, gesture) => { - // Camera drags never select or deselect, even if they finish over a plane. - // Duration alone is allowed through so a stationary long press can still - // select/switch contacts; the destructive empty-space branch below applies - // the full travel + duration click classifier. - if (!isTrackingSelectionGesture(gesture)) return; - // Cockpit mode owns the camera and keeps the current aircraft as its - // first-person reference. Empty globe clicks are inert until the user - // exits with C, Escape, or the dedicated button. - if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); - - if (picked) { - // Clicking the tracked entity itself -- ignore (don't deselect) - if (picked.id === flightState._trackedEntity) return; - - // Clicking the plane we're ALREADY tracking (its standalone 3D model or - // any pick carrying its icao) — same no-op as the 2D tracked-entity click - // above. H1: the model used to have no pick id, so this fell through to - // "empty space" and deselected the very plane being tracked. - if (flightState._trackedIcao) { - const rawPick = - typeof picked.id === 'string' ? picked.id : picked.primitive?.id; - if ( - picked.primitive === flightState._trackedModel || - rawPick === flightState._trackedIcao - ) - return; - } - - // BillboardCollection picks: CesiumJS may expose the billboard as - // picked.primitive (with .id = icao24) or directly as picked.id - const billboard = picked.primitive; - if ( - billboard && - billboard.id && - flightState._billboards.has(billboard.id) - ) { - _cancelPendingTrackingRestore(); - _trackFlight(billboard.id, { origin: 'user' }); - return; - } - // Fallback: some CesiumJS versions surface the id string at picked.id - if ( - picked.id && - typeof picked.id === 'string' && - flightState._billboards.has(picked.id) - ) { - _cancelPendingTrackingRestore(); - _trackFlight(picked.id, { origin: 'user' }); - return; - } - } - - // A pick that belongs to a sibling layer (commercial flight, satellite, - // vessel, station, CCTV camera…) is not "empty space" — leave tracking - // alone and let that layer handle it. resolvePickId String()-coerces the - // heterogeneous pick ids (numeric NORAD ids, AIS record objects) so the - // registry predicates can recognize them (H2). - if (picked) { - const pickedId = resolvePickId(picked); - if (pickedId && isOwnedByOtherLayer('military', pickedId)) return; - } - - // Clicked empty space -- deselect only for a clean, short click. A slow - // stationary press may select above, but cannot release existing tracking. - if (!isTrackingClickGesture(gesture)) return; - if (flightState._trackedIcao) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } + if (flightState._selectionRemove) return; // already joined + const dispatcher = selectionDispatcherFor(renderer.engine); + if (!dispatcher) return; + flightState._selectionRemove = dispatcher.register('military', { + // The fleet's icaos, and the tracked visual's own id: clicking it is + // this layer's no-op, never empty space. + owns: (id) => + flightState._billboards.has(id) || + (!!flightState._trackedEntity && + id === String(flightState._trackedEntity.id)), + onSelect: _onSelect, + onEmptyClick: _onEmptyClick, + onEscape: _onEscape, }); + } - document.addEventListener('keydown', _onKeyDown); + /** Leave the selection dispatcher and stop watching the follow camera. */ + + function _removeClickHandler() { + flightState._selectionRemove?.(); + flightState._selectionRemove = null; + if (flightState._trackedEntityChangedRemove) { + flightState._trackedEntityChangedRemove(); + flightState._trackedEntityChangedRemove = null; + } } return { _emitAwarenessEvent, @@ -1165,7 +1109,10 @@ export function createTracking({ _applyPendingTrackingRestore, _cancelPendingTrackingRestore, _trackFlight, - _onKeyDown, + _onEscape, + _onSelect, + _onEmptyClick, _installClickHandler, + _removeClickHandler, }; } diff --git a/src/testSupport/aircraftRecordingRenderer.mjs b/src/testSupport/aircraftRecordingRenderer.mjs new file mode 100644 index 000000000..b4710cff2 --- /dev/null +++ b/src/testSupport/aircraftRecordingRenderer.mjs @@ -0,0 +1,122 @@ +// An aircraft renderer of plain records (src/layers/aircraft/render/ +// contract.js), for testing an aircraft layer's logic alone: no Cesium, no +// Navara. `calls` logs the tracked/follow calls; `sprites` holds the fleet. + +import * as geo from '../geo/index.js'; + +/** A renderer of plain records, for the logic alone. */ +export function recordingAircraftRenderer(engine) { + const calls = []; + const sprites = new Set(); + const camera = { + positionWC: geo.cartesian3.fromDegrees(-97.7, 30.2, 400_000), + positionCartographic: geo.cartographic.fromDegrees(-97.7, 30.2, 400_000), + rightWC: geo.cartesian3.create(1, 0, 0), + upWC: geo.cartesian3.create(0, 0, 1), + directionWC: geo.cartesian3.create(0, -1, 0), + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + frustum: { fovy: 1 }, + }; + const scene = { + camera, + canvas: { clientHeight: 768 }, + frameState: { frameNumber: 1 }, + }; + let trackedSeq = 0; + let following; + const renderer = { + kind: 'record', + engine, + calls, + sprites, + view: { + camera, + scene, + surfaceOwner: engine, + surfaceScene: engine, + frameNumber: () => 1, + viewportHeightPx: () => 768, + frustumTester: () => () => true, + horizon: () => ({ isPointVisible: () => true }), + worldToWindow: () => ({ x: 0, y: 0 }), + requestRender: () => calls.push(['requestRender']), + on: () => () => {}, + holdFrames() {}, + closeFrameLatch() {}, + releaseFrames() {}, + }, + fleet: { + shown: false, + attach: () => calls.push(['attach']), + detach: () => calls.push(['detach']), + attached: () => true, + visible: () => renderer.fleet.shown, + setVisible(visible) { + renderer.fleet.shown = visible; + }, + add(spec) { + const sprite = { ...spec }; + sprites.add(sprite); + return sprite; + }, + remove: (sprite) => sprites.delete(sprite), + restoreOrder() {}, + restoreOrderOnEnable() {}, + }, + models: { + live: () => true, + setVisible() {}, + loadFleet: () => Promise.reject(new Error('no models here')), + loadTracked: () => Promise.reject(new Error('no models here')), + preload: () => Promise.reject(new Error('no models here')), + add() {}, + remove() {}, + destroy() {}, + setPose() {}, + }, + tracked: { + create(spec) { + const handle = { id: `tracked-${++trackedSeq}`, spec }; + calls.push(['tracked.create', handle.id]); + return handle; + }, + remove: (handle) => calls.push(['tracked.remove', handle.id]), + isLive: () => true, + setImage() {}, + setViewFrom(handle, offset) { + handle.viewFrom = offset; + }, + billboardSize: () => null, + }, + follow: { + start(handle) { + following = handle; + calls.push(['follow.start', handle.id]); + return () => calls.push(['follow.frameStop']); + }, + refocus: (handle) => renderer.follow.start(handle), + release() { + following = undefined; + calls.push(['follow.release']); + }, + isFollowing: (handle) => following === handle, + current: () => following, + onChanged: () => () => {}, + }, + trails: { + create: () => ({ + setPositions() {}, + setVisible() {}, + clear() {}, + destroy() {}, + }), + createHead: ({ id }) => ({ id, show: true }), + removeHead() {}, + }, + surfaceExclusions: () => [], + destroy: () => calls.push(['destroy']), + }; + return renderer; +}