diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 630c7bb49..cae59345b 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -36,6 +36,7 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/; const RENDERED_LAYER_DIRS = [ 'src/layers/flights/', 'src/layers/military/', + 'src/layers/localAdsb/', 'src/layers/aircraft/', 'src/layers/shared/', ]; diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b2139818d..63443142a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -885,6 +885,7 @@ "src/sdr/usbDevices.test.mjs", "src/sources/adsbRecords.test.mjs", "src/layers/localAdsb/index.test.mjs", + "src/layers/localAdsb/render/cesium.js", "src/ui/localSdrControls.test.mjs", "src/ui/localSdrMarkup.test.mjs", "src/ui/localSdrPresentation.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index cb6ef8514..331d523fd 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2600,7 +2600,8 @@ "src/layers/aircraft/render/navara.js", "src/layers/flights/render/navara.js", "src/layers/military/render/navara.js", - "src/layers/aircraft/qaDrawn.js" + "src/layers/aircraft/qaDrawn.js", + "src/layers/localAdsb/render/cesium.js" ], "external": [ "hls.js", @@ -3177,7 +3178,8 @@ "src/layers/shared/color.js", "src/layers/flights/render/cesium.js", "src/layers/military/render/cesium.js", - "src/layers/aircraft/qaDrawn.js" + "src/layers/aircraft/qaDrawn.js", + "src/layers/localAdsb/render/cesium.js" ], "external": [ "hls.js", diff --git a/src/app/layers/localAdsb.js b/src/app/layers/localAdsb.js index c6e57b3a9..aed095062 100644 --- a/src/app/layers/localAdsb.js +++ b/src/app/layers/localAdsb.js @@ -4,6 +4,9 @@ import { SdrController } from '../../sdr/controller.js'; import * as render from '../../renderGovernor.js'; import * as context from '../../data/contextStore.js'; import * as picking from '../../data/pickRegistry.js'; +import * as selection from '../../data/selectionDispatcher.js'; +import { rendererFor } from './renderers.js'; +import { createLocalAdsbCesiumRenderer } from '../../layers/localAdsb/render/cesium.js'; import * as trails from '../../data/trailRenderer.js'; import * as geoid from '../../data/geoid.js'; import { markDetectionSourcesChanged } from '../../data/detection.js'; @@ -27,6 +30,13 @@ export function createApplicationLocalAdsb({ displayParams = null, resolveAsset = (url) => `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, + // Test seam: the renderer factory (default: the engine's, from the registry). + renderer = { + create: ({ engine = null, ...options }) => + engine + ? rendererFor('local-adsb', engine, options) + : createLocalAdsbCesiumRenderer(null, options), + }, } = {}) { return createLocalAdsbLayer({ receiver, @@ -36,6 +46,11 @@ export function createApplicationLocalAdsb({ render, context, picking, + selection, + // The engine's Local ADS-B renderer from the registry (render/cesium.js + // on Cesium); a bare viewer without an engine (unit tests) draws on + // Cesium. + renderer, trails, geoid, groundSnap: surface?.groundSnap || null, diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 95fb252a8..3817a8f5e 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -26,6 +26,7 @@ import { APPLICATION_LAYER_DIRECTORY } from './directory.js'; import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js'; import { createMilitaryCesiumRenderer } from '../../layers/military/render/cesium.js'; +import { createLocalAdsbCesiumRenderer } from '../../layers/localAdsb/render/cesium.js'; const ENGINE_KINDS = new Set(['cesium', 'navara']); @@ -87,6 +88,7 @@ export const layerAvailableOn = (layerId, engineKind) => export const CESIUM_LAYER_RENDERERS = Object.freeze({ flights: createFlightsCesiumRenderer, military: createMilitaryCesiumRenderer, + 'local-adsb': createLocalAdsbCesiumRenderer, }); // The other application layers still draw themselves on Cesium. diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index a45784824..b21d1ffd1 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -56,10 +56,11 @@ test('the default registry: every layer on Cesium, none yet on Navara', () => { } }); -test('flights and military have extracted Cesium renderers; the others still draw themselves', () => { +test('the aircraft layers have extracted Cesium renderers; the others still draw themselves', () => { assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [ 'flights', 'military', + 'local-adsb', ]); const viewer = { scene: {}, camera: {} }; const renderer = rendererFor('flights', { kind: 'cesium' }, { viewer }); diff --git a/src/data/cockpitModels.test.mjs b/src/data/cockpitModels.test.mjs index 36f8b24d3..13e432229 100644 --- a/src/data/cockpitModels.test.mjs +++ b/src/data/cockpitModels.test.mjs @@ -29,7 +29,10 @@ for (const layer of LAYERS) { const source = readLayerSource(layer.path); test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => { - const calls = [...source.matchAll(/Cesium\.Model\.fromGltfAsync\(\{([\s\S]*?)\}\)/g)]; + // The aircraft renderer loads through its `loadModel`, Cesium.Model.fromGltfAsync + // unless a test injects a loader (phase 3). + assert.match(source, /loadModel = \(options\) => Cesium\.Model\.fromGltfAsync\(options\)/); + const calls = [...source.matchAll(/(?:Cesium\.Model\.fromGltfAsync|\bloadModel)\(\{([\s\S]*?)\}\)/g)]; assert.ok(calls.length >= 3, `expected fleet, tracked, and preload model calls; found ${calls.length}`); for (const [index, call] of calls.entries()) { assert.match(call[1], /\basynchronous:\s*false\b/, diff --git a/src/data/selectionDispatcher.js b/src/data/selectionDispatcher.js index b15ae1b81..0bf2a8dcd 100644 --- a/src/data/selectionDispatcher.js +++ b/src/data/selectionDispatcher.js @@ -10,10 +10,16 @@ // onEscape() Escape. // The rules are the old handlers': a pick another layer owns is not empty // space (a pick nobody owns is); `passThrough` picks (trails) never take the -// click, the next pick does; a `drillThrough` owner (local ADS-B) takes its object from under -// another layer's; a drag selects nothing; a pointer owned by a tool +// click, the next pick does; a `drillThrough` owner takes its object from +// under another layer's; a drag selects nothing; a pointer owned by a tool // (inputOwnership) selects nothing. Layers not moved yet keep their own // handlers: the dispatcher never acts on an id it does not own. +// A `lookUnder` owner (local ADS-B) keeps its own old click rule instead: +// its object on top, or under picks no layer owns and picks it looks under +// (`lookUnder(id)`: trails), is selected; another layer's object on top, or +// nothing, is empty space for it (it releases its selection); a click that +// only meets what it looks under does nothing; no gesture rule (its old +// handler took every click). To the others it is another layer. import { isPointerFree as pointerFree } from './inputOwnership.js'; import { @@ -75,6 +81,31 @@ export function createSelectionDispatcher( return { id, owner: findPickOwner(id) }; }; + // The look-under rule (local ADS-B's old click handler, kept as is). + const lookUnderTarget = (name, owner, event) => { + const ownedElsewhere = (id) => { + if (id === null) return false; + for (const [other, entry] of owners) + if (other !== name && safely(() => entry.owns(id))) return true; + const found = findPickOwner(id); + return found !== null && found !== undefined && found !== name; + }; + const mine = (id) => id !== null && safely(() => owner.owns(id)); + const under = (id) => id !== null && safely(() => owner.lookUnder(id)); + const top = resolvePickId(event.picked); + if (mine(top)) return { id: top, picked: event.picked }; + const lookedUnder = under(top); + if (ownedElsewhere(top) && !lookedUnder) return 'other'; + for (const hit of event.drilled ?? []) { + const id = resolvePickId(hit); + if (mine(id)) return { id, picked: hit }; + if (under(id)) continue; + if (ownedElsewhere(id)) return 'other'; + } + return lookedUnder ? 'under' : null; + }; + const plain = (owner) => typeof owner.lookUnder !== 'function'; + const onClick = (event) => { if (press) finish(event.position); const gesture = completed ?? { travelPx: 0, durationMs: 0 }; @@ -86,6 +117,14 @@ export function createSelectionDispatcher( drilled: event.drilled ?? [], gesture, }; + for (const [name, owner] of [...owners]) { + if (plain(owner)) continue; + const target = lookUnderTarget(name, owner, event); + if (target === 'under') continue; + if (target && target !== 'other') + owner.onSelect?.(target.id, { ...ctx, picked: target.picked }); + else owner.onEmptyClick?.(ctx); + } const candidates = [event.picked, ...(event.drilled ?? [])].filter( (picked) => picked && @@ -104,15 +143,16 @@ export function createSelectionDispatcher( }); if (!picks.length) { if (!isTrackingClickGesture(gesture)) return; - for (const owner of [...owners.values()]) owner.onEmptyClick?.(ctx); + for (const owner of [...owners.values()].filter(plain)) + owner.onEmptyClick?.(ctx); return; } if (!isTrackingSelectionGesture(gesture)) return; const first = ownerOf(picks[0]); if (owners.has(first.owner)) { - owners - .get(first.owner) - .onSelect?.(first.id, { ...ctx, picked: picks[0] }); + const owner = owners.get(first.owner); + if (plain(owner)) + owner.onSelect?.(first.id, { ...ctx, picked: picks[0] }); return; } // Another layer's object on top: a drill-through owner may still take @@ -120,7 +160,7 @@ export function createSelectionDispatcher( for (const picked of picks.slice(1)) { const below = ownerOf(picked); const owner = owners.get(below.owner); - if (owner?.drillThrough) { + if (owner?.drillThrough && plain(owner)) { owner.onSelect?.(below.id, { ...ctx, picked }); return; } @@ -128,7 +168,8 @@ export function createSelectionDispatcher( // A pick no layer owns (an unclaimed entity) is empty space, as it was // for the old handlers: only another layer's object is not. if (first.owner === null && isTrackingClickGesture(gesture)) - for (const owner of [...owners.values()]) owner.onEmptyClick?.(ctx); + for (const owner of [...owners.values()].filter(plain)) + owner.onEmptyClick?.(ctx); }; const onKey = (event) => { @@ -163,7 +204,8 @@ export function createSelectionDispatcher( /** * @param {string} name the owning layer's id * @param {{ owns: (id: string) => boolean, onSelect?: Function, onEmptyClick?: Function, - * onEscape?: Function, passThrough?: (picked: object) => boolean, drillThrough?: boolean }} owner + * onEscape?: Function, passThrough?: (picked: object) => boolean, drillThrough?: boolean, + * lookUnder?: (id: string) => boolean }} owner * @returns {() => void} unregister */ register(name, owner) { diff --git a/src/data/selectionDispatcher.test.mjs b/src/data/selectionDispatcher.test.mjs index fdf5bce14..e1ad5ce0b 100644 --- a/src/data/selectionDispatcher.test.mjs +++ b/src/data/selectionDispatcher.test.mjs @@ -205,3 +205,54 @@ test('the application has one dispatcher per engine', () => { selectionDispatcherFor(engine).destroy(); selectionDispatcherFor(other).destroy(); }); + +test('a look-under owner (local ADS-B) keeps its own click rule beside the others', () => { + // x: what lies under each column, top first. + const { dispatcher, click } = setup({ + 10: [{ id: 'local-adsb:a' }], // its own marker + 20: [{ primitive: { tileset: true } }, { id: 'local-adsb:a' }], // under a tile + 30: [{ id: 'gev-trail:local-adsb-head-1' }, { id: 'local-adsb:b' }], // under a trail + 40: [{ id: 'gev-trail:x' }], // a trail alone + 50: [{ id: 'ae01ce' }, { id: 'local-adsb:a' }], // another layer's on top + 60: [{ id: 'cctv-1' }, { id: 'local-adsb:a' }], // a registry owner's on top + 70: [], // empty map + }); + registerPickOwner('trails', (id) => id.startsWith('gev-trail:')); + registerPickOwner('cctv', (id) => id.startsWith('cctv')); + try { + const local = recorder(); + const flights = recorder(); + dispatcher.register('local-adsb', { + owns: (id) => id.startsWith('local-adsb:'), + lookUnder: (id) => id.startsWith('gev-trail:'), + ...local.handlers, + }); + dispatcher.register('flights', { + owns: (id) => id === 'ae01ce', + ...flights.handlers, + }); + for (const x of [10, 20, 30, 40, 50, 60, 70]) click(x); + // A long press on empty map still reaches it (its old LEFT_CLICK rule). + click(70, { ms: 900 }); + assert.deepEqual(local.calls, [ + ['select', 'local-adsb:a', undefined], + ['select', 'local-adsb:a', undefined], + ['select', 'local-adsb:b', undefined], + // 40: a trail alone is not empty map: nothing. + ['empty', 0], // 50: another layer's contact releases local's selection + ['empty', 0], // 60: the same for a registry owner's + ['empty', 0], // 70 + ['empty', 0], // 70, long press + ]); + // Flights, as before: its own contact at 50 and empty map at 70 (the + // long press does not deselect); a local marker (even under a tile), a + // trail or another owner's object is not its empty space. + assert.deepEqual(flights.calls, [ + ['select', 'ae01ce', undefined], + ['empty', 0], + ]); + } finally { + unregisterPickOwner('trails'); + unregisterPickOwner('cctv'); + } +}); diff --git a/src/data/trackingClickGesture.test.mjs b/src/data/trackingClickGesture.test.mjs index c38041382..52a8af792 100644 --- a/src/data/trackingClickGesture.test.mjs +++ b/src/data/trackingClickGesture.test.mjs @@ -120,7 +120,7 @@ test('civilian and military click handlers apply duration only at the deselect b // each layer answers with a user-origin clear. const dispatcher = readFileSync(new URL('./selectionDispatcher.js', import.meta.url), 'utf8'); assert.match(dispatcher, /if \(!isTrackingSelectionGesture\(gesture\)\) return;[\s\S]+onSelect/); - assert.match(dispatcher, /if \(!isTrackingClickGesture\(gesture\)\) return;\s*for \(const owner of \[\.\.\.owners\.values\(\)\]\) owner\.onEmptyClick/); + assert.match(dispatcher, /if \(!isTrackingClickGesture\(gesture\)\) return;\s*for \(const owner of \[\.\.\.owners\.values\(\)\]\.filter\(plain\)\)\s*owner\.onEmptyClick/); for (const [layer, source] of [['flights', sources[0]], ['military', sources[1]]]) { assert.match(source, new RegExp(`dispatcher\\.register\\('${layer}', \\{[\\s\\S]+onEmptyClick: _onEmptyClick`)); assert.match(source, /function _onEmptyClick\(\) \{[\s\S]+?_clearTracking\(false, \{ origin: 'user' \}\)/); diff --git a/src/layers/aircraft/render/cesium.js b/src/layers/aircraft/render/cesium.js index b7d5f2c0b..8a03dca4d 100644 --- a/src/layers/aircraft/render/cesium.js +++ b/src/layers/aircraft/render/cesium.js @@ -65,7 +65,10 @@ const cesiumNearFar = (scalar) => * @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. + * Drive existing collections instead of creating them (test fakes; a + * layer that keeps its own model collection, local ADS-B). + * @param {(options: object) => Promise} [options.loadModel] The glTF + * loader (test seam; Cesium.Model.fromGltfAsync). * @returns {import('./contract.js').AircraftRenderer} */ export function createAircraftCesiumRenderer({ @@ -74,6 +77,7 @@ export function createAircraftCesiumRenderer({ spriteLayer, headingOffsetDeg = 0, adopt = null, + loadModel = (options) => Cesium.Model.fromGltfAsync(options), }) { let billboards = adopt?.billboardCollection ?? null; let models = adopt?.modelCollection ?? null; @@ -202,7 +206,7 @@ export function createAircraftCesiumRenderer({ unlit, id, }) => - Cesium.Model.fromGltfAsync({ + loadModel({ url, asynchronous: false, minimumPixelSize, @@ -222,7 +226,7 @@ export function createAircraftCesiumRenderer({ unlit, id, }) => - Cesium.Model.fromGltfAsync({ + loadModel({ url, asynchronous: false, minimumPixelSize, @@ -237,7 +241,7 @@ export function createAircraftCesiumRenderer({ }), // Warms Cesium's glTF cache (the retained instance is never drawn). preload: (url) => - Cesium.Model.fromGltfAsync({ + loadModel({ url, asynchronous: false, }), diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index fcf54b5d5..b92a2f34c 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -1,12 +1,11 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { aircraftIcon } from '../../data/aircraftIcons.js'; import { - horizonOccluder, screenProjectedRotation, stabilizeScreenRotation, } from '../../data/iconOrientation.js'; -import { isPointerFree } from '../../data/inputOwnership.js'; +import { rgbaFromCss } from '../shared/color.js'; import { pickRenderAltitudeM } from '../../data/renderAltitude.js'; import { routePlausible } from '../../data/routePlausible.js'; import { @@ -32,7 +31,6 @@ import { LOCAL_ADSB_COLOR, LOCAL_ADSB_SYNC_MS, LOCAL_ADSB_TICK_MS, - LOCAL_ADSB_UAT_RING_COLOR, } from './policy.js'; import { localAdsbStatus } from './status.js'; @@ -52,10 +50,10 @@ export { localAdsbStatus } from './status.js'; const FEET_TO_METERS = 0.3048; const RENDER_HOLD_ID = 'local-adsb'; -/** Picks looked through under a click for a local aircraft. */ -const CLICK_DRILL_LIMIT = 8; /** Trail lines pass through their aircraft; a click never stops on one. */ const TRAIL_PICK_PREFIX = 'gev-trail:'; +/** The model tint (the layer's magenta), engine-neutral. */ +const MODEL_COLOR = Object.freeze(rgbaFromCss(LOCAL_ADSB_COLOR)); /** The DISPLAY rail's first-run 3D preference, used when no reader is wired. */ const DEFAULT_DISPLAY = Object.freeze({ models3d: true, @@ -86,18 +84,18 @@ const DEFAULT_DISPLAY = Object.freeze({ * @param {object} options.receiver Local receiver session. * @param {object} [options.feeds] Decoder-feed session (start, stop, * getState, subscribe). - * @param {object} options.services Render, context, picking, detection and - * readout operations supplied by the application; optionally `display` - * (`getParams()` → `{ models3d, models3dMode }`), `enrichment` (a source - * with `getEnrichment`), `trails` (`createTrail`), `geoid` - * (`ensureGeoidReady`, `geoidHeight`) and `groundSnap` + * @param {object} options.services Render, context, picking, selection + * (`selectionDispatcherFor`), renderer (`create({ viewer, engine, + * loadModel, createTrail })`: the engine's Local ADS-B renderer), + * detection and readout operations supplied by the application; + * optionally `display` (`getParams()` → `{ models3d, models3dMode }`), + * `enrichment` (a source with `getEnrichment`), `trails` (`createTrail`), + * `geoid` (`ensureGeoidReady`, `geoidHeight`) and `groundSnap` * (`createGroundSnap`). * @param {() => number} [options.now] Clock, injectable for tests. * @param {(url: string) => string} [options.resolveAsset] Model URL resolver. * @param {(options: object) => Promise} [options.loadModel] Model * loader, injectable for tests. - * @param {(canvas: HTMLCanvasElement) => object} [options.createInputHandler] - * Click-handler factory, injectable for tests. * @returns {object} Data-layer module. */ export function createLocalAdsbLayer({ @@ -107,7 +105,6 @@ export function createLocalAdsbLayer({ now = Date.now, resolveAsset = (url) => url, loadModel, - createInputHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), }) { if (typeof receiver?.getState !== 'function') throw new TypeError('Local ADS-B requires a receiver session'); @@ -124,19 +121,15 @@ export function createLocalAdsbLayer({ removeEntityContextsForLayer, } = services.context; const { registerPickOwner, unregisterPickOwner } = services.picking; - const color = Cesium.Color.fromCssColorString(LOCAL_ADSB_COLOR); - const uatRingColor = Cesium.Color.fromCssColorString( - LOCAL_ADSB_UAT_RING_COLOR, - ); - let viewer = null; - let dataSource = null; + /** The engine's Local ADS-B renderer for this lifecycle (render/*.js). */ + let renderer = null; let enabled = false; let unsubscribe = null; let unsubscribeFeeds = null; let tickTimer = null; let syncTimer = null; - let clickHandler = null; + let selectionRemove = null; let removePreRender = null; let renderHeld = false; let selectedId = null; @@ -181,14 +174,14 @@ export function createLocalAdsbLayer({ } function markerVisible(marker) { - if (!viewer?.camera?.positionWC || !marker.position) return true; - return horizonOccluder(viewer.camera).isPointVisible(marker.position); + if (!renderer?.view.camera?.positionWC || !marker.position) return true; + return renderer.view.horizon().isPointVisible(marker.position); } function markerRotation(marker) { - if (!viewer?.scene || !marker.position) return marker.lastRotation; + if (!renderer?.view.scene || !marker.position) return marker.lastRotation; const projected = screenProjectedRotation( - viewer.scene, + renderer.view.scene, marker.position, marker.courseDeg ?? 0, marker.lastRotation, @@ -226,7 +219,7 @@ export function createLocalAdsbLayer({ } function fixPosition(marker, fix) { - return Cesium.Cartesian3.fromDegrees( + return geo.cartesian3.fromDegrees( fix.lon, fix.lat, renderHeightM(marker, fix.altitudeFt, fix.lat, fix.lon), @@ -238,12 +231,12 @@ export function createLocalAdsbLayer({ if (!display) return; marker.lat = display.lat; marker.lon = display.lon; - marker.position = Cesium.Cartesian3.fromDegrees( + marker.position = geo.cartesian3.fromDegrees( display.lon, display.lat, renderHeightM(marker, display.altitudeFt, display.lat, display.lon), - Cesium.Ellipsoid.WGS84, - marker.position || new Cesium.Cartesian3(), + geo.ellipsoid.WGS84, + marker.position || geo.cartesian3.create(), ); if (display.courseDeg !== null) marker.courseDeg = display.courseDeg; } @@ -262,16 +255,15 @@ export function createLocalAdsbLayer({ ); marker.evidence = evidence; marker.klass = klass; - const billboard = marker.entity?.billboard; - if (!billboard) return; + if (!marker.entity || !renderer) return; if (marker.iconKind !== klass) { marker.iconKind = klass; - billboard.image = aircraftIcon(klass); + renderer.markers.setImage(marker.entity, aircraftIcon(klass)); } const scale = billboardScale(marker); if (marker.iconScale !== scale) { marker.iconScale = scale; - billboard.scale = scale; + renderer.markers.setScale(marker.entity, scale); } } @@ -309,7 +301,7 @@ export function createLocalAdsbLayer({ return { id, layerId: LAYER_ID, - dataSource, + dataSource: renderer?.dataSource ?? null, layerName: LAYER_NAME, source: localAdsbSourceText(record), label: localAdsbTitle(record), @@ -334,34 +326,12 @@ export function createLocalAdsbLayer({ }; } - function uatRing(marker) { - return new Cesium.PointGraphics({ - pixelSize: 24, - color: Cesium.Color.TRANSPARENT, - outlineColor: uatRingColor, - outlineWidth: 1.5, - scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5), - show: new Cesium.CallbackProperty(() => markerVisible(marker), false), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - distanceDisplayCondition: new Cesium.DistanceDisplayCondition( - 0, - 2_000_000, - ), - }); - } - // ── Trail of the selected aircraft ────────────────────────────────────── function releaseTrail() { if (!selectedTrail) return; selectedTrail.trail?.destroy(); - if (selectedTrail.head && viewer && !viewer.isDestroyed?.()) { - try { - viewer.entities.remove(selectedTrail.head); - } catch { - /* torn down */ - } - } + if (selectedTrail.head) renderer?.trails.removeHead(selectedTrail.head); selectedTrail = null; } @@ -377,31 +347,30 @@ export function createLocalAdsbLayer({ function startTrail(marker) { releaseTrail(); - const createTrail = services.trails?.createTrail; - if (typeof createTrail !== 'function' || !viewer?.entities) return; + const trail = renderer?.trails.create({ + color: LOCAL_ADSB_COLOR, + width: 2.5, + }); + if (!trail) return; selectedTrail = { id: marker.id, - trail: createTrail(viewer, { color: LOCAL_ADSB_COLOR, width: 2.5 }), + trail, head: null, lastFix: null, }; // Live head: last heard fix → the extrapolated marker, every frame. - selectedTrail.head = viewer.entities.add({ + selectedTrail.head = renderer.trails.createHead({ // 'gev-trail' namespace: claimed by the trail pick owner so a click on // the head segment never reads as empty space. id: `gev-trail:local-adsb-head-${++trailHeadSeq}`, - polyline: { - positions: new Cesium.CallbackProperty(() => { - const current = markers.get(selectedTrail?.id); - const from = selectedTrail?.lastFix; - if (!current?.position || !from) return []; - return [from, Cesium.Cartesian3.clone(current.position)]; - }, false), - width: 2.5, - material: color.withAlpha(0.9), - depthFailMaterial: color.withAlpha(0.45), - arcType: Cesium.ArcType.GEODESIC, + positions: () => { + const current = markers.get(selectedTrail?.id); + const from = selectedTrail?.lastFix; + if (!current?.position || !from) return []; + return [from, geo.cartesian3.clone(current.position)]; }, + color: LOCAL_ADSB_COLOR, + width: 2.5, }); refreshTrail(); } @@ -439,40 +408,22 @@ export function createLocalAdsbLayer({ rejectedFixes += marker.motion.rejectedFixes - rejectedBefore; updateDisplay(marker, at); if (!marker.entity) { - marker.entity = dataSource.entities.add({ + // Magenta glyph, 20 px, ×3 near / ×0.5 far, always on top, not drawn + // past 2,000 km (the renderer's display distance). + marker.entity = renderer.markers.add({ id, - position: new Cesium.CallbackProperty(() => marker.position, false), - billboard: { - image: aircraftIcon('airliner'), - width: 20, - height: 20, - scale: 1, - color, - sizeInMeters: false, - scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5), - alignedAxis: Cesium.Cartesian3.ZERO, - rotation: new Cesium.CallbackProperty( - () => markerRotation(marker), - false, - ), - show: new Cesium.CallbackProperty( - () => !marker.modelOwnsVisual && markerVisible(marker), - false, - ), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - distanceDisplayCondition: new Cesium.DistanceDisplayCondition( - 0, - 2_000_000, - ), - }, + image: aircraftIcon('airliner'), + position: () => marker.position, + rotation: () => markerRotation(marker), + show: () => !marker.modelOwnsVisual && markerVisible(marker), }); marker.entity.gevTrackedId = id; marker.entity.gevDisplayPosition = () => marker.position; } refreshClass(marker); const uat = localAdsbIsUat(record); - if (uat !== Boolean(marker.entity.point)) - marker.entity.point = uat ? uatRing(marker) : undefined; + if (uat !== renderer.markers.hasRing(marker.entity)) + renderer.markers.setRing(marker.entity, uat, () => markerVisible(marker)); marker.entity.gevLabelModel = cardModel(marker, at); registerEntityContext(marker.entity, contextMetadata(id, marker)); if (newFix && id === selectedId) refreshTrail(); @@ -513,7 +464,7 @@ export function createLocalAdsbLayer({ function sync() { clearTimeout(syncTimer); syncTimer = null; - if (!dataSource) return; + if (!renderer) return; const at = now(); const live = new Set(); let added = false; @@ -527,7 +478,7 @@ export function createLocalAdsbLayer({ let removed = false; for (const [id, marker] of markers) { if (live.has(id)) continue; - if (marker.entity) dataSource.entities.remove(marker.entity); + if (marker.entity) renderer.markers.remove(marker.entity); markers.delete(id); removed = true; } @@ -582,82 +533,46 @@ export function createLocalAdsbLayer({ return true; } - function pickedId(picked) { - const resolved = services.picking.resolvePickId?.(picked); - if (resolved !== undefined) return resolved; - if (typeof picked?.id === 'string') return picked.id; - if (typeof picked?.id?.id === 'string') return picked.id.id; - return typeof picked?.primitive?.id === 'string' - ? picked.primitive.id - : null; - } - - function ownedElsewhere(id) { - return Boolean(id) && services.picking.isOwnedByOtherLayer?.(LAYER_ID, id); - } - /** - * What a click at `position` means for this layer: a local aircraft id, - * `'other'` when a sibling layer's contact is under the pointer, `'trail'` - * for a trail line, or null for empty map. - * - * The frontmost pick decides, except that something no layer owns (a - * photoreal tile, a ring) or a trail line drawn over or through a local - * aircraft does not hide it: the click looks underneath for the aircraft. + * Join the engine's selection dispatcher with this layer's own click rule + * (`lookUnder`): a click on a local aircraft — on top, or under picks no + * layer owns (a photoreal tile, a ring) and trail lines — always + * (re)selects it and republishes its card, including the one already + * selected (its card may have been displaced; a click on a marker never + * reads as "deselect"); another layer's contact on top, or empty map, + * releases only this layer's selection, leaving a sibling layer's new + * selection intact; a click on a trail line alone does nothing. The + * dispatcher picks with a drill of 8 and yields to a tool that owns the + * pointer (src/data/inputOwnership.js). */ - function clickTarget(position) { - const scene = viewer.scene; - const top = pickedId(scene.pick(position)); - if (top && markers.has(top)) return top; - const trail = Boolean(top) && String(top).startsWith(TRAIL_PICK_PREFIX); - if (ownedElsewhere(top) && !trail) return 'other'; - let hits = []; - try { - hits = scene.drillPick?.(position, CLICK_DRILL_LIMIT) || []; - } catch { - hits = []; - } - for (const hit of hits) { - const id = pickedId(hit); - if (id && markers.has(id)) return id; - if (id && String(id).startsWith(TRAIL_PICK_PREFIX)) continue; - // A sibling layer's contact above ours takes the click. - if (ownedElsewhere(id)) return 'other'; - } - return trail ? 'trail' : null; - } - function installInteraction() { - if (clickHandler || !viewer?.scene?.canvas) return; - clickHandler = createInputHandler(viewer.scene.canvas); - clickHandler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the click. - if (!isPointerFree() || !enabled) return; - const target = clickTarget(click.position); - // Clicking a local aircraft always (re)selects it and republishes its - // card, including the one already selected: its card may have been - // displaced, and a click on a marker must never read as "deselect". - if (target && markers.has(target)) { - selectMarker(target); - return; - } - // A click on a trail line is not empty map. - if (target === 'trail' || !selectedId) return; - // Another contact or empty map releases only this layer's selection, - // leaving a sibling layer's new selection intact. - clearSelection(); - governorRequestRender('local-adsb-selection'); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + if (selectionRemove || !renderer) return; + const dispatcher = services.selection?.selectionDispatcherFor?.( + renderer.engine, + ); + if (!dispatcher) return; + selectionRemove = dispatcher.register(LAYER_ID, { + owns: (id) => markers.has(id), + lookUnder: (id) => String(id).startsWith(TRAIL_PICK_PREFIX), + onSelect(id) { + if (enabled && markers.has(id)) selectMarker(id); + }, + onEmptyClick() { + if (!enabled || !selectedId) return; + clearSelection(); + governorRequestRender('local-adsb-selection'); + }, + }); } function releaseInteraction() { - clickHandler?.destroy(); - clickHandler = null; + selectionRemove?.(); + selectionRemove = null; } function installFrame() { - if (removePreRender || !viewer?.scene?.preRender?.addEventListener) return; - removePreRender = viewer.scene.preRender.addEventListener(frame); + if (removePreRender || !renderer) return; + removePreRender = renderer.view.on('preRender', frame); } function releaseFrame() { @@ -671,12 +586,12 @@ export function createLocalAdsbLayer({ for (const marker of markers.values()) { if (rows.length >= maxCount) break; if (!marker.position) continue; - const carto = Cesium.Cartographic.fromCartesian(marker.position); + const carto = geo.cartographic.fromCartesian(marker.position); rows.push({ id: marker.record.icao, label: localAdsbTitle(marker.record), callsign: marker.record.callsign, - position: Cesium.Cartesian3.clone(marker.position), + position: geo.cartesian3.clone(marker.position), latitude: marker.lat, longitude: marker.lon, altitudeM: carto?.height ?? 0, @@ -697,18 +612,25 @@ export function createLocalAdsbLayer({ /** Decoder-feed session; the Radio card shows its one-line summary. */ feeds, - init(nextViewer) { - viewer = nextViewer; - dataSource = new Cesium.CustomDataSource(LAYER_ID); - dataSource.show = false; - viewer.dataSources.add(dataSource); - if (viewer.scene?.primitives) { + /** + * @param {object|null} nextViewer The Cesium viewer (null on an engine + * without one). + * @param {{ engine?: object }} [context] LayerLifecycle's context. + */ + init(nextViewer, { engine = null } = {}) { + renderer = services.renderer.create({ + viewer: nextViewer, + engine, + loadModel, + createTrail: services.trails?.createTrail ?? null, + }); + renderer.attach(); + if (renderer.models) { models = createLocalAdsbModels({ - viewer, - color, + renderer, + color: MODEL_COLOR, resolveAsset, groundSnap: services.groundSnap?.createGroundSnap?.() || null, - ...(loadModel ? { loadModel } : {}), }); } services.geoid @@ -727,7 +649,7 @@ export function createLocalAdsbLayer({ async enable() { enabled = true; - if (dataSource) dataSource.show = true; + renderer?.setVisible(true); registerPickOwner(LAYER_ID, (pickedValue) => markers.has(pickedValue)); installInteraction(); installFrame(); @@ -753,7 +675,7 @@ export function createLocalAdsbLayer({ unregisterPickOwner(LAYER_ID); sync(); models?.clear(); - if (dataSource) dataSource.show = false; + renderer?.setVisible(false); markSourcesChanged('local-adsb-disabled'); return true; }, @@ -786,9 +708,9 @@ export function createLocalAdsbLayer({ updateRenderHold(); models?.destroy(); models = null; - if (viewer && dataSource) viewer.dataSources.remove(dataSource, true); - dataSource = null; - viewer = null; + renderer?.detach(); + renderer?.destroy(); + renderer = null; void receiver.destroy?.(); }, diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs index 263c21210..e474c3409 100644 --- a/src/layers/localAdsb/index.test.mjs +++ b/src/layers/localAdsb/index.test.mjs @@ -12,6 +12,14 @@ import { localAdsbReceiverLine, localAdsbStatus, } from './index.js'; +import { createLocalAdsbCesiumRenderer } from './render/cesium.js'; +import * as selection from '../../data/selectionDispatcher.js'; +import { registerViewerEngine } from '../../globe/cesium/engineFromViewer.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { + registerPickOwner, + unregisterPickOwner, +} from '../../data/pickRegistry.js'; function fakeReceiver(initial = {}) { const state = { @@ -73,6 +81,13 @@ function fakeServices() { }, }, picking: { registerPickOwner() {}, unregisterPickOwner() {} }, + // As src/app/layers/localAdsb.js composes them: the layer's Cesium + // renderer, and the engine's selection dispatcher (clicks). + selection, + renderer: { + create: ({ engine = null, ...options }) => + createLocalAdsbCesiumRenderer(engine, options), + }, detection: { markSourcesChanged() {} }, overlays: { refreshReadout() {} }, }, @@ -198,9 +213,6 @@ async function enabledLayer(receiver, clock, feeds = null, options = {}) { services: { ...services, ...options.services }, now: () => clock.now, loadModel: options.loadModel, - ...(options.createInputHandler - ? { createInputHandler: options.createInputHandler } - : {}), }); layer.init(viewer); await layer.enable(); @@ -1051,35 +1063,29 @@ test('the last aircraft expiring removes its 3D model and cancels a pending load assert.equal(late.destroyed, true); }); -/** A viewer with a canvas and scripted picks, plus the layer's click action. */ +/** + * A viewer whose engine (a fake globe engine registered for it) scripts the + * picks under a click; clicks reach the layer through the engine's + * selection dispatcher, as in the app. + */ function clickHarness() { const base = fakeViewer(); - const scene = { - ...base.viewer.scene, - canvas: {}, - pickResult: null, - drillResult: [], - pick() { - return scene.pickResult; - }, - drillPick() { - return scene.drillResult; - }, - }; - const handler = { actions: new Map() }; + const engine = createFakeEngine(); + const scene = { pickResult: null, drillResult: [] }; + engine.fake.script('pick', () => scene.pickResult ?? undefined); + engine.fake.script('drillPick', () => scene.drillResult); + const unregister = registerViewerEngine(base.viewer, engine); return { scene, - viewer: { ...base, viewer: { ...base.viewer, scene } }, - createInputHandler: () => ({ - setInputAction(action, type) { - handler.actions.set(type, action); - }, - destroy() {}, - }), + viewer: base, + release() { + unregister(); + engine.destroy(); + }, click() { - handler.actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)({ - position: new Cesium.Cartesian2(10, 10), - }); + 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 } }); }, }; } @@ -1090,9 +1096,11 @@ test('clicking the selected aircraft keeps it selected and republishes its card' const harness = clickHarness(); const { layer, calls } = await enabledLayer(receiver, clock, null, { viewer: harness.viewer, - createInputHandler: harness.createInputHandler, }); - t.after(() => layer.destroy()); + t.after(() => { + layer.destroy(); + harness.release(); + }); receiver.set({ aircraft: [record()] }); await layer.update(); const entity = @@ -1111,26 +1119,18 @@ test('a click on a local aircraft under an unowned or trail pick still selects i const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const clock = { now: 100_000 }; const harness = clickHarness(); - const owners = new Map(); - const { resolvePickId } = await import('../../data/pickRegistry.js'); const { layer, calls } = await enabledLayer(receiver, clock, null, { viewer: harness.viewer, - createInputHandler: harness.createInputHandler, - services: { - picking: { - registerPickOwner: (id, predicate) => owners.set(id, predicate), - unregisterPickOwner: (id) => owners.delete(id), - resolvePickId, - isOwnedByOtherLayer: (layerId, pickedId) => - [...owners].some( - ([owner, predicate]) => owner !== layerId && predicate(pickedId), - ), - }, - }, }); - owners.set('trails', (id) => String(id).startsWith('gev-trail:')); - owners.set('flights', (id) => id === 'abc123'); - t.after(() => layer.destroy()); + // The app's pick owners: the trails, and a Flights contact. + registerPickOwner('trails', (id) => String(id).startsWith('gev-trail:')); + registerPickOwner('flights', (id) => id === 'abc123'); + t.after(() => { + unregisterPickOwner('trails'); + unregisterPickOwner('flights'); + layer.destroy(); + harness.release(); + }); receiver.set({ aircraft: [record(), record({ icao: 'def456', lat: 0.01 })], }); diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js index 50cebe07e..adb8ff978 100644 --- a/src/layers/localAdsb/models.js +++ b/src/layers/localAdsb/models.js @@ -1,5 +1,4 @@ -import * as Cesium from 'cesium'; -import { horizonOccluder } from '../../data/iconOrientation.js'; +import * as geo from '../../geo/index.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; import { civilAircraftModelSpec } from '../flights/modelSpec.js'; import { @@ -7,7 +6,6 @@ import { MODEL_ALL_ADD_M, MODEL_ALL_KEEP_M, MODEL_ALT_CEIL_M, - MODEL_HEADING_OFFSET_DEG, MODEL_MAX, MODEL_MAX_ALL, MODEL_MIN_PX, @@ -27,24 +25,25 @@ const specKey = (spec) => `${spec.url}@${spec.scale}`; * magenta, as Flights tints military amber). Each model owns its matrix. A * billboard stays the visual until its model is loaded AND placed; a grounded * aircraft's model rides the one-shot ground snap plus the class belly - * offset, and without a resolved ground it stays a billboard. + * offset, and without a resolved ground it stays a billboard. The models + * are drawn by the layer's renderer (its `models`: the shared aircraft + * renderer's model part) and read the camera through its `view`. * @param {object} options - * @param {object} options.viewer Cesium viewer. - * @param {Cesium.Color} options.color Model tint. + * @param {object} options.renderer The layer's renderer (render/cesium.js, + * render/navara.js). + * @param {object} options.color Model tint (a colour with red/green/blue/alpha). * @param {(url: string) => string} options.resolveAsset * @param {object|null} [options.groundSnap] `createGroundSnap()` instance. - * @param {(options: object) => Promise} [options.loadModel] * @returns {object} */ export function createLocalAdsbModels({ - viewer, + renderer, color, resolveAsset, groundSnap = null, - loadModel = (options) => Cesium.Model.fromGltfAsync(options), }) { - const collection = new Cesium.PrimitiveCollection(); - viewer.scene.primitives.add(collection); + const draw = renderer.models; + const view = renderer.view; /** @type {Map} id -> admitted model */ const models = new Map(); /** @type {Map} id -> generation of an in-flight load */ @@ -53,17 +52,15 @@ export function createLocalAdsbModels({ let destroyed = false; let lastEligibilityAt = 0; let eligible = new Set(); - const scratchHpr = new Cesium.HeadingPitchRoll(); - const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1); - const scratchCarto = new Cesium.Cartographic(); - const scratchGround = new Cesium.Cartesian3(); + const scratchCarto = geo.cartographic.create(); + const scratchGround = geo.cartesian3.create(); function release(id) { pending.delete(id); const model = models.get(id); if (!model) return; models.delete(id); - if (!collection.isDestroyed()) collection.remove(model); + draw.remove(model); groundSnap?.forget?.(id); } @@ -84,14 +81,15 @@ export function createLocalAdsbModels({ pending.set(id, loadGeneration); let model; try { - model = await loadModel({ + model = await draw.loadFleet({ url: resolveAsset(spec.url), - asynchronous: false, minimumPixelSize: MODEL_MIN_PX, scale: spec.scale, + // Bounding radius per scale unit (an engine that keeps + // minimumPixelSize on the CPU reports the drawn scale with it). + radiusM: spec.nativeRadiusM, color, - colorBlendMode: Cesium.ColorBlendMode.MIX, - colorBlendAmount: spec.blendAmount, + blendAmount: spec.blendAmount, id, }); } catch { @@ -107,7 +105,7 @@ export function createLocalAdsbModels({ specKey(civilAircraftModelSpec(marker.klass)) !== key; if (pending.get(id) === loadGeneration) pending.delete(id); if (stale) { - model.destroy?.(); + draw.destroy(model); return; } model.id = id; @@ -115,13 +113,13 @@ export function createLocalAdsbModels({ model._gevBellyM = spec.bellyM; // Admitted, not yet the visual: placement turns it on. model.show = false; - collection.add(model); + draw.add(model); models.set(id, model); } function regime(preferences) { if (!preferences?.models3d) return false; - const height = viewer.camera?.positionCartographic?.height ?? Infinity; + const height = view.camera?.positionCartographic?.height ?? Infinity; return height < MODEL_ALT_CEIL_M; } @@ -130,27 +128,17 @@ export function createLocalAdsbModels({ const cap = all ? MODEL_MAX_ALL : MODEL_MAX; const addM = all ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M; const keepM = all ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M; - const camera = viewer.camera; - const cameraPosition = camera.positionWC; - const cull = camera.frustum.computeCullingVolume( - cameraPosition, - camera.directionWC, - camera.upWC, - ); + const cameraPosition = view.camera.positionWC; + const inFrustum = view.frustumTester(); const candidates = []; for (const [id, marker] of markers) { if (!marker.position) continue; - const distanceSq = Cesium.Cartesian3.distanceSquared( + const distanceSq = geo.cartesian3.distanceSquared( cameraPosition, marker.position, ); if (distanceSq > keepM * keepM) continue; - Cesium.Cartesian3.clone(marker.position, scratchSphere.center); - candidates.push([ - id, - distanceSq, - cull.computeVisibility(scratchSphere) !== Cesium.Intersect.OUTSIDE, - ]); + candidates.push([id, distanceSq, inFrustum(marker.position)]); } candidates.sort((a, b) => a[1] - b[1]); eligible = selectModelEligible(candidates, { @@ -168,20 +156,23 @@ export function createLocalAdsbModels({ const model = models.get(id); // The vertical sample must hit the tile skin, not this aircraft's own // billboard or model. - const height = groundSnap?.heightFor?.(viewer, id, marker.position, () => - [marker.entity, model].filter(Boolean), + const height = groundSnap?.heightFor?.( + view.surfaceOwner, + id, + marker.position, + () => [marker.entity, model].filter(Boolean), ); if (!Number.isFinite(height)) return null; - const carto = Cesium.Cartographic.fromCartesian( + const carto = geo.cartographic.fromCartesian( marker.position, - Cesium.Ellipsoid.WGS84, + geo.ellipsoid.WGS84, scratchCarto, ); - return Cesium.Cartesian3.fromRadians( + return geo.cartesian3.fromRadians( carto.longitude, carto.latitude, height + (model?._gevBellyM || 0), - Cesium.Ellipsoid.WGS84, + geo.ellipsoid.WGS84, scratchGround, ); } @@ -213,7 +204,7 @@ export function createLocalAdsbModels({ lastEligibilityAt = nowMs; refreshEligibility(markers, preferences); } - const occluder = horizonOccluder(viewer.camera); + const occluder = view.horizon(); for (const [id, marker] of markers) { const model = models.get(id); marker.modelOwnsVisual = false; @@ -223,18 +214,9 @@ export function createLocalAdsbModels({ model.show = false; continue; } - scratchHpr.heading = Cesium.Math.toRadians( - (marker.courseDeg || 0) + MODEL_HEADING_OFFSET_DEG, - ); - scratchHpr.pitch = 0; - scratchHpr.roll = 0; - Cesium.Transforms.headingPitchRollToFixedFrame( - position, - scratchHpr, - Cesium.Ellipsoid.WGS84, - undefined, - model.modelMatrix, - ); + // The renderer's pose: course + the Flights models' nose offset, + // into the model's own matrix. + draw.setPose(model, position, marker.courseDeg || 0); if (!model.ready) { model.show = false; continue; @@ -271,12 +253,11 @@ export function createLocalAdsbModels({ return models.size; }, + /** Drop every model; the renderer removes the collection (detach). */ destroy() { destroyed = true; releaseAll(); groundSnap?.clear?.(); - if (!viewer.isDestroyed?.() && !collection.isDestroyed()) - viewer.scene.primitives.remove(collection); }, }; } diff --git a/src/layers/localAdsb/render/cesium.js b/src/layers/localAdsb/render/cesium.js new file mode 100644 index 000000000..6fcc0ff99 --- /dev/null +++ b/src/layers/localAdsb/render/cesium.js @@ -0,0 +1,218 @@ +// The Local ADS-B layer's renderer on Cesium (phase 3 Task 12a): the Cesium +// objects the layer used to build itself, moved here one for one — its +// CustomDataSource of marker entities (billboard with per-frame position, +// rotation and visibility callbacks, a 2,000 km display distance, the 978 MHz +// UAT ring as a point graphic), its model PrimitiveCollection (models load, +// pose and leave through the shared aircraft renderer's model part), the +// selected aircraft's trail and live head segment, and the camera reads. +// Nothing here decides anything: the layer's logic decides, this draws. +// +// The renderer (contract shared with render/navara.js): +// view camera, scene, horizon(), frustumTester(), +// surfaceOwner, requestRender(), on('preRender', fn) +// attach(), setVisible(bool), detach() +// markers.add({ id, image, scale, position(), rotation(), show() }) → +// handle (an Entity on Cesium: the context and readout annotations go +// on it), remove(handle), setImage(handle, image), setScale(handle, +// scale), hasRing(handle), setRing(handle, on, visible()) +// models the aircraft renderer's models (live, loadFleet, +// add, remove, destroy, setPose), or null without a +// primitive collection +// trails.create({ color, width }) → Trail | null, createHead({ id, +// positions(), color, width }), removeHead(head) +// engine, destroy() + +import * as Cesium from 'cesium'; +import { createAircraftCesiumRenderer } from '../../aircraft/render/cesium.js'; +import { MODEL_HEADING_OFFSET_DEG } from '../../flights/policy.js'; +import { + LAYER_ID, + LOCAL_ADSB_COLOR, + LOCAL_ADSB_UAT_RING_COLOR, +} from '../policy.js'; + +/** Markers (and the UAT ring) are not drawn past this camera distance (m). */ +export const LOCAL_ADSB_DISPLAY_DISTANCE_M = 2_000_000; + +/** + * Registry factory (src/app/layers/renderers.js): `(engine, options)`. + * @param {object|null} engine The Cesium globe engine (null in unit tests). + * @param {{ viewer: object, loadModel?: Function, createTrail?: Function }} options + * `loadModel`: the glTF loader (test seam); `createTrail`: the trail + * renderer (data/trailRenderer createTrail; no trail without it). + */ +export function createLocalAdsbCesiumRenderer( + engine, + { viewer, loadModel, createTrail = null } = {}, +) { + const color = Cesium.Color.fromCssColorString(LOCAL_ADSB_COLOR); + const uatRingColor = Cesium.Color.fromCssColorString( + LOCAL_ADSB_UAT_RING_COLOR, + ); + let dataSource = null; + let modelCollection = null; + let aircraft = null; + + const view = { + get camera() { + return viewer?.camera; + }, + get scene() { + return viewer?.scene; + }, + get surfaceOwner() { + return viewer; + }, + horizon: () => aircraft.view.horizon(), + frustumTester: () => aircraft.view.frustumTester(), + requestRender: () => viewer?.scene?.requestRender?.(), + on(event, listener) { + if (event !== 'preRender') return null; + return viewer?.scene?.preRender?.addEventListener + ? viewer.scene.preRender.addEventListener(listener) + : null; + }, + }; + + const uatRing = (visible) => + new Cesium.PointGraphics({ + pixelSize: 24, + color: Cesium.Color.TRANSPARENT, + outlineColor: uatRingColor, + outlineWidth: 1.5, + scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5), + show: new Cesium.CallbackProperty(() => visible(), false), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + distanceDisplayCondition: new Cesium.DistanceDisplayCondition( + 0, + LOCAL_ADSB_DISPLAY_DISTANCE_M, + ), + }); + + const markers = { + add(spec) { + return dataSource.entities.add({ + id: spec.id, + position: new Cesium.CallbackProperty(() => spec.position(), false), + billboard: { + image: spec.image, + width: 20, + height: 20, + scale: 1, + color, + sizeInMeters: false, + scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5), + alignedAxis: Cesium.Cartesian3.ZERO, + rotation: new Cesium.CallbackProperty(() => spec.rotation(), false), + show: new Cesium.CallbackProperty(() => spec.show(), false), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + distanceDisplayCondition: new Cesium.DistanceDisplayCondition( + 0, + LOCAL_ADSB_DISPLAY_DISTANCE_M, + ), + }, + }); + }, + remove(handle) { + dataSource?.entities.remove(handle); + }, + setImage(handle, image) { + if (handle?.billboard) handle.billboard.image = image; + }, + setScale(handle, scale) { + if (handle?.billboard) handle.billboard.scale = scale; + }, + hasRing: (handle) => Boolean(handle?.point), + setRing(handle, on, visible) { + handle.point = on ? uatRing(visible) : undefined; + }, + }; + + const trails = { + create: ({ color: css, width }) => + typeof createTrail === 'function' && viewer?.entities + ? createTrail(viewer, { color: css, width }) + : null, + // Live head: last heard fix → the extrapolated marker, every frame. + createHead({ id, positions, width }) { + return viewer.entities.add({ + // 'gev-trail' namespace: claimed by the trail pick owner so a click + // on the head segment never reads as empty space. + id, + polyline: { + positions: new Cesium.CallbackProperty(() => positions(), false), + width, + material: color.withAlpha(0.9), + depthFailMaterial: color.withAlpha(0.45), + arcType: Cesium.ArcType.GEODESIC, + }, + }); + }, + removeHead(head) { + if (head && viewer && !viewer.isDestroyed?.()) { + try { + viewer.entities.remove(head); + } catch { + /* torn down */ + } + } + }, + }; + + const renderer = { + kind: 'cesium', + get engine() { + return aircraft?.engine ?? engine; + }, + view, + markers, + trails, + /** The aircraft renderer's models, or null (no primitive collection). */ + models: null, + /** The model tint (the layer's magenta). */ + modelColor: color, + attach() { + dataSource = new Cesium.CustomDataSource(LAYER_ID); + dataSource.show = false; + viewer.dataSources.add(dataSource); + if (viewer.scene?.primitives) { + modelCollection = new Cesium.PrimitiveCollection(); + viewer.scene.primitives.add(modelCollection); + } + aircraft = createAircraftCesiumRenderer({ + viewer, + engine, + spriteLayer: null, + headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, + adopt: { modelCollection }, + ...(loadModel ? { loadModel } : {}), + }); + if (modelCollection) renderer.models = aircraft.models; + }, + setVisible(visible) { + if (dataSource) dataSource.show = visible; + }, + /** The data source, for the context slot's metadata. */ + get dataSource() { + return dataSource; + }, + // Removing the model collection destroys it and the models left in it. + detach() { + if ( + modelCollection && + !viewer.isDestroyed?.() && + !modelCollection.isDestroyed() + ) + viewer.scene.primitives.remove(modelCollection); + modelCollection = null; + if (viewer && dataSource) viewer.dataSources.remove(dataSource, true); + dataSource = null; + renderer.models = null; + }, + destroy() { + aircraft?.destroy(); + aircraft = null; + }, + }; + return renderer; +}