mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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 = [
|
const RENDERED_LAYER_DIRS = [
|
||||||
'src/layers/flights/',
|
'src/layers/flights/',
|
||||||
'src/layers/military/',
|
'src/layers/military/',
|
||||||
|
'src/layers/localAdsb/',
|
||||||
'src/layers/aircraft/',
|
'src/layers/aircraft/',
|
||||||
'src/layers/shared/',
|
'src/layers/shared/',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -885,6 +885,7 @@
|
|||||||
"src/sdr/usbDevices.test.mjs",
|
"src/sdr/usbDevices.test.mjs",
|
||||||
"src/sources/adsbRecords.test.mjs",
|
"src/sources/adsbRecords.test.mjs",
|
||||||
"src/layers/localAdsb/index.test.mjs",
|
"src/layers/localAdsb/index.test.mjs",
|
||||||
|
"src/layers/localAdsb/render/cesium.js",
|
||||||
"src/ui/localSdrControls.test.mjs",
|
"src/ui/localSdrControls.test.mjs",
|
||||||
"src/ui/localSdrMarkup.test.mjs",
|
"src/ui/localSdrMarkup.test.mjs",
|
||||||
"src/ui/localSdrPresentation.test.mjs",
|
"src/ui/localSdrPresentation.test.mjs",
|
||||||
|
|||||||
@@ -2600,7 +2600,8 @@
|
|||||||
"src/layers/aircraft/render/navara.js",
|
"src/layers/aircraft/render/navara.js",
|
||||||
"src/layers/flights/render/navara.js",
|
"src/layers/flights/render/navara.js",
|
||||||
"src/layers/military/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": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -3177,7 +3178,8 @@
|
|||||||
"src/layers/shared/color.js",
|
"src/layers/shared/color.js",
|
||||||
"src/layers/flights/render/cesium.js",
|
"src/layers/flights/render/cesium.js",
|
||||||
"src/layers/military/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": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { SdrController } from '../../sdr/controller.js';
|
|||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
import * as context from '../../data/contextStore.js';
|
import * as context from '../../data/contextStore.js';
|
||||||
import * as picking from '../../data/pickRegistry.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 trails from '../../data/trailRenderer.js';
|
||||||
import * as geoid from '../../data/geoid.js';
|
import * as geoid from '../../data/geoid.js';
|
||||||
import { markDetectionSourcesChanged } from '../../data/detection.js';
|
import { markDetectionSourcesChanged } from '../../data/detection.js';
|
||||||
@@ -27,6 +30,13 @@ export function createApplicationLocalAdsb({
|
|||||||
displayParams = null,
|
displayParams = null,
|
||||||
resolveAsset = (url) =>
|
resolveAsset = (url) =>
|
||||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
`${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({
|
return createLocalAdsbLayer({
|
||||||
receiver,
|
receiver,
|
||||||
@@ -36,6 +46,11 @@ export function createApplicationLocalAdsb({
|
|||||||
render,
|
render,
|
||||||
context,
|
context,
|
||||||
picking,
|
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,
|
trails,
|
||||||
geoid,
|
geoid,
|
||||||
groundSnap: surface?.groundSnap || null,
|
groundSnap: surface?.groundSnap || null,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
|
import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
|
||||||
import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js';
|
import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js';
|
||||||
import { createMilitaryCesiumRenderer } from '../../layers/military/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']);
|
const ENGINE_KINDS = new Set(['cesium', 'navara']);
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ export const layerAvailableOn = (layerId, engineKind) =>
|
|||||||
export const CESIUM_LAYER_RENDERERS = Object.freeze({
|
export const CESIUM_LAYER_RENDERERS = Object.freeze({
|
||||||
flights: createFlightsCesiumRenderer,
|
flights: createFlightsCesiumRenderer,
|
||||||
military: createMilitaryCesiumRenderer,
|
military: createMilitaryCesiumRenderer,
|
||||||
|
'local-adsb': createLocalAdsbCesiumRenderer,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The other application layers still draw themselves on Cesium.
|
// 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), [
|
assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [
|
||||||
'flights',
|
'flights',
|
||||||
'military',
|
'military',
|
||||||
|
'local-adsb',
|
||||||
]);
|
]);
|
||||||
const viewer = { scene: {}, camera: {} };
|
const viewer = { scene: {}, camera: {} };
|
||||||
const renderer = rendererFor('flights', { kind: 'cesium' }, { viewer });
|
const renderer = rendererFor('flights', { kind: 'cesium' }, { viewer });
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ for (const layer of LAYERS) {
|
|||||||
const source = readLayerSource(layer.path);
|
const source = readLayerSource(layer.path);
|
||||||
|
|
||||||
test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => {
|
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}`);
|
assert.ok(calls.length >= 3, `expected fleet, tracked, and preload model calls; found ${calls.length}`);
|
||||||
for (const [index, call] of calls.entries()) {
|
for (const [index, call] of calls.entries()) {
|
||||||
assert.match(call[1], /\basynchronous:\s*false\b/,
|
assert.match(call[1], /\basynchronous:\s*false\b/,
|
||||||
|
|||||||
@@ -10,10 +10,16 @@
|
|||||||
// onEscape() Escape.
|
// onEscape() Escape.
|
||||||
// The rules are the old handlers': a pick another layer owns is not empty
|
// 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
|
// 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
|
// click, the next pick does; a `drillThrough` owner takes its object from
|
||||||
// another layer's; a drag selects nothing; a pointer owned by a tool
|
// under another layer's; a drag selects nothing; a pointer owned by a tool
|
||||||
// (inputOwnership) selects nothing. Layers not moved yet keep their own
|
// (inputOwnership) selects nothing. Layers not moved yet keep their own
|
||||||
// handlers: the dispatcher never acts on an id it does not 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 { isPointerFree as pointerFree } from './inputOwnership.js';
|
||||||
import {
|
import {
|
||||||
@@ -75,6 +81,31 @@ export function createSelectionDispatcher(
|
|||||||
return { id, owner: findPickOwner(id) };
|
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) => {
|
const onClick = (event) => {
|
||||||
if (press) finish(event.position);
|
if (press) finish(event.position);
|
||||||
const gesture = completed ?? { travelPx: 0, durationMs: 0 };
|
const gesture = completed ?? { travelPx: 0, durationMs: 0 };
|
||||||
@@ -86,6 +117,14 @@ export function createSelectionDispatcher(
|
|||||||
drilled: event.drilled ?? [],
|
drilled: event.drilled ?? [],
|
||||||
gesture,
|
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(
|
const candidates = [event.picked, ...(event.drilled ?? [])].filter(
|
||||||
(picked) =>
|
(picked) =>
|
||||||
picked &&
|
picked &&
|
||||||
@@ -104,15 +143,16 @@ export function createSelectionDispatcher(
|
|||||||
});
|
});
|
||||||
if (!picks.length) {
|
if (!picks.length) {
|
||||||
if (!isTrackingClickGesture(gesture)) return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!isTrackingSelectionGesture(gesture)) return;
|
if (!isTrackingSelectionGesture(gesture)) return;
|
||||||
const first = ownerOf(picks[0]);
|
const first = ownerOf(picks[0]);
|
||||||
if (owners.has(first.owner)) {
|
if (owners.has(first.owner)) {
|
||||||
owners
|
const owner = owners.get(first.owner);
|
||||||
.get(first.owner)
|
if (plain(owner))
|
||||||
.onSelect?.(first.id, { ...ctx, picked: picks[0] });
|
owner.onSelect?.(first.id, { ...ctx, picked: picks[0] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Another layer's object on top: a drill-through owner may still take
|
// 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)) {
|
for (const picked of picks.slice(1)) {
|
||||||
const below = ownerOf(picked);
|
const below = ownerOf(picked);
|
||||||
const owner = owners.get(below.owner);
|
const owner = owners.get(below.owner);
|
||||||
if (owner?.drillThrough) {
|
if (owner?.drillThrough && plain(owner)) {
|
||||||
owner.onSelect?.(below.id, { ...ctx, picked });
|
owner.onSelect?.(below.id, { ...ctx, picked });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -128,7 +168,8 @@ export function createSelectionDispatcher(
|
|||||||
// A pick no layer owns (an unclaimed entity) is empty space, as it was
|
// 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.
|
// for the old handlers: only another layer's object is not.
|
||||||
if (first.owner === null && isTrackingClickGesture(gesture))
|
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) => {
|
const onKey = (event) => {
|
||||||
@@ -163,7 +204,8 @@ export function createSelectionDispatcher(
|
|||||||
/**
|
/**
|
||||||
* @param {string} name the owning layer's id
|
* @param {string} name the owning layer's id
|
||||||
* @param {{ owns: (id: string) => boolean, onSelect?: Function, onEmptyClick?: Function,
|
* @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
|
* @returns {() => void} unregister
|
||||||
*/
|
*/
|
||||||
register(name, owner) {
|
register(name, owner) {
|
||||||
|
|||||||
@@ -205,3 +205,54 @@ test('the application has one dispatcher per engine', () => {
|
|||||||
selectionDispatcherFor(engine).destroy();
|
selectionDispatcherFor(engine).destroy();
|
||||||
selectionDispatcherFor(other).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.
|
// each layer answers with a user-origin clear.
|
||||||
const dispatcher = readFileSync(new URL('./selectionDispatcher.js', import.meta.url), 'utf8');
|
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 \(!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]]]) {
|
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, new RegExp(`dispatcher\\.register\\('${layer}', \\{[\\s\\S]+onEmptyClick: _onEmptyClick`));
|
||||||
assert.match(source, /function _onEmptyClick\(\) \{[\s\S]+?_clearTracking\(false, \{ origin: 'user' \}\)/);
|
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 {string} options.spriteLayer The fleet's slot in data/spriteOrder.
|
||||||
* @param {number} [options.headingOffsetDeg=0] Model nose vs Cesium heading 0.
|
* @param {number} [options.headingOffsetDeg=0] Model nose vs Cesium heading 0.
|
||||||
* @param {{ billboardCollection?: object, modelCollection?: object }} [options.adopt]
|
* @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}
|
* @returns {import('./contract.js').AircraftRenderer}
|
||||||
*/
|
*/
|
||||||
export function createAircraftCesiumRenderer({
|
export function createAircraftCesiumRenderer({
|
||||||
@@ -74,6 +77,7 @@ export function createAircraftCesiumRenderer({
|
|||||||
spriteLayer,
|
spriteLayer,
|
||||||
headingOffsetDeg = 0,
|
headingOffsetDeg = 0,
|
||||||
adopt = null,
|
adopt = null,
|
||||||
|
loadModel = (options) => Cesium.Model.fromGltfAsync(options),
|
||||||
}) {
|
}) {
|
||||||
let billboards = adopt?.billboardCollection ?? null;
|
let billboards = adopt?.billboardCollection ?? null;
|
||||||
let models = adopt?.modelCollection ?? null;
|
let models = adopt?.modelCollection ?? null;
|
||||||
@@ -202,7 +206,7 @@ export function createAircraftCesiumRenderer({
|
|||||||
unlit,
|
unlit,
|
||||||
id,
|
id,
|
||||||
}) =>
|
}) =>
|
||||||
Cesium.Model.fromGltfAsync({
|
loadModel({
|
||||||
url,
|
url,
|
||||||
asynchronous: false,
|
asynchronous: false,
|
||||||
minimumPixelSize,
|
minimumPixelSize,
|
||||||
@@ -222,7 +226,7 @@ export function createAircraftCesiumRenderer({
|
|||||||
unlit,
|
unlit,
|
||||||
id,
|
id,
|
||||||
}) =>
|
}) =>
|
||||||
Cesium.Model.fromGltfAsync({
|
loadModel({
|
||||||
url,
|
url,
|
||||||
asynchronous: false,
|
asynchronous: false,
|
||||||
minimumPixelSize,
|
minimumPixelSize,
|
||||||
@@ -237,7 +241,7 @@ export function createAircraftCesiumRenderer({
|
|||||||
}),
|
}),
|
||||||
// Warms Cesium's glTF cache (the retained instance is never drawn).
|
// Warms Cesium's glTF cache (the retained instance is never drawn).
|
||||||
preload: (url) =>
|
preload: (url) =>
|
||||||
Cesium.Model.fromGltfAsync({
|
loadModel({
|
||||||
url,
|
url,
|
||||||
asynchronous: false,
|
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 { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||||
import {
|
import {
|
||||||
horizonOccluder,
|
|
||||||
screenProjectedRotation,
|
screenProjectedRotation,
|
||||||
stabilizeScreenRotation,
|
stabilizeScreenRotation,
|
||||||
} from '../../data/iconOrientation.js';
|
} from '../../data/iconOrientation.js';
|
||||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
import { rgbaFromCss } from '../shared/color.js';
|
||||||
import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
|
import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
|
||||||
import { routePlausible } from '../../data/routePlausible.js';
|
import { routePlausible } from '../../data/routePlausible.js';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +31,6 @@ import {
|
|||||||
LOCAL_ADSB_COLOR,
|
LOCAL_ADSB_COLOR,
|
||||||
LOCAL_ADSB_SYNC_MS,
|
LOCAL_ADSB_SYNC_MS,
|
||||||
LOCAL_ADSB_TICK_MS,
|
LOCAL_ADSB_TICK_MS,
|
||||||
LOCAL_ADSB_UAT_RING_COLOR,
|
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
import { localAdsbStatus } from './status.js';
|
import { localAdsbStatus } from './status.js';
|
||||||
|
|
||||||
@@ -52,10 +50,10 @@ export { localAdsbStatus } from './status.js';
|
|||||||
|
|
||||||
const FEET_TO_METERS = 0.3048;
|
const FEET_TO_METERS = 0.3048;
|
||||||
const RENDER_HOLD_ID = 'local-adsb';
|
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. */
|
/** Trail lines pass through their aircraft; a click never stops on one. */
|
||||||
const TRAIL_PICK_PREFIX = 'gev-trail:';
|
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. */
|
/** The DISPLAY rail's first-run 3D preference, used when no reader is wired. */
|
||||||
const DEFAULT_DISPLAY = Object.freeze({
|
const DEFAULT_DISPLAY = Object.freeze({
|
||||||
models3d: true,
|
models3d: true,
|
||||||
@@ -86,18 +84,18 @@ const DEFAULT_DISPLAY = Object.freeze({
|
|||||||
* @param {object} options.receiver Local receiver session.
|
* @param {object} options.receiver Local receiver session.
|
||||||
* @param {object} [options.feeds] Decoder-feed session (start, stop,
|
* @param {object} [options.feeds] Decoder-feed session (start, stop,
|
||||||
* getState, subscribe).
|
* getState, subscribe).
|
||||||
* @param {object} options.services Render, context, picking, detection and
|
* @param {object} options.services Render, context, picking, selection
|
||||||
* readout operations supplied by the application; optionally `display`
|
* (`selectionDispatcherFor`), renderer (`create({ viewer, engine,
|
||||||
* (`getParams()` → `{ models3d, models3dMode }`), `enrichment` (a source
|
* loadModel, createTrail })`: the engine's Local ADS-B renderer),
|
||||||
* with `getEnrichment`), `trails` (`createTrail`), `geoid`
|
* detection and readout operations supplied by the application;
|
||||||
* (`ensureGeoidReady`, `geoidHeight`) and `groundSnap`
|
* optionally `display` (`getParams()` → `{ models3d, models3dMode }`),
|
||||||
|
* `enrichment` (a source with `getEnrichment`), `trails` (`createTrail`),
|
||||||
|
* `geoid` (`ensureGeoidReady`, `geoidHeight`) and `groundSnap`
|
||||||
* (`createGroundSnap`).
|
* (`createGroundSnap`).
|
||||||
* @param {() => number} [options.now] Clock, injectable for tests.
|
* @param {() => number} [options.now] Clock, injectable for tests.
|
||||||
* @param {(url: string) => string} [options.resolveAsset] Model URL resolver.
|
* @param {(url: string) => string} [options.resolveAsset] Model URL resolver.
|
||||||
* @param {(options: object) => Promise<object>} [options.loadModel] Model
|
* @param {(options: object) => Promise<object>} [options.loadModel] Model
|
||||||
* loader, injectable for tests.
|
* loader, injectable for tests.
|
||||||
* @param {(canvas: HTMLCanvasElement) => object} [options.createInputHandler]
|
|
||||||
* Click-handler factory, injectable for tests.
|
|
||||||
* @returns {object} Data-layer module.
|
* @returns {object} Data-layer module.
|
||||||
*/
|
*/
|
||||||
export function createLocalAdsbLayer({
|
export function createLocalAdsbLayer({
|
||||||
@@ -107,7 +105,6 @@ export function createLocalAdsbLayer({
|
|||||||
now = Date.now,
|
now = Date.now,
|
||||||
resolveAsset = (url) => url,
|
resolveAsset = (url) => url,
|
||||||
loadModel,
|
loadModel,
|
||||||
createInputHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas),
|
|
||||||
}) {
|
}) {
|
||||||
if (typeof receiver?.getState !== 'function')
|
if (typeof receiver?.getState !== 'function')
|
||||||
throw new TypeError('Local ADS-B requires a receiver session');
|
throw new TypeError('Local ADS-B requires a receiver session');
|
||||||
@@ -124,19 +121,15 @@ export function createLocalAdsbLayer({
|
|||||||
removeEntityContextsForLayer,
|
removeEntityContextsForLayer,
|
||||||
} = services.context;
|
} = services.context;
|
||||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
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;
|
/** The engine's Local ADS-B renderer for this lifecycle (render/*.js). */
|
||||||
let dataSource = null;
|
let renderer = null;
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
let unsubscribe = null;
|
let unsubscribe = null;
|
||||||
let unsubscribeFeeds = null;
|
let unsubscribeFeeds = null;
|
||||||
let tickTimer = null;
|
let tickTimer = null;
|
||||||
let syncTimer = null;
|
let syncTimer = null;
|
||||||
let clickHandler = null;
|
let selectionRemove = null;
|
||||||
let removePreRender = null;
|
let removePreRender = null;
|
||||||
let renderHeld = false;
|
let renderHeld = false;
|
||||||
let selectedId = null;
|
let selectedId = null;
|
||||||
@@ -181,14 +174,14 @@ export function createLocalAdsbLayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markerVisible(marker) {
|
function markerVisible(marker) {
|
||||||
if (!viewer?.camera?.positionWC || !marker.position) return true;
|
if (!renderer?.view.camera?.positionWC || !marker.position) return true;
|
||||||
return horizonOccluder(viewer.camera).isPointVisible(marker.position);
|
return renderer.view.horizon().isPointVisible(marker.position);
|
||||||
}
|
}
|
||||||
|
|
||||||
function markerRotation(marker) {
|
function markerRotation(marker) {
|
||||||
if (!viewer?.scene || !marker.position) return marker.lastRotation;
|
if (!renderer?.view.scene || !marker.position) return marker.lastRotation;
|
||||||
const projected = screenProjectedRotation(
|
const projected = screenProjectedRotation(
|
||||||
viewer.scene,
|
renderer.view.scene,
|
||||||
marker.position,
|
marker.position,
|
||||||
marker.courseDeg ?? 0,
|
marker.courseDeg ?? 0,
|
||||||
marker.lastRotation,
|
marker.lastRotation,
|
||||||
@@ -226,7 +219,7 @@ export function createLocalAdsbLayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fixPosition(marker, fix) {
|
function fixPosition(marker, fix) {
|
||||||
return Cesium.Cartesian3.fromDegrees(
|
return geo.cartesian3.fromDegrees(
|
||||||
fix.lon,
|
fix.lon,
|
||||||
fix.lat,
|
fix.lat,
|
||||||
renderHeightM(marker, fix.altitudeFt, fix.lat, fix.lon),
|
renderHeightM(marker, fix.altitudeFt, fix.lat, fix.lon),
|
||||||
@@ -238,12 +231,12 @@ export function createLocalAdsbLayer({
|
|||||||
if (!display) return;
|
if (!display) return;
|
||||||
marker.lat = display.lat;
|
marker.lat = display.lat;
|
||||||
marker.lon = display.lon;
|
marker.lon = display.lon;
|
||||||
marker.position = Cesium.Cartesian3.fromDegrees(
|
marker.position = geo.cartesian3.fromDegrees(
|
||||||
display.lon,
|
display.lon,
|
||||||
display.lat,
|
display.lat,
|
||||||
renderHeightM(marker, display.altitudeFt, display.lat, display.lon),
|
renderHeightM(marker, display.altitudeFt, display.lat, display.lon),
|
||||||
Cesium.Ellipsoid.WGS84,
|
geo.ellipsoid.WGS84,
|
||||||
marker.position || new Cesium.Cartesian3(),
|
marker.position || geo.cartesian3.create(),
|
||||||
);
|
);
|
||||||
if (display.courseDeg !== null) marker.courseDeg = display.courseDeg;
|
if (display.courseDeg !== null) marker.courseDeg = display.courseDeg;
|
||||||
}
|
}
|
||||||
@@ -262,16 +255,15 @@ export function createLocalAdsbLayer({
|
|||||||
);
|
);
|
||||||
marker.evidence = evidence;
|
marker.evidence = evidence;
|
||||||
marker.klass = klass;
|
marker.klass = klass;
|
||||||
const billboard = marker.entity?.billboard;
|
if (!marker.entity || !renderer) return;
|
||||||
if (!billboard) return;
|
|
||||||
if (marker.iconKind !== klass) {
|
if (marker.iconKind !== klass) {
|
||||||
marker.iconKind = klass;
|
marker.iconKind = klass;
|
||||||
billboard.image = aircraftIcon(klass);
|
renderer.markers.setImage(marker.entity, aircraftIcon(klass));
|
||||||
}
|
}
|
||||||
const scale = billboardScale(marker);
|
const scale = billboardScale(marker);
|
||||||
if (marker.iconScale !== scale) {
|
if (marker.iconScale !== scale) {
|
||||||
marker.iconScale = scale;
|
marker.iconScale = scale;
|
||||||
billboard.scale = scale;
|
renderer.markers.setScale(marker.entity, scale);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +301,7 @@ export function createLocalAdsbLayer({
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
layerId: LAYER_ID,
|
layerId: LAYER_ID,
|
||||||
dataSource,
|
dataSource: renderer?.dataSource ?? null,
|
||||||
layerName: LAYER_NAME,
|
layerName: LAYER_NAME,
|
||||||
source: localAdsbSourceText(record),
|
source: localAdsbSourceText(record),
|
||||||
label: localAdsbTitle(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 ──────────────────────────────────────
|
// ── Trail of the selected aircraft ──────────────────────────────────────
|
||||||
|
|
||||||
function releaseTrail() {
|
function releaseTrail() {
|
||||||
if (!selectedTrail) return;
|
if (!selectedTrail) return;
|
||||||
selectedTrail.trail?.destroy();
|
selectedTrail.trail?.destroy();
|
||||||
if (selectedTrail.head && viewer && !viewer.isDestroyed?.()) {
|
if (selectedTrail.head) renderer?.trails.removeHead(selectedTrail.head);
|
||||||
try {
|
|
||||||
viewer.entities.remove(selectedTrail.head);
|
|
||||||
} catch {
|
|
||||||
/* torn down */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
selectedTrail = null;
|
selectedTrail = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,31 +347,30 @@ export function createLocalAdsbLayer({
|
|||||||
|
|
||||||
function startTrail(marker) {
|
function startTrail(marker) {
|
||||||
releaseTrail();
|
releaseTrail();
|
||||||
const createTrail = services.trails?.createTrail;
|
const trail = renderer?.trails.create({
|
||||||
if (typeof createTrail !== 'function' || !viewer?.entities) return;
|
color: LOCAL_ADSB_COLOR,
|
||||||
|
width: 2.5,
|
||||||
|
});
|
||||||
|
if (!trail) return;
|
||||||
selectedTrail = {
|
selectedTrail = {
|
||||||
id: marker.id,
|
id: marker.id,
|
||||||
trail: createTrail(viewer, { color: LOCAL_ADSB_COLOR, width: 2.5 }),
|
trail,
|
||||||
head: null,
|
head: null,
|
||||||
lastFix: null,
|
lastFix: null,
|
||||||
};
|
};
|
||||||
// Live head: last heard fix → the extrapolated marker, every frame.
|
// 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
|
// 'gev-trail' namespace: claimed by the trail pick owner so a click on
|
||||||
// the head segment never reads as empty space.
|
// the head segment never reads as empty space.
|
||||||
id: `gev-trail:local-adsb-head-${++trailHeadSeq}`,
|
id: `gev-trail:local-adsb-head-${++trailHeadSeq}`,
|
||||||
polyline: {
|
positions: () => {
|
||||||
positions: new Cesium.CallbackProperty(() => {
|
const current = markers.get(selectedTrail?.id);
|
||||||
const current = markers.get(selectedTrail?.id);
|
const from = selectedTrail?.lastFix;
|
||||||
const from = selectedTrail?.lastFix;
|
if (!current?.position || !from) return [];
|
||||||
if (!current?.position || !from) return [];
|
return [from, geo.cartesian3.clone(current.position)];
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
|
color: LOCAL_ADSB_COLOR,
|
||||||
|
width: 2.5,
|
||||||
});
|
});
|
||||||
refreshTrail();
|
refreshTrail();
|
||||||
}
|
}
|
||||||
@@ -439,40 +408,22 @@ export function createLocalAdsbLayer({
|
|||||||
rejectedFixes += marker.motion.rejectedFixes - rejectedBefore;
|
rejectedFixes += marker.motion.rejectedFixes - rejectedBefore;
|
||||||
updateDisplay(marker, at);
|
updateDisplay(marker, at);
|
||||||
if (!marker.entity) {
|
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,
|
id,
|
||||||
position: new Cesium.CallbackProperty(() => marker.position, false),
|
image: aircraftIcon('airliner'),
|
||||||
billboard: {
|
position: () => marker.position,
|
||||||
image: aircraftIcon('airliner'),
|
rotation: () => markerRotation(marker),
|
||||||
width: 20,
|
show: () => !marker.modelOwnsVisual && markerVisible(marker),
|
||||||
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,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
marker.entity.gevTrackedId = id;
|
marker.entity.gevTrackedId = id;
|
||||||
marker.entity.gevDisplayPosition = () => marker.position;
|
marker.entity.gevDisplayPosition = () => marker.position;
|
||||||
}
|
}
|
||||||
refreshClass(marker);
|
refreshClass(marker);
|
||||||
const uat = localAdsbIsUat(record);
|
const uat = localAdsbIsUat(record);
|
||||||
if (uat !== Boolean(marker.entity.point))
|
if (uat !== renderer.markers.hasRing(marker.entity))
|
||||||
marker.entity.point = uat ? uatRing(marker) : undefined;
|
renderer.markers.setRing(marker.entity, uat, () => markerVisible(marker));
|
||||||
marker.entity.gevLabelModel = cardModel(marker, at);
|
marker.entity.gevLabelModel = cardModel(marker, at);
|
||||||
registerEntityContext(marker.entity, contextMetadata(id, marker));
|
registerEntityContext(marker.entity, contextMetadata(id, marker));
|
||||||
if (newFix && id === selectedId) refreshTrail();
|
if (newFix && id === selectedId) refreshTrail();
|
||||||
@@ -513,7 +464,7 @@ export function createLocalAdsbLayer({
|
|||||||
function sync() {
|
function sync() {
|
||||||
clearTimeout(syncTimer);
|
clearTimeout(syncTimer);
|
||||||
syncTimer = null;
|
syncTimer = null;
|
||||||
if (!dataSource) return;
|
if (!renderer) return;
|
||||||
const at = now();
|
const at = now();
|
||||||
const live = new Set();
|
const live = new Set();
|
||||||
let added = false;
|
let added = false;
|
||||||
@@ -527,7 +478,7 @@ export function createLocalAdsbLayer({
|
|||||||
let removed = false;
|
let removed = false;
|
||||||
for (const [id, marker] of markers) {
|
for (const [id, marker] of markers) {
|
||||||
if (live.has(id)) continue;
|
if (live.has(id)) continue;
|
||||||
if (marker.entity) dataSource.entities.remove(marker.entity);
|
if (marker.entity) renderer.markers.remove(marker.entity);
|
||||||
markers.delete(id);
|
markers.delete(id);
|
||||||
removed = true;
|
removed = true;
|
||||||
}
|
}
|
||||||
@@ -582,82 +533,46 @@ export function createLocalAdsbLayer({
|
|||||||
return true;
|
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,
|
* Join the engine's selection dispatcher with this layer's own click rule
|
||||||
* `'other'` when a sibling layer's contact is under the pointer, `'trail'`
|
* (`lookUnder`): a click on a local aircraft — on top, or under picks no
|
||||||
* for a trail line, or null for empty map.
|
* layer owns (a photoreal tile, a ring) and trail lines — always
|
||||||
*
|
* (re)selects it and republishes its card, including the one already
|
||||||
* The frontmost pick decides, except that something no layer owns (a
|
* selected (its card may have been displaced; a click on a marker never
|
||||||
* photoreal tile, a ring) or a trail line drawn over or through a local
|
* reads as "deselect"); another layer's contact on top, or empty map,
|
||||||
* aircraft does not hide it: the click looks underneath for the aircraft.
|
* 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() {
|
function installInteraction() {
|
||||||
if (clickHandler || !viewer?.scene?.canvas) return;
|
if (selectionRemove || !renderer) return;
|
||||||
clickHandler = createInputHandler(viewer.scene.canvas);
|
const dispatcher = services.selection?.selectionDispatcherFor?.(
|
||||||
clickHandler.setInputAction((click) => {
|
renderer.engine,
|
||||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
);
|
||||||
if (!isPointerFree() || !enabled) return;
|
if (!dispatcher) return;
|
||||||
const target = clickTarget(click.position);
|
selectionRemove = dispatcher.register(LAYER_ID, {
|
||||||
// Clicking a local aircraft always (re)selects it and republishes its
|
owns: (id) => markers.has(id),
|
||||||
// card, including the one already selected: its card may have been
|
lookUnder: (id) => String(id).startsWith(TRAIL_PICK_PREFIX),
|
||||||
// displaced, and a click on a marker must never read as "deselect".
|
onSelect(id) {
|
||||||
if (target && markers.has(target)) {
|
if (enabled && markers.has(id)) selectMarker(id);
|
||||||
selectMarker(target);
|
},
|
||||||
return;
|
onEmptyClick() {
|
||||||
}
|
if (!enabled || !selectedId) return;
|
||||||
// A click on a trail line is not empty map.
|
clearSelection();
|
||||||
if (target === 'trail' || !selectedId) return;
|
governorRequestRender('local-adsb-selection');
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseInteraction() {
|
function releaseInteraction() {
|
||||||
clickHandler?.destroy();
|
selectionRemove?.();
|
||||||
clickHandler = null;
|
selectionRemove = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function installFrame() {
|
function installFrame() {
|
||||||
if (removePreRender || !viewer?.scene?.preRender?.addEventListener) return;
|
if (removePreRender || !renderer) return;
|
||||||
removePreRender = viewer.scene.preRender.addEventListener(frame);
|
removePreRender = renderer.view.on('preRender', frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseFrame() {
|
function releaseFrame() {
|
||||||
@@ -671,12 +586,12 @@ export function createLocalAdsbLayer({
|
|||||||
for (const marker of markers.values()) {
|
for (const marker of markers.values()) {
|
||||||
if (rows.length >= maxCount) break;
|
if (rows.length >= maxCount) break;
|
||||||
if (!marker.position) continue;
|
if (!marker.position) continue;
|
||||||
const carto = Cesium.Cartographic.fromCartesian(marker.position);
|
const carto = geo.cartographic.fromCartesian(marker.position);
|
||||||
rows.push({
|
rows.push({
|
||||||
id: marker.record.icao,
|
id: marker.record.icao,
|
||||||
label: localAdsbTitle(marker.record),
|
label: localAdsbTitle(marker.record),
|
||||||
callsign: marker.record.callsign,
|
callsign: marker.record.callsign,
|
||||||
position: Cesium.Cartesian3.clone(marker.position),
|
position: geo.cartesian3.clone(marker.position),
|
||||||
latitude: marker.lat,
|
latitude: marker.lat,
|
||||||
longitude: marker.lon,
|
longitude: marker.lon,
|
||||||
altitudeM: carto?.height ?? 0,
|
altitudeM: carto?.height ?? 0,
|
||||||
@@ -697,18 +612,25 @@ export function createLocalAdsbLayer({
|
|||||||
/** Decoder-feed session; the Radio card shows its one-line summary. */
|
/** Decoder-feed session; the Radio card shows its one-line summary. */
|
||||||
feeds,
|
feeds,
|
||||||
|
|
||||||
init(nextViewer) {
|
/**
|
||||||
viewer = nextViewer;
|
* @param {object|null} nextViewer The Cesium viewer (null on an engine
|
||||||
dataSource = new Cesium.CustomDataSource(LAYER_ID);
|
* without one).
|
||||||
dataSource.show = false;
|
* @param {{ engine?: object }} [context] LayerLifecycle's context.
|
||||||
viewer.dataSources.add(dataSource);
|
*/
|
||||||
if (viewer.scene?.primitives) {
|
init(nextViewer, { engine = null } = {}) {
|
||||||
|
renderer = services.renderer.create({
|
||||||
|
viewer: nextViewer,
|
||||||
|
engine,
|
||||||
|
loadModel,
|
||||||
|
createTrail: services.trails?.createTrail ?? null,
|
||||||
|
});
|
||||||
|
renderer.attach();
|
||||||
|
if (renderer.models) {
|
||||||
models = createLocalAdsbModels({
|
models = createLocalAdsbModels({
|
||||||
viewer,
|
renderer,
|
||||||
color,
|
color: MODEL_COLOR,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
groundSnap: services.groundSnap?.createGroundSnap?.() || null,
|
groundSnap: services.groundSnap?.createGroundSnap?.() || null,
|
||||||
...(loadModel ? { loadModel } : {}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
services.geoid
|
services.geoid
|
||||||
@@ -727,7 +649,7 @@ export function createLocalAdsbLayer({
|
|||||||
|
|
||||||
async enable() {
|
async enable() {
|
||||||
enabled = true;
|
enabled = true;
|
||||||
if (dataSource) dataSource.show = true;
|
renderer?.setVisible(true);
|
||||||
registerPickOwner(LAYER_ID, (pickedValue) => markers.has(pickedValue));
|
registerPickOwner(LAYER_ID, (pickedValue) => markers.has(pickedValue));
|
||||||
installInteraction();
|
installInteraction();
|
||||||
installFrame();
|
installFrame();
|
||||||
@@ -753,7 +675,7 @@ export function createLocalAdsbLayer({
|
|||||||
unregisterPickOwner(LAYER_ID);
|
unregisterPickOwner(LAYER_ID);
|
||||||
sync();
|
sync();
|
||||||
models?.clear();
|
models?.clear();
|
||||||
if (dataSource) dataSource.show = false;
|
renderer?.setVisible(false);
|
||||||
markSourcesChanged('local-adsb-disabled');
|
markSourcesChanged('local-adsb-disabled');
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -786,9 +708,9 @@ export function createLocalAdsbLayer({
|
|||||||
updateRenderHold();
|
updateRenderHold();
|
||||||
models?.destroy();
|
models?.destroy();
|
||||||
models = null;
|
models = null;
|
||||||
if (viewer && dataSource) viewer.dataSources.remove(dataSource, true);
|
renderer?.detach();
|
||||||
dataSource = null;
|
renderer?.destroy();
|
||||||
viewer = null;
|
renderer = null;
|
||||||
void receiver.destroy?.();
|
void receiver.destroy?.();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ import {
|
|||||||
localAdsbReceiverLine,
|
localAdsbReceiverLine,
|
||||||
localAdsbStatus,
|
localAdsbStatus,
|
||||||
} from './index.js';
|
} 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 = {}) {
|
function fakeReceiver(initial = {}) {
|
||||||
const state = {
|
const state = {
|
||||||
@@ -73,6 +81,13 @@ function fakeServices() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
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() {} },
|
detection: { markSourcesChanged() {} },
|
||||||
overlays: { refreshReadout() {} },
|
overlays: { refreshReadout() {} },
|
||||||
},
|
},
|
||||||
@@ -198,9 +213,6 @@ async function enabledLayer(receiver, clock, feeds = null, options = {}) {
|
|||||||
services: { ...services, ...options.services },
|
services: { ...services, ...options.services },
|
||||||
now: () => clock.now,
|
now: () => clock.now,
|
||||||
loadModel: options.loadModel,
|
loadModel: options.loadModel,
|
||||||
...(options.createInputHandler
|
|
||||||
? { createInputHandler: options.createInputHandler }
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
layer.init(viewer);
|
layer.init(viewer);
|
||||||
await layer.enable();
|
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);
|
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() {
|
function clickHarness() {
|
||||||
const base = fakeViewer();
|
const base = fakeViewer();
|
||||||
const scene = {
|
const engine = createFakeEngine();
|
||||||
...base.viewer.scene,
|
const scene = { pickResult: null, drillResult: [] };
|
||||||
canvas: {},
|
engine.fake.script('pick', () => scene.pickResult ?? undefined);
|
||||||
pickResult: null,
|
engine.fake.script('drillPick', () => scene.drillResult);
|
||||||
drillResult: [],
|
const unregister = registerViewerEngine(base.viewer, engine);
|
||||||
pick() {
|
|
||||||
return scene.pickResult;
|
|
||||||
},
|
|
||||||
drillPick() {
|
|
||||||
return scene.drillResult;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const handler = { actions: new Map() };
|
|
||||||
return {
|
return {
|
||||||
scene,
|
scene,
|
||||||
viewer: { ...base, viewer: { ...base.viewer, scene } },
|
viewer: base,
|
||||||
createInputHandler: () => ({
|
release() {
|
||||||
setInputAction(action, type) {
|
unregister();
|
||||||
handler.actions.set(type, action);
|
engine.destroy();
|
||||||
},
|
},
|
||||||
destroy() {},
|
|
||||||
}),
|
|
||||||
click() {
|
click() {
|
||||||
handler.actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)({
|
engine.fake.input('down', { position: { x: 10, y: 10 } });
|
||||||
position: new Cesium.Cartesian2(10, 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 harness = clickHarness();
|
||||||
const { layer, calls } = await enabledLayer(receiver, clock, null, {
|
const { layer, calls } = await enabledLayer(receiver, clock, null, {
|
||||||
viewer: harness.viewer,
|
viewer: harness.viewer,
|
||||||
createInputHandler: harness.createInputHandler,
|
|
||||||
});
|
});
|
||||||
t.after(() => layer.destroy());
|
t.after(() => {
|
||||||
|
layer.destroy();
|
||||||
|
harness.release();
|
||||||
|
});
|
||||||
receiver.set({ aircraft: [record()] });
|
receiver.set({ aircraft: [record()] });
|
||||||
await layer.update();
|
await layer.update();
|
||||||
const entity =
|
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 receiver = fakeReceiver({ mode: 'adsb', connected: true });
|
||||||
const clock = { now: 100_000 };
|
const clock = { now: 100_000 };
|
||||||
const harness = clickHarness();
|
const harness = clickHarness();
|
||||||
const owners = new Map();
|
|
||||||
const { resolvePickId } = await import('../../data/pickRegistry.js');
|
|
||||||
const { layer, calls } = await enabledLayer(receiver, clock, null, {
|
const { layer, calls } = await enabledLayer(receiver, clock, null, {
|
||||||
viewer: harness.viewer,
|
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:'));
|
// The app's pick owners: the trails, and a Flights contact.
|
||||||
owners.set('flights', (id) => id === 'abc123');
|
registerPickOwner('trails', (id) => String(id).startsWith('gev-trail:'));
|
||||||
t.after(() => layer.destroy());
|
registerPickOwner('flights', (id) => id === 'abc123');
|
||||||
|
t.after(() => {
|
||||||
|
unregisterPickOwner('trails');
|
||||||
|
unregisterPickOwner('flights');
|
||||||
|
layer.destroy();
|
||||||
|
harness.release();
|
||||||
|
});
|
||||||
receiver.set({
|
receiver.set({
|
||||||
aircraft: [record(), record({ icao: 'def456', lat: 0.01 })],
|
aircraft: [record(), record({ icao: 'def456', lat: 0.01 })],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as geo from '../../geo/index.js';
|
||||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
|
||||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||||
import { civilAircraftModelSpec } from '../flights/modelSpec.js';
|
import { civilAircraftModelSpec } from '../flights/modelSpec.js';
|
||||||
import {
|
import {
|
||||||
@@ -7,7 +6,6 @@ import {
|
|||||||
MODEL_ALL_ADD_M,
|
MODEL_ALL_ADD_M,
|
||||||
MODEL_ALL_KEEP_M,
|
MODEL_ALL_KEEP_M,
|
||||||
MODEL_ALT_CEIL_M,
|
MODEL_ALT_CEIL_M,
|
||||||
MODEL_HEADING_OFFSET_DEG,
|
|
||||||
MODEL_MAX,
|
MODEL_MAX,
|
||||||
MODEL_MAX_ALL,
|
MODEL_MAX_ALL,
|
||||||
MODEL_MIN_PX,
|
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
|
* 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
|
* 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
|
* 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
|
||||||
* @param {object} options.viewer Cesium viewer.
|
* @param {object} options.renderer The layer's renderer (render/cesium.js,
|
||||||
* @param {Cesium.Color} options.color Model tint.
|
* render/navara.js).
|
||||||
|
* @param {object} options.color Model tint (a colour with red/green/blue/alpha).
|
||||||
* @param {(url: string) => string} options.resolveAsset
|
* @param {(url: string) => string} options.resolveAsset
|
||||||
* @param {object|null} [options.groundSnap] `createGroundSnap()` instance.
|
* @param {object|null} [options.groundSnap] `createGroundSnap()` instance.
|
||||||
* @param {(options: object) => Promise<object>} [options.loadModel]
|
|
||||||
* @returns {object}
|
* @returns {object}
|
||||||
*/
|
*/
|
||||||
export function createLocalAdsbModels({
|
export function createLocalAdsbModels({
|
||||||
viewer,
|
renderer,
|
||||||
color,
|
color,
|
||||||
resolveAsset,
|
resolveAsset,
|
||||||
groundSnap = null,
|
groundSnap = null,
|
||||||
loadModel = (options) => Cesium.Model.fromGltfAsync(options),
|
|
||||||
}) {
|
}) {
|
||||||
const collection = new Cesium.PrimitiveCollection();
|
const draw = renderer.models;
|
||||||
viewer.scene.primitives.add(collection);
|
const view = renderer.view;
|
||||||
/** @type {Map<string, object>} id -> admitted model */
|
/** @type {Map<string, object>} id -> admitted model */
|
||||||
const models = new Map();
|
const models = new Map();
|
||||||
/** @type {Map<string, number>} id -> generation of an in-flight load */
|
/** @type {Map<string, number>} id -> generation of an in-flight load */
|
||||||
@@ -53,17 +52,15 @@ export function createLocalAdsbModels({
|
|||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let lastEligibilityAt = 0;
|
let lastEligibilityAt = 0;
|
||||||
let eligible = new Set();
|
let eligible = new Set();
|
||||||
const scratchHpr = new Cesium.HeadingPitchRoll();
|
const scratchCarto = geo.cartographic.create();
|
||||||
const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1);
|
const scratchGround = geo.cartesian3.create();
|
||||||
const scratchCarto = new Cesium.Cartographic();
|
|
||||||
const scratchGround = new Cesium.Cartesian3();
|
|
||||||
|
|
||||||
function release(id) {
|
function release(id) {
|
||||||
pending.delete(id);
|
pending.delete(id);
|
||||||
const model = models.get(id);
|
const model = models.get(id);
|
||||||
if (!model) return;
|
if (!model) return;
|
||||||
models.delete(id);
|
models.delete(id);
|
||||||
if (!collection.isDestroyed()) collection.remove(model);
|
draw.remove(model);
|
||||||
groundSnap?.forget?.(id);
|
groundSnap?.forget?.(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,14 +81,15 @@ export function createLocalAdsbModels({
|
|||||||
pending.set(id, loadGeneration);
|
pending.set(id, loadGeneration);
|
||||||
let model;
|
let model;
|
||||||
try {
|
try {
|
||||||
model = await loadModel({
|
model = await draw.loadFleet({
|
||||||
url: resolveAsset(spec.url),
|
url: resolveAsset(spec.url),
|
||||||
asynchronous: false,
|
|
||||||
minimumPixelSize: MODEL_MIN_PX,
|
minimumPixelSize: MODEL_MIN_PX,
|
||||||
scale: spec.scale,
|
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,
|
color,
|
||||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
blendAmount: spec.blendAmount,
|
||||||
colorBlendAmount: spec.blendAmount,
|
|
||||||
id,
|
id,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -107,7 +105,7 @@ export function createLocalAdsbModels({
|
|||||||
specKey(civilAircraftModelSpec(marker.klass)) !== key;
|
specKey(civilAircraftModelSpec(marker.klass)) !== key;
|
||||||
if (pending.get(id) === loadGeneration) pending.delete(id);
|
if (pending.get(id) === loadGeneration) pending.delete(id);
|
||||||
if (stale) {
|
if (stale) {
|
||||||
model.destroy?.();
|
draw.destroy(model);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
model.id = id;
|
model.id = id;
|
||||||
@@ -115,13 +113,13 @@ export function createLocalAdsbModels({
|
|||||||
model._gevBellyM = spec.bellyM;
|
model._gevBellyM = spec.bellyM;
|
||||||
// Admitted, not yet the visual: placement turns it on.
|
// Admitted, not yet the visual: placement turns it on.
|
||||||
model.show = false;
|
model.show = false;
|
||||||
collection.add(model);
|
draw.add(model);
|
||||||
models.set(id, model);
|
models.set(id, model);
|
||||||
}
|
}
|
||||||
|
|
||||||
function regime(preferences) {
|
function regime(preferences) {
|
||||||
if (!preferences?.models3d) return false;
|
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;
|
return height < MODEL_ALT_CEIL_M;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,27 +128,17 @@ export function createLocalAdsbModels({
|
|||||||
const cap = all ? MODEL_MAX_ALL : MODEL_MAX;
|
const cap = all ? MODEL_MAX_ALL : MODEL_MAX;
|
||||||
const addM = all ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M;
|
const addM = all ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M;
|
||||||
const keepM = all ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M;
|
const keepM = all ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M;
|
||||||
const camera = viewer.camera;
|
const cameraPosition = view.camera.positionWC;
|
||||||
const cameraPosition = camera.positionWC;
|
const inFrustum = view.frustumTester();
|
||||||
const cull = camera.frustum.computeCullingVolume(
|
|
||||||
cameraPosition,
|
|
||||||
camera.directionWC,
|
|
||||||
camera.upWC,
|
|
||||||
);
|
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
for (const [id, marker] of markers) {
|
for (const [id, marker] of markers) {
|
||||||
if (!marker.position) continue;
|
if (!marker.position) continue;
|
||||||
const distanceSq = Cesium.Cartesian3.distanceSquared(
|
const distanceSq = geo.cartesian3.distanceSquared(
|
||||||
cameraPosition,
|
cameraPosition,
|
||||||
marker.position,
|
marker.position,
|
||||||
);
|
);
|
||||||
if (distanceSq > keepM * keepM) continue;
|
if (distanceSq > keepM * keepM) continue;
|
||||||
Cesium.Cartesian3.clone(marker.position, scratchSphere.center);
|
candidates.push([id, distanceSq, inFrustum(marker.position)]);
|
||||||
candidates.push([
|
|
||||||
id,
|
|
||||||
distanceSq,
|
|
||||||
cull.computeVisibility(scratchSphere) !== Cesium.Intersect.OUTSIDE,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
candidates.sort((a, b) => a[1] - b[1]);
|
candidates.sort((a, b) => a[1] - b[1]);
|
||||||
eligible = selectModelEligible(candidates, {
|
eligible = selectModelEligible(candidates, {
|
||||||
@@ -168,20 +156,23 @@ export function createLocalAdsbModels({
|
|||||||
const model = models.get(id);
|
const model = models.get(id);
|
||||||
// The vertical sample must hit the tile skin, not this aircraft's own
|
// The vertical sample must hit the tile skin, not this aircraft's own
|
||||||
// billboard or model.
|
// billboard or model.
|
||||||
const height = groundSnap?.heightFor?.(viewer, id, marker.position, () =>
|
const height = groundSnap?.heightFor?.(
|
||||||
[marker.entity, model].filter(Boolean),
|
view.surfaceOwner,
|
||||||
|
id,
|
||||||
|
marker.position,
|
||||||
|
() => [marker.entity, model].filter(Boolean),
|
||||||
);
|
);
|
||||||
if (!Number.isFinite(height)) return null;
|
if (!Number.isFinite(height)) return null;
|
||||||
const carto = Cesium.Cartographic.fromCartesian(
|
const carto = geo.cartographic.fromCartesian(
|
||||||
marker.position,
|
marker.position,
|
||||||
Cesium.Ellipsoid.WGS84,
|
geo.ellipsoid.WGS84,
|
||||||
scratchCarto,
|
scratchCarto,
|
||||||
);
|
);
|
||||||
return Cesium.Cartesian3.fromRadians(
|
return geo.cartesian3.fromRadians(
|
||||||
carto.longitude,
|
carto.longitude,
|
||||||
carto.latitude,
|
carto.latitude,
|
||||||
height + (model?._gevBellyM || 0),
|
height + (model?._gevBellyM || 0),
|
||||||
Cesium.Ellipsoid.WGS84,
|
geo.ellipsoid.WGS84,
|
||||||
scratchGround,
|
scratchGround,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -213,7 +204,7 @@ export function createLocalAdsbModels({
|
|||||||
lastEligibilityAt = nowMs;
|
lastEligibilityAt = nowMs;
|
||||||
refreshEligibility(markers, preferences);
|
refreshEligibility(markers, preferences);
|
||||||
}
|
}
|
||||||
const occluder = horizonOccluder(viewer.camera);
|
const occluder = view.horizon();
|
||||||
for (const [id, marker] of markers) {
|
for (const [id, marker] of markers) {
|
||||||
const model = models.get(id);
|
const model = models.get(id);
|
||||||
marker.modelOwnsVisual = false;
|
marker.modelOwnsVisual = false;
|
||||||
@@ -223,18 +214,9 @@ export function createLocalAdsbModels({
|
|||||||
model.show = false;
|
model.show = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
scratchHpr.heading = Cesium.Math.toRadians(
|
// The renderer's pose: course + the Flights models' nose offset,
|
||||||
(marker.courseDeg || 0) + MODEL_HEADING_OFFSET_DEG,
|
// into the model's own matrix.
|
||||||
);
|
draw.setPose(model, position, marker.courseDeg || 0);
|
||||||
scratchHpr.pitch = 0;
|
|
||||||
scratchHpr.roll = 0;
|
|
||||||
Cesium.Transforms.headingPitchRollToFixedFrame(
|
|
||||||
position,
|
|
||||||
scratchHpr,
|
|
||||||
Cesium.Ellipsoid.WGS84,
|
|
||||||
undefined,
|
|
||||||
model.modelMatrix,
|
|
||||||
);
|
|
||||||
if (!model.ready) {
|
if (!model.ready) {
|
||||||
model.show = false;
|
model.show = false;
|
||||||
continue;
|
continue;
|
||||||
@@ -271,12 +253,11 @@ export function createLocalAdsbModels({
|
|||||||
return models.size;
|
return models.size;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Drop every model; the renderer removes the collection (detach). */
|
||||||
destroy() {
|
destroy() {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
releaseAll();
|
releaseAll();
|
||||||
groundSnap?.clear?.();
|
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