From 7f13ef4a873bda97139ccb6e584fc7ad51f1221c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 17 Oct 2026 01:04:25 +0400 Subject: [PATCH] refactor(flights): Cesium rendering behind a renderer interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 10a. The flights logic no longer imports Cesium: every Cesium object it built moved one for one into the shared aircraft renderer (src/layers/aircraft/render/cesium.js; contract in render/contract.js), with the flights' parameters in src/layers/flights/render/cesium.js, registered for 'cesium' in the renderer registry and created at init(viewer, {engine}). - Fleet: BillboardCollection + sprite-order slot; sprites stay Billboard handles (field vocabulary documented for the Navara renderer). - Models: fleet/tracked/preload glTF loads (asynchronous:false, MIX tint, IR boost as `unlit` → the UNLIT CustomShader), the model collection, setPose (same HPR frame with airplane.glb's 180° offset). - Tracked: the ENU billboard Entity with its position/colour/rotation callbacks, viewFrom, followed through viewer.trackedEntity + the one exact tracked frame (kept as is; engine.camera.follow on Cesium would also end the follow on any camera command, a behaviour change). - Trail: the entity trail (data/trailRenderer, unchanged: its Primitive predecessor failed below the mesh) and the live head segment. - View: camera/scene reads, frame number, frustum and horizon tests, projection, render requests, frame events, evidence render-loop hold. - 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. - Colours are engine-neutral Rgba (src/layers/shared/color.js), bit-equal to the Cesium.Color constants they replace; NearFarScalar → nearFarScale. - Cesium math in the logic → src/geo. - check-import-directions: cesium only in render/cesium*.js and three/Navara only in render/navara*.js for flights, aircraft and shared; a layer's Navara renderer only through navaraMode's import(). Source-text tests updated with the moved code, same meaning: - readLayerSource reads a layer's render/*.js and, for the aircraft layers, aircraft/render/*.js (GLB-load, tracked-entity, CallbackProperty checks). - modelScale.test: _normalBillboardScaleByDistance reads nearFarScale(...). - spriteOrder.test: flights' enable calls fleet.restoreOrderOnEnable(); the renderer wires it to restoreSpriteOrderOnEnable('flights', viewer). - trackingClickGesture.test: the flights gesture rules are the dispatcher's (selection gesture before select, click classifier before the empty-space clear); flights answers the empty click with a user-origin clear. - militaryAwareness.test: flights' hasContact guard reads fleet.visible(). - ownership.test: the services fixture names selection and renderer. New tests: color, the Cesium aircraft renderer (contract, collections, pose, loads, frustum/horizon, tracked entity, follow, trail head, adopted collections), the flights logic over a plain-data renderer and a fake engine (fleet, click-select, no-op on the tracked visual, empty-click release), the boundary rule. W1 replay vs 7c7422e2: counts, known objects, screen points (0 px), tracking poses (0 m), requests and shots (same sha256) identical. Main chunk 2,890,982 B (+6.2 KB, +0.21 %: the renderer indirection). --- scripts/check-import-directions.mjs | 38 ++ scripts/format-scope.json | 7 + scripts/package-boundaries.json | 15 +- src/app/layers/flights.js | 21 +- src/app/layers/renderers.js | 13 +- src/app/layers/renderers.test.mjs | 11 + src/data/militaryAwareness.test.mjs | 3 +- src/data/modelScale.test.mjs | 2 +- src/data/spriteOrder.test.mjs | 14 +- src/data/trackingClickGesture.test.mjs | 16 +- src/layers/aircraft/render/cesium.js | 420 +++++++++++++++++ src/layers/aircraft/render/cesium.test.mjs | 307 ++++++++++++ src/layers/aircraft/render/contract.js | 159 +++++++ src/layers/flights/controller.js | 5 +- src/layers/flights/enrichment.js | 25 +- src/layers/flights/evidence.js | 39 +- src/layers/flights/index.js | 2 +- src/layers/flights/lifecycle.js | 148 +++--- src/layers/flights/motion.js | 29 +- src/layers/flights/ownership.test.mjs | 4 +- src/layers/flights/policy.js | 26 +- src/layers/flights/queries.js | 49 +- src/layers/flights/render/cesium.js | 23 + src/layers/flights/renderer.test.mjs | 249 ++++++++++ src/layers/flights/rendering.js | 240 +++------- src/layers/flights/snapshotRenderer.js | 42 +- src/layers/flights/state.js | 67 +-- src/layers/flights/testing.js | 12 +- src/layers/flights/tracking.js | 514 +++++++++------------ src/layers/shared/color.js | 88 ++++ src/layers/shared/color.test.mjs | 77 +++ src/testSupport/readLayerSource.mjs | 20 +- src/tooling/importDirections.test.mjs | 37 ++ 33 files changed, 1974 insertions(+), 748 deletions(-) create mode 100644 src/layers/aircraft/render/cesium.js create mode 100644 src/layers/aircraft/render/cesium.test.mjs create mode 100644 src/layers/aircraft/render/contract.js create mode 100644 src/layers/flights/render/cesium.js create mode 100644 src/layers/flights/renderer.test.mjs create mode 100644 src/layers/shared/color.js create mode 100644 src/layers/shared/color.test.mjs diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 945381142..394e9f70b 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -30,6 +30,18 @@ const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file); const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/; // Navara renders through three.js and its postprocessing peer. const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/; +// Phase 3: the layers moved onto layer renderers (the *a tasks) keep each +// engine in their renderers: cesium only in render/cesium*.js, three and +// Navara only in render/navara*.js. The list grows with each moved layer. +const RENDERED_LAYER_DIRS = [ + 'src/layers/flights/', + 'src/layers/aircraft/', + 'src/layers/shared/', +]; +const renderedLayer = (file) => + RENDERED_LAYER_DIRS.some((dir) => file.startsWith(dir)); +const cesiumLayerRenderer = (file) => /\/render\/cesium[^/]*\.js$/.test(file); +const navaraLayerRenderer = (file) => /\/render\/navara[^/]*\.js$/.test(file); const renderer = (file) => /^src\/(?:ui|app|standalone|overlays)\//.test(file) || /^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test( @@ -123,6 +135,18 @@ export function checkImportDirections(root) { target = path.resolve(root, exported); } else { record.external.push(specifier); + if (renderedLayer(file) && !tests(file)) { + if (cesiumPackage.test(specifier) && !cesiumLayerRenderer(file)) + report( + file, + `cesium only in the layer's render/cesium*.js: ${specifier}`, + ); + if (navaraPackage.test(specifier) && !navaraLayerRenderer(file)) + report( + file, + `three and Navara only in the layer's render/navara*.js: ${specifier}`, + ); + } if ( file.startsWith('src/') && (builtins.has(specifier) || specifier.startsWith('node:')) @@ -157,6 +181,20 @@ export function checkImportDirections(root) { file, `The Navara engine loads only through a dynamic import(): ${to}`, ); + // Like the engine, a layer's Navara renderer reaches the app only + // through the Navara mode's import() (src/app/navaraMode.js). + if ( + navaraLayerRenderer(to) && + !navaraLayerRenderer(file) && + !( + file === 'src/app/navaraMode.js' && + analysis.dynamicImports.includes(specifier) + ) + ) + report( + file, + `A Navara layer renderer loads only through the Navara mode's dynamic import(): ${to}`, + ); if (file.startsWith('src/') && to.startsWith('server/')) report(file, `Browser imports server: ${to}`); if ( diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b256e624d..2e5f14dcf 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -321,6 +321,13 @@ "src/layers/flights/testing.js", "src/layers/flights/evidence.js", "src/layers/flights/ingestion.js", + "src/layers/flights/render/cesium.js", + "src/layers/flights/renderer.test.mjs", + "src/layers/aircraft/render/cesium.test.mjs", + "src/layers/aircraft/render/cesium.js", + "src/layers/aircraft/render/contract.js", + "src/layers/shared/color.test.mjs", + "src/layers/shared/color.js", "src/layers/flights/queries.js", "src/layers/flights/index.js", "src/layers/flights/enrichment.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9f1a2bc3b..d7c18c43f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -896,7 +896,8 @@ "src/geo/developerError.js", "src/time/clock.js", "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js" + "src/globe/cesium/engineFromViewer.js", + "src/layers/shared/color.js" ], "external": ["cesium"] }, @@ -2587,7 +2588,11 @@ "src/globe/surfaceDetail.js", "src/globe/navara/followGestures.js", "src/app/layers/directory.js", - "src/app/layers/renderers.js" + "src/app/layers/renderers.js", + "src/layers/aircraft/render/cesium.js", + "src/layers/flights/render/cesium.js", + "src/data/selectionDispatcher.js", + "src/layers/shared/color.js" ], "external": [ "hls.js", @@ -3158,7 +3163,11 @@ "src/globe/follow.js", "src/globe/cesium/follow.js", "src/app/layers/directory.js", - "src/app/layers/renderers.js" + "src/app/layers/renderers.js", + "src/layers/aircraft/render/cesium.js", + "src/data/selectionDispatcher.js", + "src/layers/shared/color.js", + "src/layers/flights/render/cesium.js" ], "external": [ "hls.js", diff --git a/src/app/layers/flights.js b/src/app/layers/flights.js index 87c25efc4..c622fdffa 100644 --- a/src/app/layers/flights.js +++ b/src/app/layers/flights.js @@ -1,10 +1,10 @@ import { createCivilFlightLayer } from '../../layers/flights/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 { createFlightsCesiumRenderer } from '../../layers/flights/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 createApplicationFlights({ 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('flights', engine, { viewer, adopt }) + : createFlightsCesiumRenderer(null, { viewer, adopt }), + }, }) { const { groundFloor, meshFloor, groundSnap } = surface; return createCivilFlightLayer({ @@ -27,10 +34,12 @@ export function createApplicationFlights({ resolveAsset, services: { picking, - sprites, - trails, + selection, + // The engine's flights 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 1fd0ca688..67cda281a 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -24,6 +24,7 @@ // provider (`{ engine, unavailable() }` is the part it reads). import { APPLICATION_LAYER_DIRECTORY } from './directory.js'; +import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js'; const ENGINE_KINDS = new Set(['cesium', 'navara']); @@ -81,11 +82,19 @@ export const rendererFor = (layerId, engine, options) => export const layerAvailableOn = (layerId, engineKind) => defaultRegistry.layerAvailableOn(layerId, engineKind); -// Every application layer draws itself on Cesium today. +/** Cesium renderers extracted from their layers (render/cesium.js). */ +export const CESIUM_LAYER_RENDERERS = Object.freeze({ + flights: createFlightsCesiumRenderer, +}); + +// The other application layers still draw themselves on Cesium. registerLayerRenderers( 'cesium', Object.fromEntries( - APPLICATION_LAYER_DIRECTORY.map(({ id }) => [id, CESIUM_BUILTIN]), + APPLICATION_LAYER_DIRECTORY.map(({ id }) => [ + id, + CESIUM_LAYER_RENDERERS[id] ?? CESIUM_BUILTIN, + ]), ), ); diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index 3722aecee..10f8ebc19 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -2,6 +2,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { CESIUM_BUILTIN, + CESIUM_LAYER_RENDERERS, + rendererFor, createLayerAvailability, createRendererRegistry, layerAvailableOn, @@ -54,6 +56,15 @@ 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']); + const viewer = { scene: {}, camera: {} }; + const renderer = rendererFor('flights', { kind: 'cesium' }, { viewer }); + assert.equal(renderer.kind, 'cesium'); + assert.equal(renderer.viewer, viewer); + assert.equal(rendererFor('satellites', { kind: 'cesium' }), null); +}); + test('the layer availability API: engine, isAvailable, available, unavailable', () => { const registry = createRendererRegistry(); registry.register('navara', { flights: () => ({}) }); diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 7a9cc1dcb..7abe91c1c 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -1366,8 +1366,9 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol // disable() hides the collection but keeps the records, so a map lookup // alone would report a preserved subject as FRESH from hidden stale data. for (const [name, source, guard] of [ + // Flights asks its renderer whether the fleet is shown (phase 3). ['flights', readLayerSource(new URL('./flights.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;/], ['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;/], ['aisLiveVessels', readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)), diff --git a/src/data/modelScale.test.mjs b/src/data/modelScale.test.mjs index d91914805..47acbedc9 100644 --- a/src/data/modelScale.test.mjs +++ b/src/data/modelScale.test.mjs @@ -371,7 +371,7 @@ function normalBillboardScaleByDistance(sourceFile) { const src = readLayerSource(path.join(ROOT, sourceFile)); const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/); assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`); - const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/); + const scalar = fn[1].match(/(?:NearFarScalar|nearFarScale)\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/); assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`); return scalar.slice(1).map(Number); } diff --git a/src/data/spriteOrder.test.mjs b/src/data/spriteOrder.test.mjs index e5ea59d6c..878593413 100644 --- a/src/data/spriteOrder.test.mjs +++ b/src/data/spriteOrder.test.mjs @@ -1,6 +1,7 @@ import { createRendering as createFirmsRendering } from '../layers/firms/rendering.js'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { registerSpriteCollection, restoreSpriteOrder, @@ -110,7 +111,18 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r assert.deepEqual(calls, [viewer, viewer, viewer]); const firmsLayer = createFirmsHeatmapLayer({ id: 'firms', name: 'FIRMS' }); - assert.match(flightsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('flights', viewer\)/); + // Flights draws through its renderer (phase 3): enable asks the fleet to + // restore its order, which the Cesium aircraft renderer wires to the shared + // restorer under the flights' sprite slot. + assert.match(flightsLayer.enable.toString(), /fleet\.restoreOrderOnEnable\(\)/); + assert.match( + readFileSync(new URL('../layers/aircraft/render/cesium.js', import.meta.url), 'utf8'), + /restoreOrderOnEnable: \(\) => restoreSpriteOrderOnEnable\(spriteLayer, viewer\)/, + ); + assert.match( + readFileSync(new URL('../layers/flights/render/cesium.js', import.meta.url), 'utf8'), + /spriteLayer: 'flights'/, + ); assert.match(aisLiveVesselsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('ais', activeViewer\)/); assert.match(firmsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('firms', viewer\)/); assert.match( diff --git a/src/data/trackingClickGesture.test.mjs b/src/data/trackingClickGesture.test.mjs index a9a918fef..b71b9ba9f 100644 --- a/src/data/trackingClickGesture.test.mjs +++ b/src/data/trackingClickGesture.test.mjs @@ -114,10 +114,18 @@ 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)), ]; - for (const source of sources) { - assert.match(source, /isTrackingSelectionGesture\(gesture\)[\s\S]+scene\.pick/); - assert.match(source, /isTrackingClickGesture\(gesture\)[\s\S]+(?:parts\.\w+\.)?_clearTracking\([^)]*\{ origin: 'user' \}\)/); - } + // 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' \}\)/); + 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' \}\)/); 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 new file mode 100644 index 000000000..9bd4bf3bb --- /dev/null +++ b/src/layers/aircraft/render/cesium.js @@ -0,0 +1,420 @@ +// The aircraft renderer on Cesium (contract: ./contract.js): the Cesium +// objects the aircraft layers used to build themselves, moved here one for +// one — the fleet BillboardCollection and its sprite-order slot, the model +// PrimitiveCollection and its glTF loads, the tracked Entity (billboard with +// per-frame CallbackProperties, followed through viewer.trackedEntity and +// the one exact tracked frame), the trail entity and its live head +// segment, and the camera/frame reads. Nothing here decides anything: the +// layer's logic decides, this draws. + +import * as Cesium from 'cesium'; +import { createTrail } from '../../../data/trailRenderer.js'; +import { + registerSpriteCollection, + restoreSpriteOrder, + restoreSpriteOrderOnEnable, +} from '../../../data/spriteOrder.js'; +import { horizonOccluder } from '../../../data/iconOrientation.js'; +import { engineFromViewer } from '../../../globe/cesium/engineFromViewer.js'; +import { applyTrackedCameraFrame } from '../../../globe/cesium/follow.js'; + +// The model shader of the IR boost (owner playtest 2026-08-16): boosted +// models render UNLIT so a side-lit airframe does not go black in FLIR/NVG. +// Assigning a customShader to a ready model is a silent no-op, so the layer +// reloads boosted models and the shader is set at creation. One stateless +// instance, shared. +let unlitShader = null; +const irUnlitShader = () => + (unlitShader ??= new Cesium.CustomShader({ + lightingModel: Cesium.LightingModel.UNLIT, + })); + +// A colour from the layer (an Rgba or a Cesium.Color) as a Cesium.Color, for +// the entity properties that keep what they are given. Constants convert +// once. +const cesiumColors = new WeakMap(); +function cesiumColor(color) { + if (color instanceof Cesium.Color) return color; + let converted = cesiumColors.get(color); + if (!converted) { + converted = new Cesium.Color( + color.red, + color.green, + color.blue, + color.alpha, + ); + if (Object.isFrozen(color)) cesiumColors.set(color, converted); + } + return converted; +} + +const cesiumNearFar = (scalar) => + scalar instanceof Cesium.NearFarScalar + ? scalar + : new Cesium.NearFarScalar( + scalar.near, + scalar.nearValue, + scalar.far, + scalar.farValue, + ); + +/** + * @param {object} options + * @param {Cesium.Viewer} options.viewer + * @param {object} [options.engine] The globe engine (default: the viewer's). + * @param {string} options.spriteLayer The fleet's slot in data/spriteOrder. + * @param {number} [options.headingOffsetDeg=0] Model nose vs Cesium heading 0. + * @param {{ billboardCollection?: object, modelCollection?: object }} [options.adopt] + * Test seam: drive existing (fake) collections instead of creating them. + * @returns {import('./contract.js').AircraftRenderer} + */ +export function createAircraftCesiumRenderer({ + viewer, + engine = null, + spriteLayer, + headingOffsetDeg = 0, + adopt = null, +}) { + let billboards = adopt?.billboardCollection ?? null; + let models = adopt?.modelCollection ?? null; + const scratchHpr = new Cesium.HeadingPitchRoll(0, 0, 0); + const scratchSphere = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 1.0); + // The frame's culling volume and one tester over it (no closure per call). + let cullingVolume = null; + const inFrustum = (position) => { + Cesium.Cartesian3.clone(position, scratchSphere.center); + return ( + cullingVolume.computeVisibility(scratchSphere) !== + Cesium.Intersect.OUTSIDE + ); + }; + const scratchSize = { + width: undefined, + height: undefined, + scale: undefined, + scaleByDistance: undefined, + }; + + const scene = () => viewer?.scene; + const camera = () => viewer?.camera; + + const view = { + get camera() { + return camera(); + }, + get scene() { + return scene(); + }, + get surfaceOwner() { + return viewer; + }, + get surfaceScene() { + return viewer?.scene; + }, + frameNumber: () => viewer?.scene?.frameState?.frameNumber ?? -1, + viewportHeightPx: () => viewer.scene.canvas.clientHeight, + // The tester stays valid until the next frustumTester() call. + frustumTester() { + const cam = viewer.camera; + cullingVolume = cam.frustum.computeCullingVolume( + cam.positionWC, + cam.directionWC, + cam.upWC, + ); + return inFrustum; + }, + horizon: () => horizonOccluder(viewer.camera), + worldToWindow: (position, result) => + Cesium.SceneTransforms.worldToWindowCoordinates( + viewer.scene, + position, + result, + ), + requestRender: () => viewer?.scene?.requestRender?.(), + on(event, listener) { + const source = + event === 'moveEnd' + ? viewer?.camera?.moveEnd + : event === 'preRender' + ? viewer?.scene?.preRender + : event === 'preUpdate' + ? viewer?.scene?.preUpdate + : null; + return source ? source.addEventListener(listener) : null; + }, + isDestroyed: () => !viewer || viewer.isDestroyed?.() === true, + // Evidence capture (qa-focus-evidence): Cesium's own render loop off, + // then its pending-loop latch closed so manual renders start at once. + holdFrames() { + viewer.useDefaultRenderLoop = false; + }, + closeFrameLatch() { + if (viewer?._cesiumWidget) + viewer._cesiumWidget._renderLoopRunning = false; + }, + releaseFrames() { + if (viewer) viewer.useDefaultRenderLoop = true; + }, + }; + + const fleet = { + attach() { + billboards = new Cesium.BillboardCollection(); + viewer.scene.primitives.add(billboards); + registerSpriteCollection(spriteLayer, billboards); + models = new Cesium.PrimitiveCollection(); + viewer.scene.primitives.add(models); + }, + // `owner`: the viewer the layer's destroy() was given (the same one). + detach(owner) { + const host = owner ?? viewer; + if (billboards) { + host.scene.primitives.remove(billboards); + billboards = null; + } + if (models) { + host.scene.primitives.remove(models); // removing destroys it + its models + models = null; + } + }, + attached: () => !!billboards, + visible: () => !!billboards && billboards.show, + setVisible(visible) { + if (billboards) billboards.show = visible; + }, + add: (spec) => billboards.add(spec), + remove: (sprite) => billboards.remove(sprite), + restoreOrder: () => restoreSpriteOrder(viewer), + restoreOrderOnEnable: () => restoreSpriteOrderOnEnable(spriteLayer, viewer), + }; + + const modelSet = { + live: () => !!models && !models.isDestroyed(), + setVisible(visible) { + if (models) models.show = visible; + }, + loadFleet: ({ + url, + minimumPixelSize, + scale, + color, + blendAmount, + unlit, + id, + }) => + Cesium.Model.fromGltfAsync({ + url, + asynchronous: false, + minimumPixelSize, + scale, + color, + colorBlendMode: Cesium.ColorBlendMode.MIX, + colorBlendAmount: blendAmount, + customShader: unlit ? irUnlitShader() : undefined, + id, // so scene.pick returns the icao for click-to-track + }), + loadTracked: ({ + url, + minimumPixelSize, + scale, + color, + blendAmount, + unlit, + id, + }) => + Cesium.Model.fromGltfAsync({ + url, + asynchronous: false, + minimumPixelSize, + scale, + color, + colorBlendMode: Cesium.ColorBlendMode.MIX, + colorBlendAmount: blendAmount, + customShader: unlit ? irUnlitShader() : undefined, + // Pick id (H1): clicking the tracked plane's own model must read as + // that plane, not as empty space. + id, + }), + // Warms Cesium's glTF cache (the retained instance is never drawn). + preload: (url) => + Cesium.Model.fromGltfAsync({ + url, + asynchronous: false, + }), + add: (model) => models.add(model), + remove(model) { + if (models && !models.isDestroyed()) { + try { + models.remove(model); + } catch { + /* gone */ + } + } + }, + destroy(model) { + try { + model.destroy(); + } catch { + /* gone */ + } + }, + /** + * World transform from a position and a course (pitch/roll 0, ENU frame), + * 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). + */ + setPose(model, position, headingDeg) { + scratchHpr.heading = Cesium.Math.toRadians( + (headingDeg || 0) + headingOffsetDeg, + ); + scratchHpr.pitch = 0; + scratchHpr.roll = 0; + return Cesium.Transforms.headingPitchRollToFixedFrame( + position, + scratchHpr, + Cesium.Ellipsoid.WGS84, + undefined, + model.modelMatrix, + ); + }, + }; + + const tracked = { + /** + * The tracked aircraft as an Entity: a pure billboard (no model or label + * graphic, so viewer.trackedEntity always has a ready bounding sphere), + * in the ENU tracking frame the close-range camera guard also uses. + * Cesium generates its id: an explicit one would throw when a re-init + * briefly overlaps collections. + */ + create(spec) { + return viewer.entities.add({ + position: new Cesium.CallbackProperty(() => spec.position(), false), + trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, + billboard: { + image: spec.image, + width: spec.width, + height: spec.height, + scale: spec.scale, + color: new Cesium.CallbackProperty( + () => cesiumColor(spec.color()), + false, + ), + sizeInMeters: false, + scaleByDistance: cesiumNearFar(spec.scaleByDistance), + alignedAxis: Cesium.Cartesian3.ZERO, + disableDepthTestDistance: spec.disableDepthTestDistance, + rotation: new Cesium.CallbackProperty(() => spec.rotation(), false), + }, + }); + }, + remove(handle) { + viewer.entities.remove(handle); + }, + isLive: (handle) => !!viewer?.entities?.contains?.(handle), + setImage(handle, image) { + if (handle?.billboard) handle.billboard.image = image; + }, + setViewFrom(handle, offset) { + handle.viewFrom = new Cesium.Cartesian3(offset.x, offset.y, offset.z); + }, + /** + * The tracked billboard's drawn size inputs, at the viewer's time. The + * result is reused (read it before the next call). + */ + billboardSize(handle) { + const billboard = handle?.billboard; + if (!billboard) return null; + const time = viewer.clock.currentTime; + scratchSize.width = billboard.width?.getValue(time); + scratchSize.height = billboard.height?.getValue(time); + scratchSize.scale = billboard.scale?.getValue(time); + scratchSize.scaleByDistance = billboard.scaleByDistance?.getValue(time); + return scratchSize; + }, + }; + + const follow = { + // Cancel any flight first: Cesium applies the entity's viewFrom on the + // first frame only from an idle camera. + start(handle) { + viewer.camera.cancelFlight(); + viewer.trackedEntity = handle; + return applyTrackedCameraFrame(viewer, handle, handle.viewFrom) || null; + }, + refocus(handle) { + viewer.camera.cancelFlight(); + viewer.trackedEntity = handle; + return applyTrackedCameraFrame(viewer, handle, handle.viewFrom) || null; + }, + // Releasing trackedEntity leaves the camera where the follow put it. + release() { + if (viewer) viewer.trackedEntity = undefined; + }, + isFollowing: (handle) => !!viewer && viewer.trackedEntity === handle, + current: () => viewer?.trackedEntity, + onChanged: (listener) => + viewer.trackedEntityChanged.addEventListener(listener), + }; + + const trails = { + create: ({ color, width }) => createTrail(viewer, { color, width }), + /** + * The live head segment: last fix → the dead-reckoned aircraft, updated + * per frame through a CallbackProperty (entity polylines update cheaply, + * the trail body rebuilds on setPositions). The 'gev-trail' id namespace + * is claimed by trailRenderer's pick owner. + */ + createHead({ id, show, positions, color, width }) { + if (!viewer) return null; + return viewer.entities.add({ + id, + show, + polyline: { + positions: new Cesium.CallbackProperty(() => positions(), false), + width, + material: Cesium.Color.fromCssColorString(color).withAlpha(0.9), + // The head never vanishes into the mesh (dimmed when occluded). + depthFailMaterial: + Cesium.Color.fromCssColorString(color).withAlpha(0.45), + arcType: Cesium.ArcType.GEODESIC, + }, + }); + }, + removeHead(head) { + if (head && viewer && !viewer.isDestroyed()) { + try { + viewer.entities.remove(head); + } catch { + /* already gone */ + } + } + }, + }; + + return { + kind: 'cesium', + get engine() { + return engine ?? engineFromViewer(viewer); + }, + viewer, + view, + fleet, + models: modelSet, + tracked, + follow, + trails, + /** + * Everything a vertical surface probe must not hit: Cesium excludes + * picked objects, and the layer's billboards and models are its own. + */ + surfaceExclusions: ({ sprites = [], models: modelHandles = [] }) => + [...sprites, ...modelHandles].filter(Boolean), + destroy() { + billboards = null; + models = null; + }, + }; +} diff --git a/src/layers/aircraft/render/cesium.test.mjs b/src/layers/aircraft/render/cesium.test.mjs new file mode 100644 index 000000000..8de3654ae --- /dev/null +++ b/src/layers/aircraft/render/cesium.test.mjs @@ -0,0 +1,307 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import * as geo from '../../../geo/index.js'; +import { createAircraftCesiumRenderer } from './cesium.js'; +import { missingAircraftRendererMembers } from './contract.js'; +import { CYAN, nearFarScale, rgbaFromCss } from '../../shared/color.js'; + +/** A viewer with what the renderer touches: primitives, entities, camera. */ +function stubViewer() { + const added = []; + const removed = []; + const camera = { + positionWC: Cesium.Cartesian3.fromDegrees(-73.78, 40.0, 50_000), + directionWC: Cesium.Cartesian3.normalize( + Cesium.Cartesian3.negate( + Cesium.Cartesian3.fromDegrees(-73.78, 40.0, 50_000), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ), + upWC: Cesium.Cartesian3.UNIT_Z, + frustum: new Cesium.PerspectiveFrustum({ fov: 1, aspectRatio: 1.5 }), + moveEnd: new Cesium.Event(), + cancelled: 0, + cancelFlight() { + this.cancelled += 1; + }, + }; + camera.frustum.near = 1; + camera.frustum.far = 1e8; + const viewer = { + camera, + scene: { + primitives: { + add: (p) => added.push(p) && p, + remove: (p) => removed.push(p), + }, + preRender: new Cesium.Event(), + preUpdate: new Cesium.Event(), + canvas: { clientHeight: 800 }, + frameState: { frameNumber: 42 }, + screenSpaceCameraController: {}, + requestRender() {}, + }, + entities: new Cesium.EntityCollection(), + clock: { currentTime: Cesium.JulianDate.now() }, + trackedEntity: undefined, + trackedEntityChanged: new Cesium.Event(), + isDestroyed: () => false, + }; + return { viewer, added, removed }; +} + +const renderer = (viewer, options = {}) => + createAircraftCesiumRenderer({ + viewer, + spriteLayer: 'flights', + headingOffsetDeg: 180, + ...options, + }); + +test('the Cesium aircraft renderer has every member of the contract', () => { + const { viewer } = stubViewer(); + assert.deepEqual(missingAircraftRendererMembers(renderer(viewer)), []); + assert.equal(renderer(viewer).kind, 'cesium'); +}); + +test('the fleet is one BillboardCollection and one model collection, in that order', () => { + const { viewer, added, removed } = stubViewer(); + const r = renderer(viewer); + assert.equal(r.fleet.attached(), false); + r.fleet.attach(); + assert.ok(added[0] instanceof Cesium.BillboardCollection); + assert.ok(added[1] instanceof Cesium.PrimitiveCollection); + assert.equal(r.fleet.visible(), true); + const sprite = r.fleet.add({ + position: geo.cartesian3.fromDegrees(0, 0, 1000), + color: rgbaFromCss('#FFB800').withAlpha(0.5), + scaleByDistance: nearFarScale(1000, 3, 8_000_000, 0.5), + id: 'abc123', + }); + assert.ok(sprite instanceof Cesium.Billboard); + assert.equal(sprite.id, 'abc123'); + r.fleet.setVisible(false); + assert.equal(r.fleet.visible(), false); + r.models.setVisible(false); + assert.equal(added[1].show, false); + assert.equal(r.models.live(), true); + r.fleet.detach(); + assert.deepEqual(removed, [added[0], added[1]]); + assert.equal(r.fleet.attached(), false); +}); + +test('a model pose is the old heading-pitch-roll frame with the nose offset', () => { + const { viewer } = stubViewer(); + const r = renderer(viewer); + const model = { modelMatrix: new Cesium.Matrix4() }; + const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000); + const written = r.models.setPose(model, position, 75); + assert.equal(written, model.modelMatrix, 'written into the model own matrix'); + const expected = Cesium.Transforms.headingPitchRollToFixedFrame( + position, + new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(75 + 180), 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); + const calls = []; + const realFrom = Cesium.Model.fromGltfAsync; + Cesium.Model.fromGltfAsync = (options) => { + calls.push(options); + return Promise.resolve({ options }); + }; + try { + const spec = { + url: '/models/airplane.glb', + minimumPixelSize: 24, + scale: 1, + color: CYAN, + blendAmount: 0.94, + unlit: false, + id: 'abc123', + }; + await r.models.loadFleet(spec); + await r.models.loadTracked({ ...spec, unlit: true, minimumPixelSize: 40 }); + await r.models.preload('/models/airplane.glb'); + } finally { + Cesium.Model.fromGltfAsync = realFrom; + } + assert.equal(calls.length, 3); + for (const call of calls) assert.equal(call.asynchronous, false); + assert.equal(calls[0].colorBlendMode, Cesium.ColorBlendMode.MIX); + assert.equal(calls[0].colorBlendAmount, 0.94); + assert.equal(calls[0].customShader, undefined); + assert.equal(calls[0].id, 'abc123'); + assert.equal(calls[1].minimumPixelSize, 40); + assert.equal(calls[1].customShader.lightingModel, Cesium.LightingModel.UNLIT); + assert.deepEqual(Object.keys(calls[2]).sort(), ['asynchronous', 'url']); +}); + +test('the frustum and horizon tests are the Cesium ones', () => { + const { viewer } = stubViewer(); + const r = renderer(viewer); + const inFrustum = r.view.frustumTester(); + const below = Cesium.Cartesian3.fromDegrees(-73.78, 40.0, 0); + const behind = Cesium.Cartesian3.fromDegrees(106.22, -40.0, 0); + const aboveCamera = Cesium.Cartesian3.fromDegrees(-73.78, 40.0, 200_000); + assert.equal(inFrustum(below), true); + assert.equal(inFrustum(aboveCamera), false); + assert.equal(r.view.horizon().isPointVisible(below), true); + assert.equal(r.view.horizon().isPointVisible(behind), false); + assert.equal(r.view.frameNumber(), 42); + assert.equal(r.view.viewportHeightPx(), 800); +}); + +test('the tracked aircraft is an ENU-framed billboard entity with live callbacks', () => { + const { viewer } = stubViewer(); + const r = renderer(viewer); + let position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000); + let color = CYAN; + const handle = r.tracked.create({ + position: () => position, + image: 'data:image/png;base64,', + width: 28, + height: 28, + scale: 1.2, + color: () => color, + scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + rotation: () => 0.25, + }); + assert.ok(handle instanceof Cesium.Entity); + assert.equal(r.tracked.isLive(handle), true); + const time = viewer.clock.currentTime; + assert.equal( + handle.trackingReferenceFrame, + Cesium.TrackingReferenceFrame.ENU, + ); + assert.equal(handle.position.getValue(time), position); + const billboardColor = handle.billboard.color.getValue(time); + assert.ok(billboardColor instanceof Cesium.Color); + assert.ok(billboardColor.equals(Cesium.Color.CYAN)); + color = CYAN.withAlpha(0); + assert.equal(handle.billboard.color.getValue(time).alpha, 0); + assert.equal(handle.billboard.rotation.getValue(time), 0.25); + assert.ok( + Cesium.Cartesian3.equals( + handle.billboard.alignedAxis.getValue(time), + Cesium.Cartesian3.ZERO, + ), + ); + assert.ok( + handle.billboard.scaleByDistance.getValue(time) instanceof + Cesium.NearFarScalar, + ); + assert.deepEqual(r.tracked.billboardSize(handle), { + width: 28, + height: 28, + scale: 1.2, + scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5), + }); + r.tracked.setViewFrom(handle, { x: 0, y: -2400, z: 1650 }); + assert.ok(handle.viewFrom.getValue(time) instanceof Cesium.Cartesian3); + position = geo.cartesian3.fromDegrees(-97.6, 30.2, 3000); + assert.equal(handle.position.getValue(time), position); + r.tracked.remove(handle); + assert.equal(r.tracked.isLive(handle), false); +}); + +test('follow tracks the handle after cancelling a flight; release leaves the camera', () => { + const { viewer } = stubViewer(); + const r = renderer(viewer); + const handle = r.tracked.create({ + position: () => geo.cartesian3.fromDegrees(-97.7, 30.2, 3000), + image: 'x', + width: 28, + height: 28, + scale: 1, + color: () => CYAN, + scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + rotation: () => 0, + }); + r.tracked.setViewFrom(handle, { x: 0, y: -2400, z: 1650 }); + const changes = []; + const remove = r.follow.onChanged(() => changes.push(r.follow.current())); + viewer.trackedEntityChanged.raiseEvent(); // the viewer raises on assignment + const stop = r.follow.start(handle); + assert.equal(viewer.camera.cancelled, 1); + assert.equal(viewer.trackedEntity, handle); + assert.equal(r.follow.isFollowing(handle), true); + assert.equal(typeof stop, 'function', 'the one exact tracked frame'); + stop(); + r.follow.release(); + assert.equal(viewer.trackedEntity, undefined); + assert.equal(r.follow.isFollowing(handle), false); + remove(); + assert.equal(changes.length, 1); +}); + +test('the trail head is a geodesic entity polyline in the trail namespace', () => { + const { viewer } = stubViewer(); + const r = renderer(viewer); + const ends = [ + geo.cartesian3.fromDegrees(0, 0, 1000), + geo.cartesian3.fromDegrees(0.1, 0, 1000), + ]; + const head = r.trails.createHead({ + id: 'gev-trail:fl-head-1', + show: true, + positions: () => ends, + color: '#00d4ff', + width: 2.5, + }); + const time = viewer.clock.currentTime; + assert.equal(head.id, 'gev-trail:fl-head-1'); + assert.equal(head.polyline.positions.getValue(time), ends); + assert.equal(head.polyline.arcType.getValue(time), Cesium.ArcType.GEODESIC); + const base = Cesium.Color.fromCssColorString('#00d4ff'); + assert.ok( + head.polyline.material.getValue(time).color.equals(base.withAlpha(0.9)), + ); + assert.ok( + head.polyline.depthFailMaterial + .getValue(time) + .color.equals(base.withAlpha(0.45)), + ); + r.trails.removeHead(head); + assert.equal(viewer.entities.contains(head), false); +}); + +test('an adopted (test) collection is driven as the fleet', () => { + const { viewer } = stubViewer(); + const removed = []; + const billboardCollection = { + show: true, + remove: (sprite) => removed.push(sprite), + }; + const modelCollection = { + added: [], + add(model) { + this.added.push(model); + }, + remove() {}, + isDestroyed: () => false, + }; + const r = renderer(viewer, { + adopt: { billboardCollection, modelCollection }, + }); + assert.equal(r.fleet.visible(), true); + r.fleet.remove('sprite'); + assert.deepEqual(removed, ['sprite']); + r.models.add('model'); + assert.deepEqual(modelCollection.added, ['model']); + assert.deepEqual( + r.surfaceExclusions({ sprites: ['a'], models: ['b', null] }), + ['a', 'b'], + ); +}); diff --git a/src/layers/aircraft/render/contract.js b/src/layers/aircraft/render/contract.js new file mode 100644 index 000000000..f80d224cf --- /dev/null +++ b/src/layers/aircraft/render/contract.js @@ -0,0 +1,159 @@ +// The aircraft renderer: what the aircraft layers' logic (flights now, +// military and local ADS-B after it) asks of an engine to draw a fleet of +// aircraft, its 3D models, the tracked aircraft, its trail, and to follow it. +// render/cesium.js implements it over Cesium (the objects the layers used +// to build themselves, one for one); render/navara.js implements it on the +// Navara engine over engine.movers / engine.lines / engine.camera.follow. +// Nothing here imports an engine. +// +// The logic holds HANDLES and writes plain fields on them; the field names +// are Cesium's (the extraction is a pure move), and an engine other than +// Cesium implements them as setters: +// +// SpriteHandle (fleet.add) — one aircraft glyph: +// position (Cartesian3), show, rotation (screen angle, rad), +// scale, width, height (CSS px), color ({red, green, blue, alpha} with +// withAlpha(); an Rgba of src/layers/shared/color.js or what the engine +// stored), image (canvas or URL), scaleByDistance ({near, nearValue, +// far, farValue}), id. Reads return what was last written (color and +// scaleByDistance as the engine's own copy). The logic may keep its own +// bookkeeping fields on a handle (prefixed _gev). +// ModelHandle (models.load*) — one glTF instance: +// show, ready (read-only), scale, computedScale (read-only: scale after +// minimumPixelSize), color, id, modelMatrix (16 numbers, column-major, +// written by models.setPose and read for anchors), sceneGraph? (Cesium). +// TrackedHandle (tracked.create) — the selected aircraft's glyph and the +// follow target: viewFrom (ENU offset, read), and the annotation fields +// the shell reads: gevSelectionOrigin, gevTrackedId, gevLabelModel, +// gevDisplayPosition(), gevVisualPosition(). +// Trail (trails.create): setPositions(Cartesian3[]), setVisible(bool), +// clear(), destroy(). TrailHead (trails.createHead): show (bool). +// +// The view: what the logic reads of the camera and the frame. `camera` is +// camera-shaped (positionWC, positionCartographic, directionWC, upWC, +// rightWC, heading, pitch, roll, frustum.fovy) and `scene` scene-shaped +// ({ camera, canvas.clientHeight, frameState.frameNumber }) for the shared +// helpers that take them (iconOrientation, focusDeemphasis); on Navara +// they are adapters over engine.camera.pose(), with the engine registered +// for the scene (engineFromViewer) so projections go through its projector. + +/** + * @typedef {object} AircraftView + * @property {object} camera + * @property {object} scene + * @property {() => number} frameNumber One value per rendered frame. + * @property {() => number} viewportHeightPx + * @property {() => ((position: object) => boolean)} frustumTester For this + * frame: is a point (1 m sphere) inside or on the view frustum. + * @property {() => { isPointVisible(position: object): boolean }} horizon + * For this frame: the ellipsoid horizon test. + * @property {(position: object, result?: object) => ({x: number, y: number} | undefined)} worldToWindow + * @property {() => void} requestRender + * @property {(event: 'preRender'|'preUpdate'|'moveEnd', listener: Function) => () => void} on + * @property {object} surfaceOwner What groundSnap/meshFloor sample through + * (a viewer registered with its engine, or the engine). + * @property {object} surfaceScene What sampleMeshFloorCells takes. + * @property {() => void} holdFrames Evidence capture: stop the engine's own + * frame loop (QA only). + * @property {() => void} closeFrameLatch Evidence capture, one task later: + * let manual renders start without waiting for the loop's next frame. + * @property {() => void} releaseFrames + */ + +/** + * @typedef {object} AircraftRenderer + * @property {'cesium'|'navara'} kind + * @property {object|null} engine The globe engine (null in unit tests that + * drive a bare viewer). + * @property {AircraftView} view + * @property {object} fleet attach(), detach(), attached(), visible(), + * setVisible(bool), add(spec) → SpriteHandle, remove(sprite), + * 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). + * spec: { url, minimumPixelSize, scale, color, blendAmount, unlit, id }. + * @property {object} tracked create(spec) → TrackedHandle, remove(handle), + * isLive(handle), setImage(handle, image), setViewFrom(handle, enu), + * billboardSize(handle) → { width, height, scale, scaleByDistance } | null. + * spec: { image, width, height, scale, scaleByDistance, + * disableDepthTestDistance, position(), color() → colour, rotation() }. + * @property {object} follow start(handle) → stop, refocus(handle) → stop, + * release(), isFollowing(handle), current(), onChanged(listener) → remove. + * @property {object} trails create({ color, width }) → Trail, + * createHead({ id, show, positions() → Cartesian3[], color, width }) → + * TrailHead, removeHead(head). + * @property {(objects: { sprites: Iterable, models: Iterable }) => object[]} surfaceExclusions + * What a vertical surface probe must not hit: the layer's own drawings. + * @property {() => void} destroy + */ + +/** The members every aircraft renderer has (shape checks, tests). */ +export const AIRCRAFT_RENDERER_SHAPE = Object.freeze({ + view: [ + 'frameNumber', + 'viewportHeightPx', + 'frustumTester', + 'horizon', + 'worldToWindow', + 'requestRender', + 'on', + 'holdFrames', + 'closeFrameLatch', + 'releaseFrames', + ], + fleet: [ + 'attach', + 'detach', + 'attached', + 'visible', + 'setVisible', + 'add', + 'remove', + 'restoreOrder', + 'restoreOrderOnEnable', + ], + models: [ + 'live', + 'setVisible', + 'loadFleet', + 'loadTracked', + 'preload', + 'add', + 'remove', + 'destroy', + 'setPose', + ], + tracked: [ + 'create', + 'remove', + 'isLive', + 'setImage', + 'setViewFrom', + 'billboardSize', + ], + follow: [ + 'start', + 'refocus', + 'release', + 'isFollowing', + 'current', + 'onChanged', + ], + trails: ['create', 'createHead', 'removeHead'], +}); + +/** + * The members of AIRCRAFT_RENDERER_SHAPE that `renderer` lacks + * (['group.member', …]); empty for a complete renderer. + */ +export function missingAircraftRendererMembers(renderer) { + const missing = []; + for (const key of ['kind', 'view', 'surfaceExclusions', 'destroy']) + if (renderer?.[key] === undefined) missing.push(key); + for (const [group, members] of Object.entries(AIRCRAFT_RENDERER_SHAPE)) + for (const member of members) + if (typeof renderer?.[group]?.[member] !== 'function') + missing.push(`${group}.${member}`); + return missing; +} diff --git a/src/layers/flights/controller.js b/src/layers/flights/controller.js index 771b9fd3e..6ef959bb3 100644 --- a/src/layers/flights/controller.js +++ b/src/layers/flights/controller.js @@ -13,8 +13,9 @@ export function createController({ flightState.feed._activeUpdateControllers.clear(); } - function _flightQuery(viewer) { - const cartographic = viewer?.camera?.positionCartographic; + /** The feed query around the camera of `view` (a viewer or the renderer's view). */ + function _flightQuery(view) { + const cartographic = view?.camera?.positionCartographic; return cartographic ? { latitude: geo.math.toDegrees(cartographic.latitude), diff --git a/src/layers/flights/enrichment.js b/src/layers/flights/enrichment.js index 2201d1ae9..8c5f0aea5 100644 --- a/src/layers/flights/enrichment.js +++ b/src/layers/flights/enrichment.js @@ -1,6 +1,4 @@ import { classifyAircraft } from '../../data/aircraftClass.js'; -import { horizonOccluder } from '../../data/iconOrientation.js'; -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { ENRICH_MAX_INFLIGHT, @@ -181,32 +179,21 @@ export function createEnrichment({ _refillAmbientBudget(Date.now()); if ( flightState._enrichAmbientBudget <= 0 || - !flightState._viewer || - !flightState._billboardCollection || - !flightState._billboardCollection.show + !flightState._renderer?.fleet.visible() ) return; try { - const camera = flightState._viewer.camera; - const camPos = camera.positionWC; - const occluder = horizonOccluder(camera); - const cull = camera.frustum.computeCullingVolume( - camPos, - camera.directionWC, - camera.upWC, - ); + const view = flightState._renderer.view; + const camPos = view.camera.positionWC; + const occluder = view.horizon(); + const inFrustum = view.frustumTester(); const cand = []; for (const [icao24, bb] of flightState._billboards) { if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only if (flightState.records.data.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works) if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb - geo.cartesian3.clone(bb.position, flightState._scratchModelBS.center); - if ( - cull.computeVisibility(flightState._scratchModelBS) === - Cesium.Intersect.OUTSIDE - ) - continue; // off-screen + if (!inFrustum(bb.position)) continue; // off-screen cand.push([ icao24, geo.cartesian3.distanceSquared(camPos, bb.position), diff --git a/src/layers/flights/evidence.js b/src/layers/flights/evidence.js index 243fe9b9a..2ae148cba 100644 --- a/src/layers/flights/evidence.js +++ b/src/layers/flights/evidence.js @@ -1,4 +1,4 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { aircraftIcon } from '../../data/aircraftIcons.js'; import { FOCUS_EVIDENCE_DEV } from './policy.js'; @@ -18,7 +18,7 @@ export function createEvidence({ cartesian.length >= 3 && cartesian.slice(0, 3).every(Number.isFinite) ) { - return Cesium.Cartesian3.fromElements( + return geo.cartesian3.fromElements( cartesian[0], cartesian[1], cartesian[2], @@ -29,7 +29,7 @@ export function createEvidence({ !Number.isFinite(record?.latitude) ) return null; - return Cesium.Cartesian3.fromDegrees( + return geo.cartesian3.fromDegrees( record.longitude, record.latitude, Number.isFinite(record.altitudeM) ? record.altitudeM : 3_000, @@ -39,16 +39,12 @@ export function createEvidence({ /** Replace the real fleet with deterministic explicit-position contacts. */ function _setFocusEvidenceAircraft(records = []) { - if ( - !FOCUS_EVIDENCE_DEV || - !flightState._billboardCollection || - !flightState._viewer - ) + if (!FOCUS_EVIDENCE_DEV || !flightState._renderer?.fleet.attached()) return { ok: false, count: 0 }; if (flightState._trackedIcao) parts.tracking._clearTracking(); parts.rendering._releaseModels(); for (const bb of flightState._billboards.values()) - flightState._billboardCollection.remove(bb); + flightState._renderer.fleet.remove(bb); flightState._billboards.clear(); flightState.records.data.clear(); flightState._positionHistory.clear(); @@ -65,7 +61,7 @@ export function createEvidence({ const klass = record.klass || 'airliner'; const altitudeM = Number.isFinite(record.altitudeM) ? record.altitudeM - : Cesium.Cartographic.fromCartesian(position)?.height || 3_000; + : geo.cartographic.fromCartesian(position)?.height || 3_000; const meta = { callsign: String(record.callsign || id).toUpperCase(), altitude: altitudeM, @@ -83,14 +79,14 @@ export function createEvidence({ flightState.records.data.set(id, meta); flightState._cullPositions.delete(id); flightState._focusEvidenceIds.add(id); - const bb = flightState._billboardCollection.add({ + const bb = flightState._renderer.fleet.add({ position, image: aircraftIcon(parts.rendering._iconKind(id, klass)), width: 20, height: 20, scale: parts.rendering._fleetBillboardScale(id, klass), rotation: 0, - alignedAxis: Cesium.Cartesian3.ZERO, + alignedAxis: geo.cartesian3.ZERO, color: parts.rendering._fleetBillboardColor(id), sizeInMeters: false, scaleByDistance: parts.rendering._normalBillboardScaleByDistance(), @@ -102,7 +98,7 @@ export function createEvidence({ } flightState.feed._count = flightState._billboards.size; flightState._lastFleetTickMs = 0; - flightState._viewer.scene.requestRender?.(); + flightState._renderer.view.requestRender(); return { ok: true, count: flightState.feed._count }; } @@ -129,22 +125,18 @@ export function createEvidence({ moved += 1; } flightState._lastFleetTickMs = 0; - flightState._viewer?.scene?.requestRender?.(); + flightState._renderer?.view.requestRender(); return { ok: true, moved }; } /** JSON-safe visual snapshot for the evidence report. */ function _focusEvidenceSnapshot() { - if (!FOCUS_EVIDENCE_DEV || !flightState._viewer) return []; + const view = flightState._renderer?.view; + if (!FOCUS_EVIDENCE_DEV || !view) return []; return [...flightState._focusEvidenceIds].map((id) => { const bb = flightState._billboards.get(id); - const screen = bb?.position - ? Cesium.SceneTransforms.worldToWindowCoordinates( - flightState._viewer.scene, - bb.position, - ) - : null; + const screen = bb?.position ? view.worldToWindow(bb.position) : null; return { id, show: bb?.show === true, @@ -153,10 +145,7 @@ export function createEvidence({ x: screen?.x ?? null, y: screen?.y ?? null, cameraDistanceM: bb?.position - ? Cesium.Cartesian3.distance( - flightState._viewer.camera.positionWC, - bb.position, - ) + ? geo.cartesian3.distance(view.camera.positionWC, bb.position) : null, }; }); diff --git a/src/layers/flights/index.js b/src/layers/flights/index.js index f778c98b1..ee4d960c5 100644 --- a/src/layers/flights/index.js +++ b/src/layers/flights/index.js @@ -44,7 +44,7 @@ export function createCivilFlightLayer({ parts.ingestion = createIngestion({ feed: flightState.feed, getQuery: (viewer) => - parts.controller._flightQuery(viewer || flightState._viewer), + parts.controller._flightQuery(viewer || flightState._renderer?.view), applySnapshot, setSourceLabel: (source) => { layer.source = source; diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index 0ca944647..a238ebcdd 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { PLANE_MODEL_URL } from './policy.js'; export function createLifecycle({ @@ -9,11 +8,6 @@ export function createLifecycle({ resolveAsset, }) { const { clearFocusTarget } = services.focus; - const { - registerSpriteCollection, - restoreSpriteOrder, - restoreSpriteOrderOnEnable, - } = services.sprites; const { onMilitaryLayerActiveChange } = services.militaryRegistry; const { holdContinuousRender, releaseContinuousRender } = services.render; const { ensureGeoidReady } = services.geoid; @@ -22,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'); @@ -34,12 +28,14 @@ export function createLifecycle({ /** * Initialize the flights layer. - * Creates the BillboardCollection, resets all state, and installs the - * click-to-track handler on the scene canvas. - * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. + * Creates the engine's aircraft renderer (fleet sprites, models), resets + * all state, and joins 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'); @@ -47,31 +43,22 @@ export function createLifecycle({ flightState.lifetime = new AbortController(); clearFocusTarget('flights'); flightState._focusEvidenceIds.clear(); - flightState._viewer = viewer; - flightState._billboardCollection = new Cesium.BillboardCollection(); - viewer.scene.primitives.add(flightState._billboardCollection); - registerSpriteCollection('flights', 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(PLANE_MODEL_URL), - asynchronous: false, - }) + renderer.models + .preload(resolveAsset(PLANE_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(() => { @@ -114,7 +101,7 @@ export function createLifecycle({ parts.enrichment._ambientBudgetKnobs().ceil; flightState._enrichAmbientRefillAnchorMs = 0; - parts.tracking._installClickHandler(viewer); + parts.tracking._installClickHandler(); // React to Military-layer toggles IMMEDIATELY (suppress/restore sweep) // instead of waiting out the 30 s poll (M2). @@ -124,21 +111,19 @@ export function createLifecycle({ ); } - restoreSpriteOrder(viewer); + renderer.fleet.restoreOrder(); console.log('[Data:Flights] Initialized with billboard icons'); }, /** - * Show the billboard collection and re-install the click handler. - * @param {Cesium.Viewer} viewer + * Show the fleet 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('flights'); // 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'), ); @@ -155,24 +140,25 @@ export function createLifecycle({ /* geoid grid failed to load — baro path stays un-geoid-corrected until retried */ }); } - parts.tracking._installClickHandler(viewer); + parts.tracking._installClickHandler(); registerPickOwner('flights', (pickedId) => flightState._billboards.has(pickedId), ); // 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 (field test 2026-07-03: "planes look weird when you first // come to them"): when a camera move SETTLES (voice fly-to, fast pan // release), force a full rotation pass on the very next frame. The @@ -182,43 +168,30 @@ export function createLifecycle({ // ROTATION_REFRESH_MS. Zeroing the tick throttle too means the pass runs // on the next preRender, not up to FLEET_DR_INTERVAL_MS later. Cost: one // extra rotation pass per completed camera 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; + }); } - restoreSpriteOrderOnEnable('flights', viewer); + renderer?.fleet.restoreOrderOnEnable(); }, /** - * Hide all flight billboards and tear down click/keyboard handlers. + * Hide all flight billboards and leave the selection dispatcher. * Also clears any active flight tracking so the camera is released. - * @param {Cesium.Viewer} viewer */ - disable(viewer) { + disable() { parts.controller._abortActiveUpdates(); parts.tracking._cancelPendingTrackingRestore(); - if (flightState._billboardCollection) - flightState._billboardCollection.show = false; + flightState._renderer?.fleet.setVisible(false); releaseContinuousRender('flights'); 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('flights'); if (flightState._preRenderRemove) { flightState._preRenderRemove(); @@ -235,9 +208,9 @@ export function createLifecycle({ }, /** - * Fully tear down the flights layer — remove primitives, handlers, - * tracked entities, and clear all internal state maps. - * @param {Cesium.Viewer} viewer + * Fully tear down the flights layer — remove its drawings, handlers, + * the tracked visual, and clear all internal state maps. + * @param {object|null} viewer - The viewer the layer was drawn on. */ destroy(viewer) { flightState.lifetime.abort(); @@ -248,19 +221,11 @@ 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; - } + parts.tracking._removeClickHandler(); if (flightState._milActiveChangeUnsub) { flightState._milActiveChangeUnsub(); flightState._milActiveChangeUnsub = null; } - document.removeEventListener('keydown', parts.tracking._onKeyDown); if (flightState._cockpitModeListener) { window.removeEventListener( 'gev:cockpit-mode-changed', @@ -282,23 +247,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; @@ -332,7 +287,8 @@ export function createLifecycle({ coverage: flightState.feed._lastCoverage, }; 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/flights/motion.js b/src/layers/flights/motion.js index e41d5e7e7..4cfa044e8 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -297,8 +297,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)) { @@ -309,12 +310,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( @@ -323,16 +324,16 @@ 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 ?? (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; } @@ -347,7 +348,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 @@ -434,8 +435,8 @@ export function createMotion({ publishFocusTargetFromCachedPosition({ ownerLayer: 'flights', 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/flights/ownership.test.mjs b/src/layers/flights/ownership.test.mjs index 813df5641..04eb1fb82 100644 --- a/src/layers/flights/ownership.test.mjs +++ b/src/layers/flights/ownership.test.mjs @@ -7,8 +7,8 @@ import { createEnrichment } from './enrichment.js'; function services() { const names = [ 'picking', - 'sprites', - 'trails', + 'selection', + 'renderer', 'aircraftPresentation', 'camera', 'militaryRegistry', diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index f8358c98f..cc14bbb08 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -6,13 +6,18 @@ export { MISSING_POLL_LIMIT, ERROR_BACKOFF_INTERVAL, } from './recordPolicy.js'; -import * as Cesium from 'cesium'; +import { + CYAN, + constantRgba, + nearFarScale, + rgbaFromCss, +} from '../shared/color.js'; export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; /** Amber tint for known-military aircraft rendered by this layer (matches the military layer's icon color). */ -export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800'); +export const MIL_TINT = Object.freeze(rgbaFromCss('#FFB800')); // --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes // taxiing and landing") ----------------------------------------------------------- @@ -107,18 +112,19 @@ export const MODEL_COLOR_BLEND_AMOUNT = 0.94; export const MODEL_BELLY_OFFSET_NATIVE = 6.719; -export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0); +export const CYAN_TRANSPARENT = constantRgba( + CYAN.red, + CYAN.green, + CYAN.blue, + 0, +); export const COCKPIT_CONTACT_SIZE_PX = 6; -export const COCKPIT_CIVILIAN_COLOR = - Cesium.Color.fromCssColorString('#DCEEFF'); +export const COCKPIT_CIVILIAN_COLOR = Object.freeze(rgbaFromCss('#DCEEFF')); -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/flights/queries.js b/src/layers/flights/queries.js index cfcafd374..c1ca4ec06 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -26,7 +26,6 @@ export function createQueries({ const { tr3bTypeLabel, tr3bAircraftClass } = services.aircraftPresentation; const { formatFlightLevel } = services.labels; const { isMilitaryIcao } = services.militaryRegistry; - const { applyTrackedCameraFrame } = services.camera; const { setFocusDeemphasisParams, getFocusDeemphasisParams, @@ -303,7 +302,7 @@ export function createQueries({ // Restore fleet billboards (the horizon-cull pass re-asserts next tick), but NEVER the // tracked plane's own fleet billboard — its tracked entity is the visual, so re-showing it // here would double-image the tracked plane when 3D is turned off mid-track. - if (flightState._billboardCollection) + if (flightState._renderer?.fleet.attached()) for (const [icao, bb] of flightState._billboards) { if (icao !== flightState._trackedIcao) bb.show = true; } @@ -389,11 +388,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 []; // Compute a stride that evenly samples the billboard map. // seed shifts the starting offset so successive calls can sample // different aircraft without shuffling the underlying Map order. @@ -536,12 +531,7 @@ export function createQueries({ * @returns {Array<{id: string, icao24: string, callsign: string|null, position: Cesium.Cartesian3, distance: number, aircraftClass: string|null, altitudeM: number|null, velocityMps: number|null, track: number|null}>} */ 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)) @@ -625,8 +615,7 @@ export function createQueries({ */ hasContact(icao24) { if ( - !flightState._billboardCollection || - !flightState._billboardCollection.show || + !flightState._renderer?.fleet.visible() || flightState._billboards.size === 0 ) return null; @@ -640,7 +629,7 @@ export function createQueries({ getAllPositions(maxCount = 500) { if ( - !flightState._billboardCollection || + !flightState._renderer?.fleet.attached() || flightState._billboards.size === 0 ) return []; @@ -695,8 +684,7 @@ export function createQueries({ */ getAnalystRecords(maxCount = 2000) { if ( - !flightState._billboardCollection || - !flightState._billboardCollection.show || + !flightState._renderer?.fleet.visible() || flightState.records.data.size === 0 ) return []; @@ -792,7 +780,7 @@ export function createQueries({ if ( !icao24 || flightState._cockpitContactMode || - !flightState._viewer || + !flightState._renderer || !flightState._trackedEntity ) return false; @@ -800,19 +788,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; }, @@ -886,9 +868,10 @@ export function createQueries({ }; }, takeFrameClock(startMs = 1_000_000_000) { - if (!flightState._viewer || !Number.isFinite(startMs)) + const view = flightState._renderer?.view; + if (!view || !Number.isFinite(startMs)) return { ok: false, nowMs: null }; - flightState._viewer.useDefaultRenderLoop = false; + view.holdFrames(); setFocusEvidenceNowMs(startMs); flightState._lastFleetTickMs = startMs - FLEET_DR_INTERVAL_MS; // Cross a browser task boundary, then close Cesium's pending-loop @@ -896,8 +879,7 @@ export function createQueries({ // while manual evidence renders can begin without waiting on VSYNC. return new Promise((resolve) => setTimeout(() => { - if (flightState._viewer?._cesiumWidget) - flightState._viewer._cesiumWidget._renderLoopRunning = false; + flightState._renderer?.view.closeFrameLatch(); resolve({ ok: true, nowMs: startMs }); }, 0), ); @@ -907,8 +889,7 @@ export function createQueries({ }, releaseFrameClock() { setFocusEvidenceNowMs(null); - if (flightState._viewer) - flightState._viewer.useDefaultRenderLoop = true; + flightState._renderer?.view.releaseFrames(); }, }), } diff --git a/src/layers/flights/render/cesium.js b/src/layers/flights/render/cesium.js new file mode 100644 index 000000000..fd5600696 --- /dev/null +++ b/src/layers/flights/render/cesium.js @@ -0,0 +1,23 @@ +// The civil flights layer's renderer on Cesium: the shared aircraft renderer +// (src/layers/aircraft/render/cesium.js) with the flights' sprite-order slot +// and airplane.glb's nose offset. The flights' presentation choices (amber +// for known military, the TR-3B glyph, 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 createFlightsCesiumRenderer(engine, { viewer, adopt } = {}) { + return createAircraftCesiumRenderer({ + viewer, + engine, + spriteLayer: 'flights', + headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, + adopt, + }); +} diff --git a/src/layers/flights/renderer.test.mjs b/src/layers/flights/renderer.test.mjs new file mode 100644 index 000000000..02051b1ab --- /dev/null +++ b/src/layers/flights/renderer.test.mjs @@ -0,0 +1,249 @@ +// The flights logic draws only through its renderer (phase 3 Task 10a): with +// a renderer that is plain data (no Cesium), the layer builds its fleet, +// selects a contact on a click through the engine's selection dispatcher, +// tracks and follows it, and releases it on an empty click. This is the seam +// the Navara renderer (Task 10b) plugs into. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../geo/index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { createApplicationFlights } from '../../app/layers/flights.js'; +import { defaultSurface } from '../../data/surfaceServices.js'; +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'; + +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() {}, + 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({ + time: nowSec, + states: ids.map((id, index) => [ + id, + `TST${index} `, + 'United States', + nowSec, + nowSec, + -97.6 + index * 0.1, + 30.3, + 10_668, + false, + 250, + 95, + 5, + null, + 10_700, + null, + null, + null, + 5, + ]), + }); +} + +test('the flights logic draws, selects and follows only through its renderer', async () => { + const engine = createFakeEngine(); + let renderer = null; + const layer = createApplicationFlights({ + surface: defaultSurface, + source: { + label: 'Fixture aircraft', + getSnapshot: async () => snapshotOf(['abc123', 'def456']), + }, + militaryRegistry, + renderer: { + create: ({ viewer, engine: given }) => { + assert.equal(viewer, null, 'no viewer on an engine without one'); + renderer = recordingRenderer(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; + assert.ok(sprite.color instanceof Rgba, 'colours are engine-neutral'); + assert.equal(sprite.id, 'abc123'); + assert.equal(layer.getAllPositions().length, 2); + + // A click on a contact: the engine's pick, through the dispatcher. + engine.fake.script('pick', () => ({ id: 'def456' })); + engine.fake.script('drillPick', () => [{ id: 'def456' }]); + const click = () => { + 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 } }); + }; + click(); + assert.equal(layer.getTrackedInfo()?.icao24, 'def456'); + assert.deepEqual( + renderer.calls.filter( + ([name]) => name.startsWith('tracked') || name.startsWith('follow'), + ), + [ + ['tracked.create', 'tracked-1'], + ['follow.start', 'tracked-1'], + ], + ); + + // Clicking the tracked visual itself is a no-op. + engine.fake.script('pick', () => ({ id: 'tracked-1' })); + engine.fake.script('drillPick', () => [{ id: 'tracked-1' }]); + click(); + assert.equal(layer.getTrackedInfo()?.icao24, 'def456'); + + // Empty space releases the follow and removes the tracked visual. + engine.fake.script('pick', () => undefined); + engine.fake.script('drillPick', () => []); + click(); + 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', + ), + ); + } finally { + layer.disable(); + layer.destroy(null); + } + assert.ok(renderer.calls.some(([name]) => name === 'destroy')); +}); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 44e1b02c3..7de20f4cd 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -1,4 +1,5 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { WHITE, CYAN, nearFarScale } from '../shared/color.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; import { civilAircraftModelSpec } from './modelSpec.js'; @@ -7,7 +8,6 @@ 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'; @@ -24,7 +24,6 @@ import { MODEL_PROX_ADD_M, MODEL_ALL_KEEP_M, MODEL_PROX_KEEP_M, - MODEL_HEADING_OFFSET_DEG, IR_RELOAD_BATCH, MODEL_MIN_PX, TRACKED_MODEL_MIN_PX, @@ -58,7 +57,7 @@ export function createRendering({ * Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */ function _fleetBillboardColor(icao24) { - return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE; + return isMilitaryIcao(icao24) ? MIL_TINT : WHITE; } /** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */ @@ -97,11 +96,11 @@ export function createRendering({ function _normalBillboardScaleByDistance() { // Preserve the established close-range 3× scale. Any smaller owner-visible // default belongs in a separate evidence-backed proposal. - 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); } /** Sprite kind for one contact's billboard. Identity for every aircraft except @@ -161,8 +160,8 @@ export function createRendering({ /** Model tint, mirroring the billboard color rules. */ function _modelColor(icao24) { - if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN; - return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE; + if (icao24 === flightState._trackedIcao) return CYAN; + return isMilitaryIcao(icao24) ? MIL_TINT : WHITE; } /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude @@ -177,7 +176,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; } @@ -212,30 +212,13 @@ 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. `Model.modelMatrix` is a plain field (not a cloning setter); Cesium - * clones it per frame in updateModelMatrix(). Sharing a single scratch made every model render at - * the LAST-written transform — all stacked on one plane — and, once the tracked model wrote the - * scratch every frame, the stack point oscillated frame-to-frame: the "flickering like mad" bug. */ + /** 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: one shared matrix + * stacked every model on the last-written plane, "flickering like mad"). */ - function _modelMatrix( - pos, - headingDeg, - result = flightState._scratchModelMtx, - ) { - flightState._scratchModelHpr.heading = Cesium.Math.toRadians( - (headingDeg || 0) + MODEL_HEADING_OFFSET_DEG, - ); - flightState._scratchModelHpr.pitch = 0; - flightState._scratchModelHpr.roll = 0; - return Cesium.Transforms.headingPitchRollToFixedFrame( - pos, - flightState._scratchModelHpr, - Cesium.Ellipsoid.WGS84, - undefined, - result, - ); + function _modelMatrix(pos, headingDeg, model) { + return flightState._renderer.models.setPose(model, pos, headingDeg); } /** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the @@ -271,23 +254,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, ); } @@ -316,7 +299,7 @@ export function createRendering({ // The matrix is written BEFORE the readiness test on purpose: a model can flip // `ready` during scene update after this tick, and a first rendered frame on a // stale load-start matrix is the one-frame jump this ordering prevents. - _modelMatrix(displayPos, course, model.modelMatrix); + _modelMatrix(displayPos, course, model); if (!model.ready) { model.show = false; // not loaded yet → keep the 2D icon, no half-model flash bb.show = true; @@ -477,22 +460,19 @@ export function createRendering({ // toggle must reject too (the reload queue only covers ADMITTED models). 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, + color: flightState._irBoost ? WHITE : _modelColor(icao24), // Launch presentation keeps the code-side tint dominant for every approved // model; IR boost removes the remaining diffuse hint with flat UNLIT white. - colorBlendAmount: flightState._irBoost ? 1.0 : spec.blendAmount, - 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 : spec.blendAmount, + 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 @@ -506,11 +486,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); @@ -522,8 +498,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) || @@ -533,11 +508,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; } @@ -551,7 +522,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 } @@ -571,16 +542,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 @@ -610,17 +572,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(); } @@ -631,16 +585,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; } } @@ -654,8 +599,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; @@ -685,36 +629,29 @@ 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 : Cesium.Color.CYAN, - colorBlendMode: Cesium.ColorBlendMode.MIX, - // The tracked aircraft uses the same dominant light tint as the fleet; - // IR boost removes the remaining diffuse hint with flat UNLIT white. - colorBlendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount, - 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, + color: flightState._irBoost ? WHITE : CYAN, + // The tracked aircraft uses the same dominant light tint as the fleet; + // IR boost removes the remaining diffuse hint with flat UNLIT white. + blendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount, + 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) => { // Untracked / re-tracked / torn down during the load → drop it. 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 — @@ -725,11 +662,7 @@ export function createRendering({ ) !== trackedKey || flightState._irBoost !== trackedIrBoost ) { - try { - m.destroy(); - } catch { - /* gone */ - } + renderer.models.destroy(m); flightState._trackedModelLoading = false; return; } @@ -751,11 +684,7 @@ export function createRendering({ flightState._scratchGroundPos, ); if (displayPos) - _modelMatrix( - displayPos, - parts.motion._trackedDisplayCourse(), - m.modelMatrix, - ); + _modelMatrix(displayPos, parts.motion._trackedDisplayCourse(), m); } flightState._trackedModel = m; flightState._trackedModelLoading = false; @@ -764,7 +693,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; // GLB cached — the tracked billboard can fade out once the model is up }) .catch((err) => { @@ -790,21 +719,19 @@ export function createRendering({ _modelMatrix( displayPos, parts.motion._trackedDisplayCourse(), - flightState._trackedModel.modelMatrix, + flightState._trackedModel, ); if (!flightState._trackedModel.ready) return; const spec = _modelSpec( flightState.records.data.get(flightState._trackedIcao)?.klass, ); + 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; @@ -812,14 +739,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 @@ -848,7 +771,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. @@ -879,11 +802,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(); // Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness. const cand = []; for (const [icao, bb] of flightState._billboards) { @@ -898,18 +817,9 @@ export function createRendering({ // ground or in the air — no distinction"). The cap + nearest-first ordering // below already bound airport clusters; 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 the 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]); // nearest first if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) { @@ -952,7 +862,7 @@ export function createRendering({ // collection's vertex buffer, so skip sub-meter moves. if ( display && - Cesium.Cartesian3.distanceSquared(display, bb.position) > 1.0 + geo.cartesian3.distanceSquared(display, bb.position) > 1.0 ) { bb.position = display; } @@ -985,7 +895,7 @@ export function createRendering({ // locked NearFarScalar remains a separate multiplicative stage. This // narrowly amends always-visible rendering without count culling or zero // alpha; nearer focus behavior remains tunable rather than universal. - const cameraDistanceM = Cesium.Cartesian3.distance( + const cameraDistanceM = geo.cartesian3.distance( camera.positionWC, bb.position, ); @@ -1101,9 +1011,7 @@ export function createRendering({ // white. Boosted models also skip the recession fade: hot targets // stay full-strength at any range (billboards keep their normal // fade — full-opacity glyph walls read as overwhelming). - 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/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index 9265e7b92..20bbf8703 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { @@ -8,8 +7,9 @@ import { } from '../../data/motionModel.js'; import { aircraftIcon } from '../../data/aircraftIcons.js'; import { POSITION_HISTORY_LIMIT } from './recordPolicy.js'; +import { CYAN } from '../shared/color.js'; -/** Apply reconciled aircraft records to Cesium resources and follow state. */ +/** Apply reconciled aircraft records to the renderer's fleet and follow state. */ export function createFlightSnapshotRenderer({ flightState, records, @@ -32,7 +32,8 @@ export function createFlightSnapshotRenderer({ // subpoint + collected floor cells for the viewer-proximate low-contact // clamp below — one carto read per poll, one batch warm after the loop. const viewerCarto = - (viewer || flightState._viewer)?.camera?.positionCartographic || null; + (viewer || flightState._renderer?.view)?.camera?.positionCartographic || + null; const viewerLatDeg = viewerCarto ? geo.math.toDegrees(viewerCarto.latitude) : null; @@ -55,7 +56,7 @@ export function createFlightSnapshotRenderer({ if (isMil && tracking._militaryLayerSuppresses(icao24)) { const dupe = flightState._billboards.get(icao24); if (dupe) { - flightState._billboardCollection.remove(dupe); + flightState._renderer.fleet.remove(dupe); flightState._billboards.delete(icao24); rendering._releaseModel(icao24); // military-suppression: drop any 3D model too records.forget(icao24); @@ -200,7 +201,7 @@ export function createFlightSnapshotRenderer({ rendering._modelOwnsVisual(icao24), )) || 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, @@ -209,9 +210,7 @@ export function createFlightSnapshotRenderer({ // Screen-projected rotation lands on the next fleet tick. rotation: 0, alignedAxis: geo.cartesian3.ZERO, - color: isTracked - ? Cesium.Color.CYAN - : rendering._fleetBillboardColor(icao24), + color: isTracked ? CYAN : rendering._fleetBillboardColor(icao24), sizeInMeters: false, scaleByDistance: rendering._normalBillboardScaleByDistance(), // Grounded/near-surface planes sit at/below the photoreal tile @@ -272,7 +271,7 @@ export function createFlightSnapshotRenderer({ 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); @@ -308,15 +307,22 @@ export function createFlightSnapshotRenderer({ // cell, budget-capped, viewer-proximate, google-3d regime only). Own // billboards/models are excluded so a vertical probe can't land on an // aircraft instead of the pavement. - sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, { - excludeObjects: [ - ...flightState._billboards.values(), - ...flightState._models.values(), - flightState._trackedModel, - ].filter(Boolean), - viewerLat: viewerLatDeg, - viewerLon: viewerLonDeg, - }); + sampleMeshFloorCells( + flightState._renderer?.view.surfaceScene, + floorWarmPoints, + { + excludeObjects: + flightState._renderer?.surfaceExclusions({ + sprites: flightState._billboards.values(), + models: [ + ...flightState._models.values(), + flightState._trackedModel, + ], + }) ?? [], + viewerLat: viewerLatDeg, + viewerLon: viewerLonDeg, + }, + ); // Round 5: the grounded exact-key warm that used to live here is gone — // see the note where _warmGroundedAircraftSurfaceCache was removed. The diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index 62ccba13d..33095edd6 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -1,6 +1,5 @@ import { FlightRecords } from './records.js'; import { createFlightFeed } from './ingestion.js'; -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js'; @@ -36,11 +35,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(); @@ -79,33 +81,20 @@ 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(); // keep last N positions per aircraft // --------------------------------------------------------------------------- - // Module-level state: billboard collection and per-aircraft lookup maps + // Per-aircraft lookup maps (the fleet sprites live in the renderer) // --------------------------------------------------------------------------- - /** @type {Cesium.BillboardCollection|null} */ - - flightState._billboardCollection = null; - - /** @type {Map} icao24 -> billboard primitive */ + /** @type {Map} icao24 -> fleet sprite handle */ flightState._billboards = new Map(); @@ -133,7 +122,7 @@ export function createFlightState({ source, services }) { flightState._trackingIntentGeneration = 0; - /** @type {Cesium.Entity|null} Entity used for camera tracking */ + /** @type {object|null} The renderer's tracked handle (the follow target) */ flightState._trackedEntity = null; @@ -141,7 +130,7 @@ export function createFlightState({ source, services }) { flightState._trackedCameraFrameStop = null; - /** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft. Deliberately NOT a + /** @type {object|null} Standalone 3D model for the tracked aircraft. Deliberately NOT a * graphic on _trackedEntity: viewer.trackedEntity derives the follow-camera from the entity's * bounding sphere, and a model graphic reports PENDING until its glTF loads — which stalls (or, on * 3D-toggle, freezes) the centering. A pure-billboard entity always supplies a ready sphere; the @@ -155,13 +144,10 @@ export function createFlightState({ source, services }) { flightState._trackedModelLoading = false; - /** @type {Cesium.ScreenSpaceEventHandler|null} Click handler on the scene canvas */ + /** @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. */ @@ -183,7 +169,7 @@ export function createFlightState({ source, services }) { flightState._trail = null; - /** @type {Cesium.Entity|null} Cheap 2-point head segment bridging the last fix to the LIVE + /** @type {object|null} Cheap 2-point head segment bridging the last fix to the LIVE * dead-reckoned icon, updated per frame via a CallbackProperty — so the trail head stays * glued to the 12 Hz icon without rebuilding the 400-point trail primitive every frame. */ @@ -421,26 +407,15 @@ export function createFlightState({ source, services }) { /** IR hot-target mode (owner playtest 2026-08-16): the NVG/FLIR post-styles * map LUMINANCE, so mid-gray textured models read cold and vanish into * terrain. While a boost style is active every model renders flat white - * (hottest); per-spec color/tint restores on style exit. Driven by ui.js - * setStyle via the `irBoost` layer param. */ + * (hottest) and UNLIT (`unlit` in the model spec: a side-lit airframe under + * a high sun otherwise rendered near-black in FLIR/NVG); per-spec + * color/tint restores on style exit. Driven by ui.js setStyle via the + * `irBoost` layer param. 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 (see setParams). */ flightState._irBoost = false; - /** Boosted models render UNLIT (owner cockpit-FLIR field rounds, 2026-08-16): - * the white tint alone is applied to the MATERIAL, so Cesium still - * sun-shades it — near-horizon viewing shows a plane's SIDE, ~90° to a high - * sun, so it rendered near-BLACK in FLIR/NVG while sun-lit neighbors glowed. - * LightingModel.UNLIT emits the flat white directly, orientation be damned. - * CRITICAL (field-verified via scene.pick): assigning customShader to an - * already-READY model is a silent no-op — the property sets but the shader - * program never rebuilds. The boost therefore flips by RELEASE-AND-RELOAD - * (see setParams), so every boosted model gets the shader AT CREATION. - * One shared shader instance — stateless, safe across models. */ - - flightState._IR_UNLIT_SHADER = new Cesium.CustomShader({ - lightingModel: Cesium.LightingModel.UNLIT, - }); - flightState._irReloadQueue = null; return flightState; } diff --git a/src/layers/flights/testing.js b/src/layers/flights/testing.js index ccb109ae1..24e5fec18 100644 --- a/src/layers/flights/testing.js +++ b/src/layers/flights/testing.js @@ -1,5 +1,5 @@ -import * as Cesium from 'cesium'; import { CYAN_TRANSPARENT } from './policy.js'; +import { CYAN } from '../shared/color.js'; export function createTesting({ flightState, @@ -65,9 +65,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(); @@ -175,7 +177,7 @@ export function createTesting({ function _trackedBillboardColorForTest() { return parts.rendering._modelOwnsVisual(flightState._trackedIcao) ? CYAN_TRANSPARENT - : Cesium.Color.CYAN; + : CYAN; } /** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */ diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index 83b19b25f..6ecfe7543 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -1,4 +1,5 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { CYAN, nearFarScale } from '../shared/color.js'; import { appClock } from '../../time/clock.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; import { trackedModelZoomActive } from '../../data/trackedModelRegime.js'; @@ -10,11 +11,6 @@ import { trailHeadStart } from '../../data/modelVisualAnchor.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; import { routePlausible } from '../../data/routePlausible.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, @@ -38,15 +34,13 @@ export function createTracking({ } = services.context; const { isTr3b, tr3bTypeLabel, tr3bConvertedIds } = services.aircraftPresentation; - const { createTrail } = services.trails; const { ensureGeoidReady, geoidHeight } = services.geoid; const { resolveGroundFloorCellsBounded, floorAltitudeM, cachedGroundFloor } = services.groundFloor; const { clearFocusTarget } = services.focus; const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry; const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; - const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { selectionDispatcherFor } = services.selection; const flightsLayer = layer; function _emitAwarenessEvent(type, detail) { @@ -73,7 +67,7 @@ export function createTracking({ // a callsign-less contact reached Context as its raw hex even once adsbdb // had supplied a registration. Identity below stays `icao24`. label: parts.queries._contactLabel(icao24, info), - position: Cesium.Cartesian3.clone(bb.position), + position: geo.cartesian3.clone(bb.position), origin, }); selectTrackedSubjectContext(_contextSubjectMetadata(icao24)); @@ -147,10 +141,8 @@ export function createTracking({ */ 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; @@ -161,10 +153,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( @@ -195,7 +184,7 @@ export function createTracking({ // aircraft silhouette until a ready model takes over; far contacts are pips. // Never destroy on entry: tearing down hundreds of live glTF instances // synchronously blocked Chrome's renderer into Page Unresponsive. - 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) @@ -300,7 +289,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; @@ -309,13 +298,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, @@ -331,7 +320,7 @@ export function createTracking({ /** * 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) { @@ -382,11 +371,11 @@ export function createTracking({ const seedRenderTimeMs = appClock.now() - RENDER_DELAY_SEC * 1000; for (const fix of history) { if (fix.timeMs <= seedRenderTimeMs) { - flightState._trailPositions.push(Cesium.Cartesian3.clone(fix.position)); + flightState._trailPositions.push(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, }); @@ -396,60 +385,54 @@ export function createTracking({ // 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:fl-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 — which is ~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 - ]; - // 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. - // See trailHeadStart. Read `head` in place and clone only on the draw - // path — a suppressed parked contact runs this every frame. - const from = trailHeadStart( - start, - head, - parts.motion._trackedModelCenterWorld(), - parts.motion._trackedModelEnvelopeM(), - flightState._scratchTrailHead, - ); - if (!from) return []; - return [from, Cesium.Cartesian3.clone(head)]; - }, 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 — which is ~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]; + // 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. + // See trailHeadStart. Read `head` in place and clone only on the draw + // path — a suppressed parked contact runs this every frame. + const from = trailHeadStart( + start, + head, + parts.motion._trackedModelCenterWorld(), + parts.motion._trackedModelEnvelopeM(), + flightState._scratchTrailHead, + ); + if (!from) return []; + return [from, geo.cartesian3.clone(head)]; }, + 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(); @@ -547,7 +530,7 @@ export function createTracking({ // with nothing to carry keep the old 10 km airborne default. if (altM == null) altM = lastAltM != null ? lastAltM : 10000; lastAltM = altM; - older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM)); + older.push(geo.cartesian3.fromDegrees(lon, lat, altM)); } flightState._trailPositions = older.concat(flightState._trailPositions); @@ -567,17 +550,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; } @@ -653,11 +627,11 @@ export function createTracking({ // controls it now — clearing it would yank the camera off their plane). 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(); @@ -715,7 +689,7 @@ export function createTracking({ ) return false; if ( - !flightState._billboardCollection?.show || + !flightState._renderer?.fleet.visible() || !flightState._billboards.has(pending.id) ) return false; @@ -787,14 +761,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, ); } @@ -829,7 +805,7 @@ export function createTracking({ _syncTrackedBillboardImage(); _updateTrackedLabelModel(id); } - flightState._viewer?.scene?.requestRender?.(); + flightState._renderer?.view.requestRender(); return flightState._billboards.has(id) || id === flightState._trackedIcao; } @@ -850,15 +826,15 @@ export function createTracking({ const info = flightState.records.data.get(icao24); const bb = flightState._billboards.get(icao24); if (!info || !bb || !bb.position) return true; - const carto = Cesium.Cartographic.fromCartesian( + const carto = geo.cartographic.fromCartesian( bb.position, - Cesium.Ellipsoid.WGS84, + geo.ellipsoid.WGS84, flightState._scratchCarto, ); if (!carto) return true; return routePlausible({ - latDeg: Cesium.Math.toDegrees(carto.latitude), - lonDeg: Cesium.Math.toDegrees(carto.longitude), + latDeg: geo.math.toDegrees(carto.latitude), + lonDeg: geo.math.toDegrees(carto.longitude), altitudeM: info.altitude ?? null, verticalRateMps: info.verticalRate ?? null, origin: route.origin, @@ -909,11 +885,6 @@ export function createTracking({ const getTrackedPosition = () => parts.motion._trackedDisplayPosition(icao24) || bb.position; - // Dead-reckoning position property — smooth continuous motion between API updates. - const positionProperty = new Cesium.CallbackProperty(() => { - return getTrackedPosition(); - }, false); - // 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 // DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always @@ -929,68 +900,53 @@ export function createTracking({ // The tracked entity is a PURE BILLBOARD — no label or model graphic. The 3D model for the // tracked plane is a standalone primitive driven by _updateTrackedModel(); keeping it off the // entity is what makes the follow-camera's bounding sphere always ready (see _trackedModel). - // Keep Cesium's generated entity ID: re-init without destroy can temporarily - // overlap collections, and an explicit ICAO ID would throw on that duplicate. - flightState._trackedEntity = flightState._viewer.entities.add({ - position: positionProperty, - // Force Cesium's built-in EntityView and our close-range camera guard to - // use the same local frame. AUTO can select a velocity frame while the - // model matrix uses aircraft orientation; alternating between those - // frames makes the target oscillate forward/back on screen. - trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, - billboard: { - image: aircraftIcon( - parts.rendering._iconKind( - flightState._trackedIcao, - flightState.records.data.get(flightState._trackedIcao)?.klass, - ), - TRACKED_ICON_PX, + flightState._trackedEntity = flightState._renderer.tracked.create({ + // Dead-reckoning position — smooth continuous motion between API updates. + position: getTrackedPosition, + image: aircraftIcon( + parts.rendering._iconKind( + flightState._trackedIcao, + flightState.records.data.get(flightState._trackedIcao)?.klass, ), - width: 28, - height: 28, - scale: - CLASS_SCALE_2D[ - flightState.records.data.get(flightState._trackedIcao)?.klass - ] || 1, - // Solid cyan 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) - ? CYAN_TRANSPARENT - : Cesium.Color.CYAN, - 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 cyan 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: + CLASS_SCALE_2D[ + flightState.records.data.get(flightState._trackedIcao)?.klass + ] || 1, + // Solid cyan 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) + ? CYAN_TRANSPARENT + : CYAN, + 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 cyan 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; @@ -1008,11 +964,11 @@ export function createTracking({ Math.max((info.altitude || 1500) * 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 @@ -1029,15 +985,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 F1): seed from local history + async backfill. // Ground traffic draws NO trail (a taxi path is noise, not a track) — if the @@ -1077,12 +1029,12 @@ export function createTracking({ */ function _onMilitaryActiveChange(active) { - if (!flightState._viewer || !flightState._billboardCollection) return; + if (!flightState._renderer?.fleet.attached()) return; if (active) { for (const [icao24, bb] of flightState._billboards) { if (!isMilitaryIcao(icao24) || icao24 === flightState._trackedIcao) continue; - flightState._billboardCollection.remove(bb); + flightState._renderer.fleet.remove(bb); flightState._billboards.delete(icao24); parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too flightState.records.data.delete(icao24); @@ -1093,137 +1045,122 @@ export function createTracking({ flightState.records.missingPolls.delete(icao24); } flightState.feed._count = flightState._billboards.size; - } else if (flightState._billboardCollection.show) { + } else if (flightState._renderer.fleet.visible()) { // Fire-and-forget refresh; only while the layer is actually enabled. - void flightsLayer.update(flightState._viewer); + // The query reads the renderer's camera (no viewer argument needed). + void flightsLayer.update(null); } } /** - * Global keydown handler — Escape deselects the tracked flight. - * @param {KeyboardEvent} e + * Escape (through the selection dispatcher) deselects the tracked flight. */ - 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 flight selection. - * - * Picking logic checks both `picked.primitive` and `picked.id` because - * different CesiumJS versions surface BillboardCollection hits differently. - * Also registers a global keydown listener for the Escape key. - * - * Idempotent — returns immediately if a handler is already installed. - * - * @param {Cesium.Viewer} viewer + * 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 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 — returns immediately if already joined. + */ + + function _installClickHandler() { + const renderer = flightState._renderer; + if (!renderer) return; // Cross-layer untrack: if ANOTHER layer (military, vessels, …) grabs the follow-camera, drop our - // tracking so its model/entity/update-loop don't orphan — without touching viewer.trackedEntity + // tracking so its model/entity/update-loop don't orphan — without touching the follow target // (the new owner controls it). Guarded so the intermediate untrack→retrack of OUR OWN switch - // (viewer.trackedEntity briefly undefined) doesn't self-clear. + // (the follow target briefly empty) doesn't self-clear. 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. A globe click must not fall through to the - // normal empty-space deselection path; cockpit has explicit exit controls. - 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; - } - - // For BillboardCollection picks, the billboard may be at picked.primitive or picked.id - const billboard = picked.primitive; - if ( - billboard && - billboard.id && - flightState._billboards.has(billboard.id) - ) { - _cancelPendingTrackingRestore(); - _trackFlight(billboard.id, { origin: 'user' }); - return; - } - // Some CesiumJS versions surface the id as a string on picked.id instead - 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 (military aircraft, 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('flights', 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('flights', { + // 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, @@ -1256,7 +1193,10 @@ export function createTracking({ _routeIsPlausible, _trackFlight, _onMilitaryActiveChange, - _onKeyDown, + _onEscape, + _onSelect, + _onEmptyClick, _installClickHandler, + _removeClickHandler, }; } diff --git a/src/layers/shared/color.js b/src/layers/shared/color.js new file mode 100644 index 000000000..393874a28 --- /dev/null +++ b/src/layers/shared/color.js @@ -0,0 +1,88 @@ +// Engine-neutral layer colours (phase 3): the colour constants the layers +// used to build as Cesium.Color, without Cesium. +// +// An Rgba has Cesium.Color's fields and the methods the layers call on their +// colours (`withAlpha`, `clone`, `equals`), so it is accepted wherever a +// Cesium property clones a colour from its fields (Billboard.color, +// Model.color, a billboard graphics' colour), and a renderer on another +// engine reads the same four numbers (`rgba()` gives them as an array). +// Components are 0..1 floats computed exactly as Cesium computes them +// (`fromCssColorString`: byte / 255), so a constant here equals the +// Cesium.Color it replaces bit for bit (color.test.mjs). + +export class Rgba { + constructor(red = 1, green = 1, blue = 1, alpha = 1) { + this.red = red; + this.green = green; + this.blue = blue; + this.alpha = alpha; + } + + /** The same colour with `alpha` (Cesium.Color#withAlpha). */ + withAlpha(alpha, result) { + if (!result) return new Rgba(this.red, this.green, this.blue, alpha); + result.red = this.red; + result.green = this.green; + result.blue = this.blue; + result.alpha = alpha; + return result; + } + + clone(result) { + return this.withAlpha(this.alpha, result); + } + + /** Field equality with an Rgba or a Cesium.Color. */ + equals(other) { + return ( + this === other || + (!!other && + this.red === other.red && + this.green === other.green && + this.blue === other.blue && + this.alpha === other.alpha) + ); + } + + /** `[red, green, blue, alpha]`, 0..1. */ + rgba() { + return [this.red, this.green, this.blue, this.alpha]; + } +} + +const HEX = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$/i; + +/** + * A colour from `#rrggbb` or `#rrggbbaa`, as Cesium.Color.fromCssColorString + * reads it. + * @param {string} css + * @returns {Rgba} + */ +export function rgbaFromCss(css) { + const match = HEX.exec(String(css).trim()); + if (!match) throw new TypeError(`Unsupported colour: ${css}`); + return new Rgba( + parseInt(match[1], 16) / 255.0, + parseInt(match[2], 16) / 255.0, + parseInt(match[3], 16) / 255.0, + parseInt(match[4] ?? 'ff', 16) / 255.0, + ); +} + +/** A frozen colour constant. */ +export const constantRgba = (red, green, blue, alpha = 1) => + Object.freeze(new Rgba(red, green, blue, alpha)); + +/** Cesium.Color.WHITE. */ +export const WHITE = constantRgba(1, 1, 1, 1); +/** Cesium.Color.CYAN (#00FFFF). */ +export const CYAN = constantRgba(0, 1, 1, 1); + +/** + * A distance scale for sprites (Cesium.NearFarScalar's fields): the scale is + * `nearValue` at `near` metres from the camera and `farValue` at `far`. + * Plain data, accepted by Cesium's billboard setters, which copy the fields. + */ +export function nearFarScale(near, nearValue, far, farValue) { + return { near, nearValue, far, farValue }; +} diff --git a/src/layers/shared/color.test.mjs b/src/layers/shared/color.test.mjs new file mode 100644 index 000000000..9ccaf0f45 --- /dev/null +++ b/src/layers/shared/color.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + CYAN, + Rgba, + WHITE, + constantRgba, + nearFarScale, + rgbaFromCss, +} from './color.js'; + +const fields = (color) => [color.red, color.green, color.blue, color.alpha]; + +test('css colours equal Cesium.Color.fromCssColorString bit for bit', () => { + for (const css of ['#FFB800', '#DCEEFF', '#00d4ff', '#39d0ff', '#ffd16680']) { + assert.deepEqual( + fields(rgbaFromCss(css)), + fields(Cesium.Color.fromCssColorString(css)), + css, + ); + } + assert.throws(() => rgbaFromCss('teal'), /Unsupported colour/); +}); + +test('the named constants equal Cesium.Color.WHITE and CYAN', () => { + assert.deepEqual(fields(WHITE), fields(Cesium.Color.WHITE)); + assert.deepEqual(fields(CYAN), fields(Cesium.Color.CYAN)); + assert.ok(Object.isFrozen(WHITE) && Object.isFrozen(CYAN)); + assert.ok(CYAN.equals(Cesium.Color.CYAN)); +}); + +test('withAlpha, clone and equals behave as Cesium.Color', () => { + const amber = rgbaFromCss('#FFB800'); + const cesium = Cesium.Color.fromCssColorString('#FFB800'); + assert.deepEqual( + fields(amber.withAlpha(0.45)), + fields(cesium.withAlpha(0.45)), + ); + const scratch = new Rgba(); + assert.equal(amber.withAlpha(0.2, scratch), scratch); + assert.deepEqual(fields(scratch), fields(cesium.withAlpha(0.2))); + assert.deepEqual( + fields(CYAN.withAlpha(0)), + fields(Cesium.Color.CYAN.withAlpha(0)), + ); + assert.ok(amber.clone().equals(amber)); + assert.notEqual(amber.clone(), amber); + assert.deepEqual(amber.rgba(), fields(cesium)); + assert.ok(Object.isFrozen(constantRgba(1, 0, 0))); +}); + +test('Cesium billboards and models take an Rgba as their colour', () => { + const collection = new Cesium.BillboardCollection(); + const billboard = collection.add({ + position: Cesium.Cartesian3.fromDegrees(0, 0, 10), + color: rgbaFromCss('#FFB800').withAlpha(0.5), + scaleByDistance: nearFarScale(1000, 3, 8_000_000, 0.5), + }); + assert.ok(billboard.color instanceof Cesium.Color); + assert.ok( + billboard.color.equals( + Cesium.Color.fromCssColorString('#FFB800').withAlpha(0.5), + ), + ); + assert.ok(billboard.scaleByDistance instanceof Cesium.NearFarScalar); + assert.ok( + Cesium.NearFarScalar.equals( + billboard.scaleByDistance, + new Cesium.NearFarScalar(1000, 3, 8_000_000, 0.5), + ), + ); + billboard.color = CYAN; + assert.ok(billboard.color.equals(Cesium.Color.CYAN)); + assert.notEqual(billboard.color, CYAN, 'the billboard keeps its own copy'); + collection.destroy(); +}); diff --git a/src/testSupport/readLayerSource.mjs b/src/testSupport/readLayerSource.mjs index 4d07e0407..11daa5e48 100644 --- a/src/testSupport/readLayerSource.mjs +++ b/src/testSupport/readLayerSource.mjs @@ -1,4 +1,4 @@ -import { readFileSync, readdirSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -45,9 +45,19 @@ export function readLayerSource(file) { ? '../layers/cctv' : '../layers/vessels', ); - return readdirSync(directory) - .filter((name) => name.endsWith('.js')) - .sort() - .map((name) => readFileSync(join(directory, name), 'utf8')) + // A layer's code includes its renderers (render/*.js, phase 3) and, for + // the aircraft layers, the shared aircraft renderer they draw through: + // checks on what the layer does keep finding it where it moved. + const directories = [directory, join(directory, 'render')]; + if (['flights.js', 'militaryFlights.js'].includes(basename(path))) + directories.push(join(directory, '../aircraft/render')); + return directories + .filter((dir) => existsSync(dir)) + .flatMap((dir) => + readdirSync(dir) + .filter((name) => name.endsWith('.js')) + .sort() + .map((name) => readFileSync(join(dir, name), 'utf8')), + ) .join('\n'); } diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index fefd07c0f..22ff1be3e 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -296,6 +296,43 @@ test('the app reaches the Navara engine only through a dynamic import', (t) => { ); }); +test('layers moved onto renderers keep each engine in its render file', (t) => { + const { root, write } = fixture(t); + write('src/layers/flights/rendering.js', "import './render/cesium.js';"); + write('src/layers/flights/render/cesium.js', "import 'cesium';"); + write( + 'src/layers/flights/render/navara.js', + "import 'three'; import '@navaramap/three';", + ); + write('src/layers/aircraft/render/cesium.js', "import 'cesium';"); + write('src/layers/flights/render/cesium.test.mjs', "import 'cesium';"); + write( + 'src/app/navaraMode.js', + "export const load = () => import('../layers/flights/render/navara.js');", + ); + // A layer not moved yet still imports cesium anywhere (until its *a task). + write('src/layers/vessels/rendering.js', "import 'cesium';"); + assert.doesNotThrow(() => checkImportDirections(root)); + + write('src/layers/flights/tracking.js', "import 'cesium';"); + assert.throws( + () => checkImportDirections(root), + /src\/layers\/flights\/tracking\.js: cesium only in the layer's render\/cesium\*\.js: cesium/, + ); + write('src/layers/flights/tracking.js', ''); + write('src/layers/aircraft/render/cesium.js', "import 'three';"); + assert.throws( + () => checkImportDirections(root), + /src\/layers\/aircraft\/render\/cesium\.js: three and Navara only in the layer's render\/navara\*\.js: three/, + ); + write('src/layers/aircraft/render/cesium.js', ''); + write('src/app/data.js', "import '../layers/flights/render/navara.js';"); + assert.throws( + () => checkImportDirections(root), + /src\/app\/data\.js: A Navara layer renderer loads only through the Navara mode's dynamic import\(\): src\/layers\/flights\/render\/navara\.js/, + ); +}); + test('geo modules may import each other, and its tests may import cesium', (t) => { const { root, write } = fixture(t); write('src/geo/math.js', 'export const half = (value) => value / 2;');