mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
refactor(localAdsb): Cesium rendering behind a renderer interface
Task 12a. The Local ADS-B logic (index.js, models.js) no longer imports
Cesium. src/layers/localAdsb/render/cesium.js holds what it built, one
for one: the CustomDataSource of marker entities (billboard with
per-frame position/rotation/visibility CallbackProperties, the 2,000 km
DistanceDisplayCondition, the 978 MHz UAT ring as a PointGraphics), the
model PrimitiveCollection (models load, pose and leave through the shared
aircraft renderer's model part, adopting that collection), the selected
aircraft's trail (data/trailRenderer, as before) and live head segment,
and the camera reads. Registered for 'local-adsb' in the renderer
registry, created at init(viewer, {engine}).
- Clicks go through the engine's selection dispatcher (drill 8) instead of
the layer's ScreenSpaceEventHandler, with the layer's own rule kept as is
— a new `lookUnder` owner kind: its marker on top, or under picks no
layer owns and under trail lines, is selected (and re-selected);
another layer's contact on top, or empty map, releases only its own
selection; a trail line alone does nothing; no gesture rule. To the
other owners it is another layer, as its pick-owner registration made
it before. (The dispatcher's `drillThrough` would have taken the marker
from under another layer's contact, which the old handler never did.)
- models.js reads the camera through the renderer's view (frustum and
horizon tests, ground snap owner) and draws through renderer.models
(loadFleet/add/remove/destroy/setPose: the same MIX-tinted glTF options,
the same HPR frame with the Flights nose offset). The shared aircraft
renderer gains a loadModel seam (default Cesium.Model.fromGltfAsync).
- Cesium math → src/geo; the model tint is an Rgba equal to the old
Cesium.Color; check-import-directions: src/layers/localAdsb/ joins the
rendered layers.
Tests moved with the code, same meaning:
- index.test: the services fixture creates the layer's Cesium renderer and
the selection dispatcher; the click harness scripts the picks on a fake
engine registered for the fake viewer (was a fake ScreenSpaceEventHandler)
and the app's pick owners (trails, a Flights contact) are registered in
the pick registry (was an injected picking service).
- cockpitModels.test: the GLB loads are the aircraft renderer's loadModel
calls (Cesium.Model.fromGltfAsync by default), each asynchronous:false.
- trackingClickGesture.test: the dispatcher's empty click goes to the
plain owners (filter(plain)).
- renderers.test: local-adsb has an extracted Cesium renderer.
New: the dispatcher's look-under rule beside a plain owner (7 click cases).
W1 replay (flights, military, localAdsb) vs a fresh 7c7422e2 run: counts,
known objects, screen points, tracking poses, requests and every shot's
sha256 identical.
This commit is contained in:
1 parent
941b308c29
commit
0b8cb855fa
15 files changed
+549
-306
No files matched your search
@@ -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/',
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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' \}\)/);
|
||||
|
||||
@@ -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<object>} [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,
|
||||
}),
|
||||
|
||||
+106
-184
@@ -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<object>} [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?.();
|
||||
},
|
||||
|
||||
|
||||
@@ -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 })],
|
||||
});
|
||||
|
||||
@@ -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<object>} [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<string, object>} id -> admitted model */
|
||||
const models = new Map();
|
||||
/** @type {Map<string, number>} 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user