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:
German Khokhlov committed 2026-10-17 05:50:30 +04:00
1 parent 941b308c29
commit 0b8cb855fa
15 files changed
+549 -306

No files matched your search

+1
View File
@@ -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/',
];
+1
View File
@@ -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",
+4 -2
View File
@@ -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",
+15
View File
@@ -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,
+2
View File
@@ -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.
+2 -1
View File
@@ -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 });
+4 -1
View File
@@ -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/,
+51 -9
View File
@@ -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) {
+51
View File
@@ -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');
}
});
+1 -1
View File
@@ -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' \}\)/);
+8 -4
View File
@@ -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
View File
@@ -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?.();
},
+46 -46
View File
@@ -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 })],
});
+39 -58
View File
@@ -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);
},
};
}
+218
View File
@@ -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;
}