Draw the aircraft family, Wildlife Sightings and the Cockpit through the contract

The layers that still had one Cesium and one Navara implementation, or
drew on Cesium only, now draw through engine.movers, engine.lines and
engine.camera on both engines, and application code holds the engine
instead of guessing it from a viewer.

- Aircraft family: flights, military and local ADS-B share one display
  (src/layers/aircraft/render/display.js). The Cesium and Navara
  renderers are gone. Where they differed, the display follows Cesium:
  - attach and visibility tracking, live disableDepthTestDistance
    changes, the tracked glyph's distance scale, model ids that change,
    local ADS-B markers hidden until shown and its UAT ring as a point;
  - the trail head's hidden alpha is 0.45, the contract's
    depthFailAlpha scaled by the head's 0.9.
  The tracked glyph stays over the fleet, as on Navara. The movers
  contract reports readiness per model and reads model axes as the
  glTF's own on every engine. Cesium's follow takes any native target
  through a proxy entity in the ENU frame and mirrors its readout fields.
- Wildlife Sightings draws badges and the density glow as sprite sets
  and its privacy cells as line outlines (no fill yet). It takes clicks
  from engine.input and its card anchors to the engine's canvas and
  camera, so the layer is in both engines' catalogs again and a switch
  keeps the session's layer set.
- Cockpit: cockpitHost takes the engine only. The ridden aircraft is the
  engine follow's target, hidden through its sprite; only the Cesium
  Viewer's stock double-click is still routed around.
- About forty camera, navigation, share-link, HUD, voice, detection and
  layer-lifecycle callers take the engine. isGlobeEngine and
  engineFromViewer calls outside src/globe fall from 51 to 14.

The coupling baseline drops accordingly: layer renderer lines from
5239/12331 to 4546/11106 (Cesium/Navara), `viewer` lines from 2424 to
2209, Cesium-importing files from 69 to 66.
This commit is contained in:
German Khokhlov committed 2026-11-07 02:34:34 +04:00
1 parent 48988015f1
commit 35e70ec280
145 files changed
+4716 -4212

No files matched your search

+2 -3
View File
@@ -3,9 +3,8 @@
`?engine=navara`. Navara draws at CSS pixels like Cesium; Navara HD
(`?engine=navara-hd`) draws at the screen's full resolution and needs about
1 GiB more on a Retina screen. Layers keep their data across a switch and
draw on both engines, except Wildlife Sightings, which is Cesium-only for
now; Cesium loads only when a Cesium globe or a Cesium-only feature needs
it (German, @gekh, #1555).
draw on both engines; Cesium loads only when a Cesium globe or a
Cesium-only feature needs it (German, @gekh, #1555).
- Keep the cockpit camera steady near the ground: it no longer jumps up and
down when an aircraft's reported altitude sits below the globe's terrain,
on either engine (German, @gekh, #1555).
+17 -7
View File
@@ -206,9 +206,13 @@ one application, and only the globe-bound parts move to the next engine.
(`scripts/qa-strict-parked-release.mjs`).
- **Renderers per engine.** Each layer keeps its model and display policy in
engine-neutral files and draws through `render/cesium*.js` or
`render/navara*.js`. Cesium renderers are listed in
`src/app/layers/renderers.js` (`CESIUM_LAYER_RENDERERS`), Navara ones are
loaded by `registerNavaraLayerRenderers` in `src/app/navaraMode.js`. Navara
`render/navara*.js`, or through one `render/display.js` that runs on every
engine over the globe contract: the aircraft family (flights, military and
local ADS-B) draws through `src/layers/aircraft/render/display.js`. Cesium
renderers are listed in `src/app/layers/renderers.js`
(`CESIUM_LAYER_RENDERERS`), Navara ones are loaded by
`registerNavaraLayerRenderers` in `src/app/navaraMode.js`; both register the
same display factory for a layer that has one. Navara
ground effects (heat, pulses, trails, pads, drapes) are declared with
`defineGroundEffect` (`src/layers/shared/render/navaraGroundEffect.js`);
their insert order is pinned by `navaraGroundOrder.test.mjs`.
@@ -288,17 +292,23 @@ snapshot, including one whose rows all fall outside coverage or the species
checks, is a fresh success that clears earlier rows; only a malformed or failed
upstream reply serves stale and starts a retry cooldown. WHALES accepts only
recognized cetaceans; other identified marine mammals appear under ALL or
MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia
MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells, drawn as
outlines (`engine.lines`), with no fill yet; Acartia
positions are rounded to 0.01° on the server. Acartia comments are never retained.
Observer identity is retained only for photo attribution.
`/api/inaturalist/grid` supplies a dedicated, nonpickable billboard density glow,
The layer draws on both engines through one display
(`src/layers/wildlifeSightings/display.js`): taxon badges and the density glow
are `engine.movers` sprite sets.
`/api/inaturalist/grid` supplies a dedicated, nonpickable sprite density glow,
with a view-relative density ramp from cool teal to warm amber. Overlapping
Gaussian splats span four logical cell widths; mono and CRT styles follow map
and cockpit vision changes. Empty snapshots paint nothing. Full footprints
stay inside the globe limb with a 5% radius margin; opacity fades from full at
3,000 km to zero at 8,000 km camera altitude. Culling and cached coordinates
add no per-frame work and leave sighting counts intact.
3,000 km to zero at 8,000 km camera altitude. Splats are sized in metres on
the CPU, only on frames where the camera moved; culling and cached coordinates
leave sighting counts intact.
Only real sampled observation rows produce taxon badges at their public
positions. The badges use 28 px screen buckets, a 22 px overlap check and a
+7 -7
View File
@@ -1,16 +1,16 @@
{
"metrics": {
"cesiumLayerRendererLines": 5239,
"navaraLayerRendererLines": 12331,
"cesiumImportFiles": 69,
"viewerLines": 2424,
"cesiumLayerRendererLines": 4546,
"navaraLayerRendererLines": 11106,
"cesiumImportFiles": 66,
"viewerLines": 2209,
"navaraKindChecks": 17,
"engineSniffCalls": 51,
"engineSniffCalls": 14,
"engineExtensionReads": 27,
"groundEffectPasses": 9,
"cesiumSdkLoads": 6,
"screenSpaceHandlerFiles": 20,
"engineNameLines": 175
"engineNameLines": 162
},
"measuredAt": "german/navara merged with origin/main 8da8c05e, 2026-11-07"
"measuredAt": "after the aircraft, Wildlife and Cockpit displays and engine-only app code, 2026-11-07"
}
+11 -11
View File
@@ -1213,22 +1213,21 @@
"src/data/headingDegrees.test.mjs",
"src/data/distanceFormat.test.mjs",
"src/sources/adsbFields.test.mjs",
"src/layers/flights/render/cesium.js",
"src/layers/flights/render/navara.js",
"src/layers/aircraft/render/navara.test.mjs",
"src/layers/aircraft/render/navara.js",
"src/layers/flights/render/display.js",
"src/layers/aircraft/render/display.test.mjs",
"src/layers/aircraft/render/display.js",
"src/layers/flights/renderer.test.mjs",
"src/layers/military/renderer.test.mjs",
"src/layers/military/policy.test.mjs",
"src/testSupport/aircraftRecordingRenderer.mjs",
"src/layers/aircraft/render/cesium.test.mjs",
"src/layers/aircraft/render/cesium.js",
"src/testSupport/aircraftViewerRenderer.mjs",
"src/testSupport/cesiumMoversEngine.mjs",
"src/testSupport/cockpitEngine.mjs",
"src/layers/aircraft/render/contract.js",
"src/layers/shared/color.test.mjs",
"src/layers/shared/color.js",
"src/layers/shared/viewRectangle.js",
"src/layers/military/render/cesium.js",
"src/layers/military/render/navara.js",
"src/layers/military/render/display.js",
"src/layers/aircraft/qaDrawn.js",
"src/app/navaraLayers.test.mjs",
"src/layers/vessels/icons.js",
@@ -1240,6 +1239,7 @@
"src/layers/shared/render/engineView.js",
"src/layers/shared/render/navaraGroundEffect.js",
"src/layers/shared/render/navaraMoverBank.js",
"src/layers/shared/render/moverBank.js",
"src/layers/shared/render/imageAtlas.js",
"src/layers/installations/render/cesium/lifecycle.js",
"src/layers/installations/render/cesium/rendering.js",
@@ -1317,8 +1317,7 @@
"src/layers/transit/render/navaraTrail.js",
"src/layers/transit/render/navara.test.mjs",
"scripts/fixtures/w2/transit.mjs",
"src/layers/localAdsb/render/cesium.js",
"src/layers/localAdsb/render/navara.js",
"src/layers/localAdsb/render/display.js",
"src/layers/localAdsb/renderer.test.mjs",
"build/navaraAssets.js",
"scripts/qa-engine-switch.mjs",
@@ -1624,5 +1623,6 @@
"scripts/engineCoupling.test.mjs",
"scripts/qa-contract-suite.mjs",
"src/globe/cesium/doubleClick.js",
"scripts/engine-coupling-baseline.json"
"scripts/engine-coupling-baseline.json",
"src/layers/wildlifeSightings/display.js"
]
+18 -15
View File
@@ -3483,18 +3483,14 @@
"src/globe/navara/followGestures.js",
"src/app/layers/directory.js",
"src/app/layers/renderers.js",
"src/layers/aircraft/render/cesium.js",
"src/layers/flights/render/cesium.js",
"src/layers/military/render/cesium.js",
"src/layers/aircraft/render/display.js",
"src/layers/flights/render/display.js",
"src/layers/military/render/display.js",
"src/data/selectionDispatcher.js",
"src/layers/shared/color.js",
"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/localAdsb/render/cesium.js",
"src/layers/localAdsb/render/display.js",
"src/layers/vessels/render/cesium.js",
"src/layers/localAdsb/render/navara.js",
"src/layers/vessels/render/navara.js",
"src/layers/launches/render/navara.js",
"src/layers/launches/render/navaraPadGround.js",
@@ -3550,6 +3546,7 @@
"src/layers/transit/render/navara.js",
"src/layers/transit/render/navaraSprites.js",
"src/layers/shared/render/navaraMoverBank.js",
"src/layers/shared/render/moverBank.js",
"src/layers/transit/render/navaraView.js",
"src/layers/transit/render/navaraTrail.js",
"src/layers/recentImagery/render/navara.js",
@@ -3619,7 +3616,8 @@
"src/overlays/worldOverlayChrome.js",
"src/layers/wildlifeSightings/photoText.js",
"src/layers/wildlifeSightings/performance.js",
"src/globe/cesium/doubleClick.js"
"src/globe/cesium/doubleClick.js",
"src/layers/wildlifeSightings/display.js"
],
"external": [
"hls.js",
@@ -4303,13 +4301,14 @@
"src/globe/cesium/follow.js",
"src/app/layers/directory.js",
"src/app/layers/renderers.js",
"src/layers/aircraft/render/cesium.js",
"src/layers/aircraft/render/display.js",
"src/data/selectionDispatcher.js",
"src/layers/shared/color.js",
"src/layers/flights/render/cesium.js",
"src/layers/military/render/cesium.js",
"src/layers/flights/render/display.js",
"src/layers/military/render/display.js",
"src/layers/aircraft/qaDrawn.js",
"src/layers/localAdsb/render/cesium.js",
"src/layers/localAdsb/render/display.js",
"src/layers/shared/render/moverBank.js",
"src/layers/vessels/render/cesium.js",
"src/celestialRing/render/cesium.js",
"src/celestialRing/view.js",
@@ -4352,7 +4351,8 @@
"src/layers/wildlifeSightings/sprites.js",
"src/overlays/worldOverlayChrome.js",
"src/layers/wildlifeSightings/photoText.js",
"src/layers/wildlifeSightings/performance.js"
"src/layers/wildlifeSightings/performance.js",
"src/layers/wildlifeSightings/display.js"
],
"external": [
"hls.js",
@@ -5647,7 +5647,10 @@
"src/geo/",
"src/globe/contract.js",
"src/globe/cesium/engineFromViewer.js",
"src/globe/cesium/credits.js"
"src/globe/cesium/credits.js",
"src/layers/wildlifeSightings/display.js",
"src/data/spriteOrder.js",
"src/layers/shared/color.js"
],
"external": ["cesium"]
},
+1 -1
View File
@@ -226,7 +226,7 @@ try {
await page.evaluate(async (v) => {
const { flyToLandmark } = await import('/src/locations.js');
await new Promise((resolve) =>
flyToLandmark(window.__godsEyeView.viewer, v.lat, v.lon, {
flyToLandmark(window.__godsEyeView.engine, v.lat, v.lon, {
range: 1000,
pitch: -15,
heading: 0,
+2 -2
View File
@@ -39,7 +39,7 @@ try {
const { maxPitchForHeight } = await import('/src/app/zoomOutGuard.js');
const { readCameraTargetFrame } =
await import('/src/ui/cameraOrientationControls.js');
const viewer = window.__godsEyeView.viewer;
const { viewer, engine } = window.__godsEyeView;
viewer.camera.cancelFlight();
viewer.trackedEntity = undefined;
// Explicit real renders make each gesture independent of host frame rate.
@@ -51,7 +51,7 @@ try {
const qa = {
viewer,
guardWrites: 0,
targetPitch: () => readCameraTargetFrame(viewer)?.pitch,
targetPitch: () => readCameraTargetFrame(engine, viewer)?.pitch,
history: [],
frame() {
viewer.scene.requestRender();
+3 -9
View File
@@ -251,7 +251,7 @@ test('the catalog per engine: Cesium as before, Navara only what it can show', a
assert.equal(subset.get('military'), undefined);
});
test('both production registries attach to the same session models, Cesium-only layers aside', async (t) => {
test('both production registries attach to the same 38 session models', async (t) => {
const { registerNavaraLayerRenderers } = await import('./navaraMode.js');
const lifetime = new AbortController();
t.after(() => lifetime.abort());
@@ -265,18 +265,12 @@ test('both production registries attach to the same session models, Cesium-only
await registerNavaraLayerRenderers();
const navara = createApplicationCatalog({ ...options, engineKind: 'navara' });
assert.equal(cesium.layers.length, 38);
// Wildlife Sightings still draws itself on Cesium: Navara leaves it out
// until it draws through the globe contract.
const cesiumOnly = new Set(['wildlife-sightings']);
assert.deepEqual(
navara.layers.map(({ id }) => id),
cesium.layers.map(({ id }) => id).filter((id) => !cesiumOnly.has(id)),
cesium.layers.map(({ id }) => id),
);
assert.deepEqual(navara.metadata, cesium.metadata);
for (const layer of cesium.layers) {
if (cesiumOnly.has(layer.id)) {
assert.equal(navara.get(layer.id), undefined, layer.id);
continue;
}
assert.equal(navara.get(layer.id), layer, layer.id);
assert.equal(typeof layer.attachRenderer, 'function', layer.id);
assert.equal(typeof layer.detachRenderer, 'function', layer.id);
+1 -4
View File
@@ -31,10 +31,7 @@ test('the controls pass the scene engine to the shell', () => {
test('the shell initializes the world overlay on its engine', () => {
const shell = read('../ui/applicationShell.js');
assert.match(
shell,
/\{\n\s+engine = engineFromViewer\(viewer\),\n\s+mapStackController = null,/,
);
assert.match(shell, /\{\n\s+engine = null,\n\s+mapStackController = null,/);
assert.match(shell, /initWorldOverlay\(engine\);/);
assert.doesNotMatch(shell, /initWorldOverlay\(viewer\)/);
});
+2 -2
View File
@@ -3,8 +3,8 @@ import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js';
/**
* Build the page's one globe engine on the application viewer and tie its
* teardown to the scene lifetime; engineFromViewer(viewer) returns it while
* it lives. Call after the viewer's own cleanup is
* teardown to the scene lifetime; it stays registered for the viewer
* (engineFromViewer) while it lives. Call after the viewer's own cleanup is
* deferred, so the engine is destroyed first. Render holds taken before the
* render governor is installed apply when it is (src/renderGovernor.js).
* @param {Cesium.Viewer} viewer
+1 -1
View File
@@ -13,7 +13,7 @@ export function createApplicationBikeshare({ source }) {
rendererFactory: ({ engine, ...context }) =>
engine?.kind === 'navara'
? rendererFor('bikeshare', engine, context)
: createBikeshareCesiumRenderer(context),
: createBikeshareCesiumRenderer({ ...context, engine }),
services: { render, sprites, picking, overlays },
});
}
+4 -8
View File
@@ -4,7 +4,6 @@ import * as selection from '../../data/selectionDispatcher.js';
import * as aircraftPresentation from '../../data/tr3bRegistry.js';
import * as trackedCamera from '../../data/trackedCamera.js';
import { rendererFor } from './renderers.js';
import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js';
import * as labels from '../../data/detectionDraw.js';
import * as geoid from '../../data/geoid.js';
import * as focus from '../../data/focusDeemphasis.js';
@@ -20,12 +19,10 @@ export function createApplicationFlights({
militaryRegistry,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
// Test seam: the renderer factory (default: the engine's, from the registry).
// Test seam: the renderer factory (default: the engine's, from the
// registry: the flights display on every engine).
renderer = {
create: ({ viewer, engine = null, adopt }) =>
engine
? rendererFor('flights', engine, { viewer, adopt })
: createFlightsCesiumRenderer(null, { viewer, adopt }),
create: ({ engine = null } = {}) => rendererFor('flights', engine),
},
}) {
const { groundFloor, meshFloor, groundSnap } = surface;
@@ -35,8 +32,7 @@ export function createApplicationFlights({
services: {
picking,
selection,
// The engine's flights renderer from the registry (render/cesium.js on
// Cesium); a bare viewer without an engine (unit tests) draws on Cesium.
// The engine's flights renderer from the registry (render/display.js).
renderer,
aircraftPresentation,
camera: trackedCamera,
+4 -11
View File
@@ -6,8 +6,6 @@ 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';
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
@@ -30,12 +28,10 @@ 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).
// Test seam: the renderer factory (default: the engine's, from the
// registry: the Local ADS-B display on every engine).
renderer = {
create: ({ engine = null, ...options }) =>
engine
? rendererFor('local-adsb', engine, options)
: createLocalAdsbCesiumRenderer(null, options),
create: ({ engine = null } = {}) => rendererFor('local-adsb', engine),
},
} = {}) {
return createLocalAdsbLayer({
@@ -47,11 +43,8 @@ export function createApplicationLocalAdsb({
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.
// The engine's Local ADS-B renderer from the registry (render/display.js).
renderer,
trails,
geoid,
groundSnap: surface?.groundSnap || null,
enrichment,
+4 -9
View File
@@ -4,7 +4,6 @@ import * as selection from '../../data/selectionDispatcher.js';
import * as aircraftPresentation from '../../data/tr3bRegistry.js';
import * as trackedCamera from '../../data/trackedCamera.js';
import { rendererFor } from './renderers.js';
import { createMilitaryCesiumRenderer } from '../../layers/military/render/cesium.js';
import * as labels from '../../data/detectionDraw.js';
import * as geoid from '../../data/geoid.js';
import * as focus from '../../data/focusDeemphasis.js';
@@ -20,12 +19,10 @@ export function createApplicationMilitary({
militaryRegistry,
resolveAsset = (url) =>
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
// Test seam: the renderer factory (default: the engine's, from the registry).
// Test seam: the renderer factory (default: the engine's, from the
// registry: the military display on every engine).
renderer = {
create: ({ viewer, engine = null, adopt }) =>
engine
? rendererFor('military', engine, { viewer, adopt })
: createMilitaryCesiumRenderer(null, { viewer, adopt }),
create: ({ engine = null } = {}) => rendererFor('military', engine),
},
}) {
const { groundFloor, meshFloor, groundSnap } = surface;
@@ -35,9 +32,7 @@ export function createApplicationMilitary({
services: {
picking,
selection,
// The engine's military renderer from the registry (render/cesium.js
// on Cesium); a bare viewer without an engine (unit tests) draws on
// Cesium.
// The engine's military renderer from the registry (render/display.js).
renderer,
aircraftPresentation,
camera: trackedCamera,
+8 -7
View File
@@ -10,7 +10,8 @@ import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesiu
// availability API built on it.
//
// A renderer factory `(engine, options) => renderer` draws one layer on one
// engine kind (`render/cesium.js`, `render/navara.js` of a layer). Cesium
// engine kind (`render/cesium.js`, `render/navara.js` of a layer), or on
// every kind (its `render/display.js`, registered for each). Cesium
// layers without a render/cesium.js draw themselves: they are registered as
// CESIUM_BUILTIN. Cesium entries are registered here, statically (main
// chunk); Navara factories are registered by the Navara mode after its
@@ -32,9 +33,9 @@ import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesiu
// `availability` provider (`{ engine, unavailable() }` is the part it reads).
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';
import { createFlightsDisplay } from '../../layers/flights/render/display.js';
import { createMilitaryDisplay } from '../../layers/military/render/display.js';
import { createLocalAdsbDisplay } from '../../layers/localAdsb/render/display.js';
import { createSatellitesCesiumRenderer } from '../../layers/satellites/render/cesium.js';
import { createVesselsCesiumRenderer } from '../../layers/vessels/render/cesium.js';
@@ -107,9 +108,9 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({
'local-firms': createFirmsCesiumRenderer,
'rocket-launches': createLaunchesCesiumRenderer,
satellites: createSatellitesCesiumRenderer,
flights: createFlightsCesiumRenderer,
military: createMilitaryCesiumRenderer,
'local-adsb': createLocalAdsbCesiumRenderer,
flights: createFlightsDisplay,
military: createMilitaryDisplay,
'local-adsb': createLocalAdsbDisplay,
'ais-live-vessels': createVesselsCesiumRenderer,
});
+36 -7
View File
@@ -1,4 +1,5 @@
import test from 'node:test';
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import {
CESIUM_BUILTIN,
@@ -9,6 +10,7 @@ import {
layerAvailableOn,
} from './renderers.js';
import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
test('a registry knows which engine renders which layer', () => {
const registry = createRendererRegistry();
@@ -80,16 +82,43 @@ test('the aircraft, vessels, satellites, launches and earthquakes have extracted
},
);
assert.equal(vessels.kind, 'cesium');
const viewer = { scene: {}, camera: {} };
const renderer = rendererFor('flights', { kind: 'cesium' }, { viewer });
assert.equal(renderer.kind, 'cesium');
assert.equal(renderer.viewer, viewer);
const military = rendererFor('military', { kind: 'cesium' }, { viewer });
assert.equal(military.kind, 'cesium');
assert.equal(military.viewer, viewer);
assert.equal(rendererFor('satellites', { kind: 'cesium' }).kind, 'cesium');
});
test('the aircraft family draws through one display, the same factory on every engine', async () => {
const displays = {
flights: ['flights', 'createFlightsDisplay'],
military: ['military', 'createMilitaryDisplay'],
'local-adsb': ['localAdsb', 'createLocalAdsbDisplay'],
};
// The Navara mode registers the same modules' factories.
const navaraMode = readFileSync(
new URL('../navaraMode.js', import.meta.url),
'utf8',
);
const drawAtlas = async () => ({ canvas: {}, cells: {} });
for (const [id, [directory, name]] of Object.entries(displays)) {
const factory = (
await import(`../../layers/${directory}/render/display.js`)
)[name];
assert.equal(CESIUM_LAYER_RENDERERS[id], factory, id);
assert.match(
navaraMode,
new RegExp(
`'?${id}'?: async \\(\\) =>\\s*\\(await import\\('[^']+/render/display\\.js'\\)\\)\\s*\\.${name}\\b`,
),
id,
);
for (const kind of ['cesium', 'navara']) {
const engine = createFakeEngine({ kind });
const display = factory(engine, { drawAtlas });
assert.equal(display.engine, engine, `${id} on ${kind}`);
display.destroy();
engine.destroy();
}
}
});
test('the layer availability API: engine, isAvailable, available, unavailable', () => {
const registry = createRendererRegistry();
registry.register('navara', { flights: () => ({}) });
+1 -1
View File
@@ -30,7 +30,7 @@ export function createApplicationTransit({
rendererFactory = ({ engine, ...context }) =>
engine?.kind === 'navara'
? rendererFor('transit', engine, context)
: createTransitCesiumRenderer(context),
: createTransitCesiumRenderer({ ...context, engine }),
}) {
if (!surface?.groundFloor || !surface?.meshFloor)
throw new TypeError('Transit needs the application surface services');
+1 -6
View File
@@ -1,14 +1,12 @@
import * as Cesium from 'cesium';
import { createWildlifeSightingsLayer } from '../../layers/wildlifeSightings/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as sprites from '../../data/spriteOrder.js';
import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js';
import * as focus from '../../worldFocus.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Bind the wildlife layer to the application's scene and selection owners. */
/** Bind the wildlife layer to the application's selection and overlay owners. */
export function createApplicationWildlifeSightings(options) {
return createWildlifeSightingsLayer({
overlayHost: {
@@ -17,11 +15,8 @@ export function createApplicationWildlifeSightings(options) {
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
sprites,
render,
context,
focus,
+1 -1
View File
@@ -112,7 +112,7 @@ test('the standalone startup graph can be imported before the Cesium SDK exists'
const { createStandaloneCatalog } = await import('./src/standalone/catalog.js');
const lifetime = new AbortController();
const catalog = createStandaloneCatalog({ engineKind: 'navara', signal: lifetime.signal });
if (catalog.layers.length !== 37) throw new Error('Incomplete cold Navara catalog');
if (catalog.layers.length !== 38) throw new Error('Incomplete cold Navara catalog');
lifetime.abort();
`;
const result = spawnSync(
+1
View File
@@ -54,6 +54,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'gnss-integrity',
's2-ghostmaps',
'drone-airspace',
'wildlife-sightings',
'alpr-cameras',
'street-level',
'satellites',
+10 -6
View File
@@ -61,6 +61,7 @@ export function navaraModeNotice(availability) {
/** Static attribution for the shell and the layers available on Navara. */
const NAVARA_CREDIT_KEYS = new Set([
'inaturalist',
'photon-geocoder',
'openstreetmap',
'reearth-terrain',
@@ -244,6 +245,8 @@ export async function registerNavaraLayerRenderers() {
'osm-places': async () =>
(await import('../layers/osmPlaces/render/navara.js'))
.createOsmPlacesNavaraRenderer,
// Draws itself through the globe contract (engine.movers, engine.lines).
'wildlife-sightings': async () => () => null,
'drone-airspace': async () => {
const { createDroneAirspaceNavaraRenderer } =
await import('../layers/droneAirspace/render/navara.js');
@@ -289,15 +292,16 @@ export async function registerNavaraLayerRenderers() {
satellites: async () =>
(await import('../layers/satellites/render/navara.js'))
.createSatellitesNavaraRenderer,
// The aircraft family: the same displays as on Cesium (renderers.js).
flights: async () =>
(await import('../layers/flights/render/navara.js'))
.createFlightsNavaraRenderer,
(await import('../layers/flights/render/display.js'))
.createFlightsDisplay,
military: async () =>
(await import('../layers/military/render/navara.js'))
.createMilitaryNavaraRenderer,
(await import('../layers/military/render/display.js'))
.createMilitaryDisplay,
'local-adsb': async () =>
(await import('../layers/localAdsb/render/navara.js'))
.createLocalAdsbNavaraRenderer,
(await import('../layers/localAdsb/render/display.js'))
.createLocalAdsbDisplay,
'ais-live-vessels': async () =>
(await import('../layers/vessels/render/navara.js'))
.createVesselsNavaraRenderer,
+2 -2
View File
@@ -35,8 +35,8 @@ export function createApplicationOperations({ requests, signal, eventTarget }) {
requests,
surface,
annotationResolver,
searchAndFlyTo: (viewer, query, options = {}) =>
searchAndFlyTo(viewer, query, {
searchAndFlyTo: (engine, query, options = {}) =>
searchAndFlyTo(engine, query, {
...options,
features,
ground: surface.groundFloor,
+2 -5
View File
@@ -1,14 +1,11 @@
import * as geo from './geo/index.js';
import { engineFromViewer } from './globe/cesium/engineFromViewer.js';
/**
* Set camera to Austin on load with a cinematic fly-in.
* @param {import('./globe/contract.js').GlobeEngine | object} target The globe
* engine, or a viewer registered for one.
* @param {import('./globe/contract.js').GlobeEngine | null} engine
* @returns {Function} Cancels the pending or active startup flight.
*/
export function flyToAustin(target) {
const engine = engineFromViewer(target);
export function flyToAustin(engine) {
if (!engine) return () => {};
// Start from a high altitude, then fly down
engine.camera.setView({
+2 -5
View File
@@ -16,7 +16,6 @@
*/
import * as geo from './geo/index.js';
import { ownCameraArrival } from './data/cameraArrival.js';
import { engineFromViewer } from './globe/cesium/engineFromViewer.js';
/** Minimum eye height above the rendered surface for a usable view. */
export const MIN_EYE_CLEARANCE_M = 120;
@@ -80,14 +79,13 @@ export function groundClearanceDeficitM(
* (`camera.commanded`: a flight, setView, lookAt, cancelFlight or, on
* Cesium, a new tracked entity), or `isStale()` reporting that a newer
* arrival owns the camera.
* @param {import('./globe/contract.js').GlobeEngine | object} target The
* globe engine, or a viewer registered for one.
* @param {import('./globe/contract.js').GlobeEngine | null} engine
* @param {{lat: number, lon: number}} at Destination being framed.
* @param {{clearanceM?: number, attempts?: number, intervalMs?: number,
* isStale?: () => boolean, onLift?: (liftM: number) => void}} [options]
* @returns {() => void} Cancel function.
*/
export function guardCameraAboveGround(target, at, options = {}) {
export function guardCameraAboveGround(engine, at, options = {}) {
const {
clearanceM = MIN_EYE_CLEARANCE_M,
attempts = GUARD_ATTEMPTS,
@@ -95,7 +93,6 @@ export function guardCameraAboveGround(target, at, options = {}) {
isStale = () => false,
onLift = null,
} = options;
const engine = engineFromViewer(target);
if (!engine || !Number.isFinite(at?.lat) || !Number.isFinite(at?.lon))
return () => {};
+7 -9
View File
@@ -12,7 +12,6 @@ import {
guardCameraAboveGround,
} from './cameraGroundGuard.js';
import { createCesiumEngine } from './globe/cesium/createCesiumEngine.js';
import { registerViewerEngine } from './globe/cesium/engineFromViewer.js';
import { createFakeEngine } from './globe/testing/fakeEngine.js';
test('a buried camera is lifted clear of the surface', () => {
@@ -50,8 +49,8 @@ test('the clearance frames a subject without turning into an overflight', () =>
assert.ok(MIN_EYE_CLEARANCE_M <= 250);
});
// A viewer double under the real Cesium engine, registered as the app does,
// so the guard runs the shell's engine path against Cesium-shaped calls.
// A viewer double under the real Cesium engine (`viewer.engine`), so the
// guard runs the shell's engine path against Cesium-shaped calls.
function fakeViewer({ cameraHeightM, surfaceM, canvas = null }) {
const flights = [];
const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM);
@@ -75,7 +74,6 @@ function fakeViewer({ cameraHeightM, surfaceM, canvas = null }) {
},
};
viewer.engine = createCesiumEngine(viewer);
registerViewerEngine(viewer, viewer.engine);
return viewer;
}
@@ -83,7 +81,7 @@ test('the guard lifts a buried arrival once the surface answers', async () => {
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
const lifts = [];
guardCameraAboveGround(
viewer,
viewer.engine,
{ lat: 30.3125, lon: -97.765 },
{ intervalMs: 1, onLift: (m) => lifts.push(m) },
);
@@ -102,7 +100,7 @@ test('the guard waits for streaming tiles and yields to a newer arrival', async
let stale = false;
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: () => answer });
guardCameraAboveGround(
viewer,
viewer.engine,
{ lat: 30.3125, lon: -97.765 },
{ intervalMs: 2, attempts: 20, isStale: () => stale },
);
@@ -116,7 +114,7 @@ test('the guard waits for streaming tiles and yields to a newer arrival', async
test('the guard leaves a clear view untouched', async () => {
const viewer = fakeViewer({ cameraHeightM: 400, surfaceM: 171 });
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1 });
guardCameraAboveGround(viewer.engine, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1 });
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(viewer.flights.length, 0);
});
@@ -147,7 +145,7 @@ function handoffFixture(t) {
getDataManager() {}, interruptCameraMotion() {}, stopOrbit() {},
isCockpitActive: () => false, showToast() {},
});
guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 });
guardCameraAboveGround(viewer.engine, { lat: 30.3, lon: -97.7 });
return { viewer, navigation, originalFly };
}
@@ -168,7 +166,7 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
viewer.scene.canvas.ownerDocument.dispatchEvent(new Event('keydown'));
} else if (handoff === 'app-stop') navigation.stop();
else if (handoff.startsWith('layer-')) {
const lifecycle = new LayerLifecycle(viewer);
const lifecycle = new LayerLifecycle(viewer, { engine: viewer.engine });
lifecycle.register({ id: 'test', init() {}, enable() {}, disable() {}, destroy() {} });
await lifecycle.setEnabled('test', true);
if (handoff === 'layer-disable') await lifecycle.setEnabled('test', false);
+1 -1
View File
@@ -398,7 +398,7 @@ test('refused canned destinations commit no location or POI state', () => {
test('world-focus listener lifecycle is symmetric and idempotent', () => {
assert.match(ui, /registerWorldFocusRequestListener\(\s*window,/);
assert.match(ui, /routeWorldFocusRequest\(/);
assert.match(ui, /flyToWorldTarget\(this\.engine \?\? this\.viewer, detail\)/);
assert.match(ui, /flyToWorldTarget\(this\.engine, detail\)/);
assert.match(ui, /this\._removeWorldRequestFocusListener\?\.\(\);/);
assert.match(ui, /this\._removeWorldRequestFocusListener = null;/);
});
+3 -5
View File
@@ -10,7 +10,6 @@ import {
} from './cameraVerbs.js';
import { createGevActionRunner } from './voice/gevActions.js';
import { createCameraViewer } from './globe/cesium/testing/cameraViewer.mjs';
import { registerViewerEngine } from './globe/cesium/engineFromViewer.js';
const radians = Cesium.Math.toRadians;
const degrees = Cesium.Math.toDegrees;
@@ -35,8 +34,6 @@ function harness(
destination: Cesium.Cartesian3.fromDegrees(-0.1276, 51.5072, height),
orientation: { heading: radians(heading), pitch: radians(pitch), roll: 0 },
});
// A voice runner built on the viewer reaches the same engine.
const unregister = registerViewerEngine(viewer, engine);
// The app's view target: the ground point under the screen centre.
const viewTarget = () => {
const ray = new Cesium.Ray(camera.positionWC, camera.directionWC);
@@ -47,7 +44,6 @@ function harness(
initCameraVerbs(engine, pivot ? viewTarget : () => undefined);
t.after(() => {
interruptCameraMotion('test-cleanup');
unregister();
engine.destroy();
globalThis.performance = realPerformance;
});
@@ -59,7 +55,7 @@ function harness(
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
};
return { viewer, camera, target, tick, finish, viewTarget };
return { viewer, camera, engine, target, tick, finish, viewTarget };
}
const pitchDeg = (camera) => degrees(camera.pitch);
@@ -417,6 +413,7 @@ test('a voice zoom during a pivot tilt says what it changed', async (t) => {
const h = harness(t, { height: 400 });
const runner = createGevActionRunner({
viewer: h.viewer,
engine: h.engine,
styleManager: {},
dataManager: { layers: new Map(), getAll: () => [] },
});
@@ -653,6 +650,7 @@ test('a voice zoom during a tilt to an angle passes on the new end angle', async
const h = harness(t, { height: 400_000 });
const runner = createGevActionRunner({
viewer: h.viewer,
engine: h.engine,
styleManager: {},
dataManager: { layers: new Map(), getAll: () => [] },
});
+2 -3
View File
@@ -38,7 +38,6 @@ import {
holdContinuousRender,
releaseContinuousRender,
} from './renderGovernor.js';
import { isGlobeEngine } from './globe/contract.js';
let _isCockpitActive = null;
/** °/s by speed word — orbit; pan uses fractions of view height/s. */
@@ -1285,7 +1284,7 @@ const ENGINE_RIG_MEMBERS = Object.freeze([
* @returns {boolean}
*/
export function cameraVerbsSupported(target) {
if (!isGlobeEngine(target) || target.isDestroyed?.()) return false;
if (!target || target.isDestroyed?.()) return false;
return ENGINE_RIG_MEMBERS.every(
(path) =>
typeof path
@@ -2210,7 +2209,7 @@ export function initCameraVerbs(
_isCockpitActive = isCockpitActive;
return;
}
if (!isGlobeEngine(target)) return;
if (!target) return;
const previous = _rig;
const next = engineRigFor(target);
if (_active && (_active.rig === previous || previous === null)) {
+13 -5
View File
@@ -1,6 +1,5 @@
import * as geo from './geo/index.js';
import { createCelestialView } from './celestialRing/view.js';
import { engineFromViewer } from './globe/cesium/engineFromViewer.js';
import {
cesiumIcrfToFixed,
projectCelestialPoint,
@@ -245,6 +244,9 @@ export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) {
* @param {number} height - Viewport height in CSS pixels.
* @param {object} [scratchCenter] - Reusable Cesium Cartesian2 result.
* @param {object} [scratchToCenter] - Reusable Cesium Cartesian3 result.
* @param {import('./globe/contract.js').GlobeProjector | null} [projector] -
* The globe engine's projector; without one, Cesium's SceneTransforms
* (the same projection on a Cesium scene).
* @returns {object|null}
*/
export function projectEarthDiscToViewport(
@@ -253,6 +255,7 @@ export function projectEarthDiscToViewport(
height,
scratchCenter = undefined,
scratchToCenter = undefined,
projector = null,
) {
const camera = viewer?.camera;
const scene = viewer?.scene;
@@ -271,7 +274,6 @@ export function projectEarthDiscToViewport(
);
if (geo.cartesian3.dot(camera.directionWC, toCenter) <= 0) return null;
const projector = engineFromViewer(viewer)?.projector;
const center = projector
? projector.worldToWindow(geo.cartesian3.ZERO, scratchCenter)
: projectCelestialPoint(scene, geo.cartesian3.ZERO, scratchCenter);
@@ -398,14 +400,19 @@ let rings = 0;
export class CelestialRing {
/**
* @param {Cesium.Viewer} viewer
* @param {{enabled?:boolean,onAutoDisable?:Function,store:object}} options
* @param {{enabled?:boolean,onAutoDisable?:Function,store:object,
* engine?:object}} options
* `store` is the celestial ring store (src/ui/stores/celestialRing.js);
* the keyhole helpers here stay free of Vue for the packages that use
* them.
* them. `engine`: the globe engine, whose projector places the Earth disc.
*/
constructor(viewer, { enabled = true, onAutoDisable = null, store } = {}) {
constructor(
viewer,
{ enabled = true, onAutoDisable = null, store, engine = null } = {},
) {
this._engineView = viewer?.kind ? createCelestialView(viewer) : null;
this.viewer = this._engineView?.view ?? viewer;
this._projector = engine?.projector ?? null;
this.enabled = !!enabled;
this.visible = false;
this._onAutoDisable =
@@ -684,6 +691,7 @@ export class CelestialRing {
height,
this._screenCenter,
this._toCenter,
this._projector,
);
}
-3
View File
@@ -1,6 +1,5 @@
// The shared engine view has no renderer-library imports. Reuse its cached
// camera basis for the celestial canvas, with flight and host ownership here.
import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js';
import { createEngineLayerView } from '../layers/shared/render/engineView.js';
export function createCelestialView(engine) {
const adapter = createEngineLayerView(engine, { layerId: 'celestial-ring' });
@@ -11,11 +10,9 @@ export function createCelestialView(engine) {
view.scene.requestRender = () => engine.render.request('celestial-ring');
view.camera.flyTo = ({ easingFunction, ...options }) =>
engine.camera.flyTo({ ...options, easing: 'cubicInOut' });
const unregister = registerViewerEngine(view, engine);
return {
view,
destroy() {
unregister();
adapter.destroy();
},
};
+10 -9
View File
@@ -10,6 +10,7 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { SceneControls } from './ui/sceneControls.js';
import { isRenderedOnScreen } from './ui/cockpitPresentation.js';
import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js';
import { cockpitHost } from './ui/cockpitHost.js';
import {
enter as cockpitEnter,
exit as cockpitExit,
@@ -223,11 +224,11 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
assert.match(enter, /activeElement/);
assert.match(enter, /mapViewButton.*focus|focus.*mapViewButton/s);
assert.match(exit, /focus\(\{ preventScroll: true \}\)/);
assert.match(exit, /this\.host\.restore\(entity, this\.restoreTrackingFrame\)/);
assert.match(exit, /this\.host\.restore\(entity\)/);
assert.doesNotMatch(exit, /applyTrackedCameraFrame/);
assert.match(
ui,
/restoreTrackingFrame: \(entity\) => \{[\s\S]*?gevTrackedId[\s\S]*?flightsLayer\.refocusTrackedById[\s\S]*?militaryFlightsLayer\.refocusTrackedById/,
cockpitHost.toString(),
/restore: \(target\) => target\.resumeAircraftFollow\(\)/,
'Cockpit exit must return camera-frame ownership through the source layer so Contact Focus cannot accumulate a second owner',
);
});
@@ -295,13 +296,13 @@ test('the Contact panel never hides itself out from under its own NEXT button',
);
});
test('the cockpit reads its aircraft from the layer that owns Cesium tracking', () => {
test('the cockpit reads its aircraft from the layer that owns the tracking', () => {
const read = readAircraftInfo.toString();
assert.ok(read, 'readAircraftInfo is missing');
assert.match(read, /resolveTrackedAircraftInfo\(\{/);
assert.match(read, /gevTrackedId/);
// In cockpit mode the controller moves the entity off viewer.trackedEntity,
// so its own handle is the tracked identity there.
// In cockpit mode the controller releases the engine's follow of the
// aircraft, so its own handle is the tracked identity there.
assert.match(read, /this\.host\.followed\(\) \|\| this\.trackedEntity/);
});
@@ -1380,9 +1381,9 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => {
});
test('cockpit state cannot report entryAllowed while already active', () => {
// Cockpit takes the entity off viewer.trackedEntity on entry and NEXT puts
// one back, so this flipped true/false between calls while active stayed
// true — read by the voice model as a broken half-entered state.
// Cockpit releases the engine's follow on entry and NEXT starts another,
// so this flipped true/false between calls while active stayed true —
// read by the voice model as a broken half-entered state.
const state = ui.slice(
ui.indexOf(' getCockpitState() {'),
ui.indexOf(' _retargetCockpitEntryLayer({'),
+7 -14
View File
@@ -1,26 +1,19 @@
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
/**
* Arrival corrections belong to one globe engine and yield to its next camera
* owner. Callers may name the engine or (layers that still hold one) the
* viewer it is registered for; both reach the same correction.
* owner.
*/
const arrivals = new WeakMap();
const ownerOf = (engineOrViewer) =>
engineFromViewer(engineOrViewer) ?? engineOrViewer;
/** Cancel an outstanding arrival correction before navigation or teardown. */
export function cancelCameraArrival(engineOrViewer) {
if (engineOrViewer) arrivals.get(ownerOf(engineOrViewer))?.();
export function cancelCameraArrival(engine) {
if (engine) arrivals.get(engine)?.();
}
/** Register a correction and return an idempotent ownership release. */
export function ownCameraArrival(engineOrViewer, cancel) {
const owner = ownerOf(engineOrViewer);
cancelCameraArrival(owner);
arrivals.set(owner, cancel);
export function ownCameraArrival(engine, cancel) {
cancelCameraArrival(engine);
arrivals.set(engine, cancel);
return () => {
if (arrivals.get(owner) === cancel) arrivals.delete(owner);
if (arrivals.get(engine) === cancel) arrivals.delete(engine);
};
}
+5 -9
View File
@@ -1,13 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js';
import { cancelCameraArrival, ownCameraArrival } from './cameraArrival.js';
test('an arrival owned on the engine yields to a cancel through its viewer', () => {
test('an arrival owned on the engine yields to a cancel on the engine', () => {
const engine = createFakeEngine();
const viewer = {};
const unregister = registerViewerEngine(viewer, engine);
const cancelled = [];
// A correction releases its ownership when it stops, as the ground guard does.
const own = (key, name) => {
@@ -17,12 +14,11 @@ test('an arrival owned on the engine yields to a cancel through its viewer', ()
release();
});
};
own(engine, 'engine-owned');
cancelCameraArrival(viewer);
own(viewer, 'viewer-owned');
own(engine, 'first');
cancelCameraArrival(engine);
assert.deepEqual(cancelled, ['engine-owned', 'viewer-owned']);
unregister();
own(engine, 'second');
cancelCameraArrival(engine);
assert.deepEqual(cancelled, ['first', 'second']);
});
test('a newer arrival replaces the older one and release is idempotent', () => {
+6 -11
View File
@@ -24,8 +24,6 @@
* for, only that nobody's answer is taken away while they still need it.
*/
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
/** @type {WeakMap<object, {base: number, claims: Map<string, number>}>} */
const _cameras = new WeakMap();
@@ -33,13 +31,11 @@ const _cameras = new WeakMap();
const DEFAULT_PERCENTAGE_CHANGED = 0.5;
/**
* One key per camera: the engine's camera. A globe engine claims through its
* camera; a Cesium viewer or its camera through the engine registered for
* that viewer (engineFromViewer); anything else is the camera itself (a
* camera has no `camera` of its own).
* One key per camera: a globe engine claims through its camera, and so does
* a viewer; anything else is the camera itself (a camera has no `camera` of
* its own). Every layer on one globe claims through its engine.
*/
const cameraOf = (target) =>
engineFromViewer(target)?.camera ?? target?.camera ?? target;
const cameraOf = (target) => target?.camera ?? target;
function ledgerFor(camera) {
let ledger = _cameras.get(camera);
@@ -64,9 +60,8 @@ function applyLedger(camera, ledger) {
/**
* Ask for a camera-change sensitivity at least as fine as `value`.
* @param {object|null|undefined} target A globe engine, its camera, or a
* Cesium viewer or camera (anything with `percentageChanged`). A viewer
* with a registered engine, and its camera, claim on the engine camera.
* @param {object|null|undefined} target A globe engine or its camera, or a
* camera (anything with `percentageChanged`) where no engine is given.
* @param {string} owner Stable claim name, usually the layer id.
* @param {number} value Requested `percentageChanged`.
* @returns {number|null} The value now in force, or null when there is no camera.
+4 -8
View File
@@ -26,30 +26,26 @@ test('claims on a globe engine set its camera sensitivity and share one ledger',
assert.deepEqual(cameraSensitivityClaims(engine), []);
});
test("a Cesium viewer's camera claims on the same key as its engine's camera", async () => {
test("a Cesium engine's claims share one ledger and set its viewer's camera", async () => {
const { createFakeViewer } =
await import('../globe/cesium/testing/fakeViewer.mjs');
const { createCesiumEngine } =
await import('../globe/cesium/createCesiumEngine.js');
const { registerViewerEngine } =
await import('../globe/cesium/engineFromViewer.js');
const viewer = createFakeViewer();
const engine = createCesiumEngine(viewer, { requestRender: () => {} });
const unregister = registerViewerEngine(viewer, engine);
try {
claimCameraSensitivity(viewer.camera, 'traffic', 0.05);
claimCameraSensitivity(engine.camera, 'traffic', 0.05);
claimCameraSensitivity(engine, 'transit', 0.05);
// One ledger: both claims are on it, and releasing one keeps the other.
assert.deepEqual(cameraSensitivityClaims(viewer).sort(), [
assert.deepEqual(cameraSensitivityClaims(engine).sort(), [
'traffic',
'transit',
]);
releaseCameraSensitivity(engine, 'transit');
assert.equal(viewer.camera.percentageChanged, 0.05);
releaseCameraSensitivity(viewer.camera, 'traffic');
releaseCameraSensitivity(engine.camera, 'traffic');
assert.equal(viewer.camera.percentageChanged, 0.5);
} finally {
unregister();
engine.destroy();
}
});
@@ -11,6 +11,7 @@ import militaryFlightsLayer, {
_setCockpitDetectionSubjectForTest as setMilitaryCockpitSubject,
_setTrackedMilitaryRefreshStateForTest,
} from './militaryFlights.js';
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
const SUBJECT = 'abc123';
const NEXT_SUBJECT = 'def456';
@@ -25,6 +26,7 @@ const LAYERS = [
setSubject: setFlightsCockpitSubject,
seed(icao24) {
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24,
entity: null,
billboard: candidateBillboard(icao24),
@@ -46,6 +48,7 @@ const LAYERS = [
setSubject: setMilitaryCockpitSubject,
seed(icao24) {
_setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24,
entity: null,
billboard: candidateBillboard(icao24),
+12 -9
View File
@@ -1,3 +1,4 @@
import { readFileSync } from 'node:fs';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -28,15 +29,17 @@ for (const layer of LAYERS) {
const source = readLayerSource(layer.path);
test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => {
// The aircraft renderer loads through its `loadModel`, Cesium.Model.fromGltfAsync
// unless a test injects a loader.
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/,
`Model.fromGltfAsync call ${index + 1} must keep bounded GLB readiness independent of tile jobs`);
}
// The layer's models are the engine's model movers (the aircraft display);
// on Cesium each instance loads through Cesium.Model.fromGltfAsync there.
assert.doesNotMatch(source, /Cesium\.Model\.fromGltfAsync/);
const movers = readFileSync(
new URL('../globe/cesium/movers.js', import.meta.url),
'utf8',
);
const calls = [...movers.matchAll(/\.loadModel\(\{([\s\S]*?)\}\)/g)];
assert.equal(calls.length, 1, 'one model load in the Cesium movers');
assert.match(calls[0][1], /\basynchronous:\s*false\b/,
'Model.fromGltfAsync must keep bounded GLB readiness independent of tile jobs');
});
test(`${layer.name}: Cockpit 3D obeys the shared Display toggle`, () => {
+10 -14
View File
@@ -1,4 +1,3 @@
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
import { paintTransitBracket } from './detectionDraw.js';
import * as geo from '../geo/index.js';
import { governorRequestRender } from '../renderGovernor.js';
@@ -198,18 +197,15 @@ export function countFadingRenderEntries(renderEntries) {
return count;
}
// ---------------------------------------------------------------------------
// Module-level state — singleton lifecycle tied to the Cesium viewer
// Module-level state — singleton lifecycle tied to the globe engine
// ---------------------------------------------------------------------------
/** @type {Cesium.Viewer|null} */
let _viewer = null;
/** @type {import('../globe/contract.js').GlobeEngine|null} */
let _engine = null;
/** The camera height (m): the engine's, else a bare viewer's (tests). */
/** The camera height (m), or undefined before init. */
function cameraHeight() {
const engine = engineFromViewer(_viewer);
return engine
? engine.camera.height()
: _viewer?.camera?.positionCartographic?.height;
return _engine?.camera.height();
}
/** @type {number} Current mode index into MODE_LABELS */
let _mode = MODE_OFF;
@@ -297,12 +293,12 @@ let _cockpitModeListener = null;
/**
* Initializes detection inside the shared world-overlay host and stores
* references to data layers. The host owns the canvas and render listener.
* @param {object} viewer - The globe engine (or a Cesium viewer; only the
* camera height is read, through the engine when one is registered).
* @param {import('../globe/contract.js').GlobeEngine} engine - The globe
* engine; only its camera height is read.
* @param {Array} layers - Data layer modules that may implement getDetectableObjects().
* @param {Function} onModeChange - Callback invoked with the new mode label string on mode changes.
*/
export function initDetection(viewer, layers, onModeChange) {
export function initDetection(engine, layers, onModeChange) {
if (_cockpitModeListener && typeof window !== 'undefined') {
window.removeEventListener(
'gev:cockpit-mode-changed',
@@ -311,7 +307,7 @@ export function initDetection(viewer, layers, onModeChange) {
}
_hostLane?.unregister?.();
_calloutLane?.unregister?.();
_viewer = viewer;
_engine = engine;
_layers = layers;
_onModeChange = onModeChange;
_debugBanner = detectionDebugRequested(
@@ -367,7 +363,7 @@ export function destroyDetection() {
_calloutLane = null;
_transitBracketPaths.clear();
_calloutCount = 0;
_viewer = null;
_engine = null;
_layers = [];
_onModeChange = null;
_hostCanvas = null;
+15 -15
View File
@@ -364,7 +364,7 @@ test('detection lifecycle re-hosts unchanged painters behind the sole host liste
const modes = [];
try {
initWorldOverlay(env.engine);
initDetection(env.viewer, [detectableLayer()], (mode) => modes.push(mode));
initDetection(env.engine, [detectableLayer()], (mode) => modes.push(mode));
assert.equal(env.postRender.listeners.size, 1);
assert.equal(env.document.getElementById('detection-overlay'), null);
assert.ok(env.document.getElementById('world-overlay-canvas'));
@@ -518,7 +518,7 @@ test('callouts stop painting the moment the last detectable object goes away', (
}];
try {
initWorldOverlay(env.engine);
initDetection(env.viewer, [{ id: 'flights', getDetectableObjects: () => objects }], () => {});
initDetection(env.engine, [{ id: 'flights', getDetectableObjects: () => objects }], () => {});
setMode('DENSE');
env.advance(250);
env.postRender.raise();
@@ -554,7 +554,7 @@ test('every style paints its OWN plate token through the production callout path
const env = installEnvironment();
try {
initWorldOverlay(env.engine);
initDetection(env.viewer, [mixedTierLayer()], () => {});
initDetection(env.engine, [mixedTierLayer()], () => {});
setMode('DENSE');
settleFrame(env);
settleFrame(env);
@@ -592,7 +592,7 @@ test('space-tier callouts take the heavier plate while air contacts keep the lig
const env = installEnvironment();
try {
initWorldOverlay(env.engine);
initDetection(env.viewer, [mixedTierLayer()], () => {});
initDetection(env.engine, [mixedTierLayer()], () => {});
setDetectionStyle('surveillance');
setMode('DENSE');
settleFrame(env);
@@ -620,7 +620,7 @@ test('surveillance military tier reaches the dedicated render target as shipped
const env = installEnvironment();
try {
initWorldOverlay(env.engine);
initDetection(env.viewer, [militaryLayer()], () => {});
initDetection(env.engine, [militaryLayer()], () => {});
setDetectionStyle('surveillance');
setMode('DENSE');
env.advance(250);
@@ -660,7 +660,7 @@ test('Global Context echo brackets reach the canvas in the detection style colou
echo,
});
initWorldOverlay(env.engine);
initDetection(env.viewer, [militaryLayer()], () => {});
initDetection(env.engine, [militaryLayer()], () => {});
setMode('DENSE');
setDetectionStyle('normal');
settleFrame(env);
@@ -706,7 +706,7 @@ test('a Global Context echo bracket is drawn outside its contact bracket and dim
level: () => 0.5,
});
initWorldOverlay(env.engine);
initDetection(env.viewer, [militaryLayer()], () => {});
initDetection(env.engine, [militaryLayer()], () => {});
setMode('DENSE');
setDetectionStyle('normal');
settleFrame(env);
@@ -778,7 +778,7 @@ test('under the Cyber sonar sweep an echo is never brighter than its own bracket
level: () => 1,
});
initWorldOverlay(env.engine);
initDetection(env.viewer, [contact], () => {});
initDetection(env.engine, [contact], () => {});
setMode('DENSE');
setDetectionStyle('normal');
settleFrame(env);
@@ -840,7 +840,7 @@ test('resting Global Context pulses skip echo work for the whole frame, not per
},
});
initWorldOverlay(env.engine);
initDetection(env.viewer, [layer], () => {});
initDetection(env.engine, [layer], () => {});
setMode('DENSE');
settleFrame(env);
levelCalls = 0;
@@ -867,7 +867,7 @@ test('pathological detection paint holds alternate frames without freezing share
const env = installEnvironment({ search: '?detectDebug=1' });
try {
initWorldOverlay(env.engine);
initDetection(env.viewer, [detectableLayer()], () => {});
initDetection(env.engine, [detectableLayer()], () => {});
setOverlayEntries('valve-card', [{
id: 'ambient-card',
position: new Cesium.Cartesian3(0, 0, 0),
@@ -974,7 +974,7 @@ test('the mode banner is absent by default and present behind the flag', () => {
const env = installEnvironment({ search });
try {
initWorldOverlay(env.engine);
initDetection(env.viewer, [detectableLayer()], () => {});
initDetection(env.engine, [detectableLayer()], () => {});
setMode('DENSE');
env.advance(250);
env.postRender.raise();
@@ -1001,7 +1001,7 @@ test('detection telemetry stays reachable programmatically with the banner hidde
const env = installEnvironment();
try {
initWorldOverlay(env.engine);
initDetection(env.viewer, [detectableLayer()], () => {});
initDetection(env.engine, [detectableLayer()], () => {});
setMode('DENSE');
env.advance(250);
env.postRender.raise();
@@ -1029,7 +1029,7 @@ test('civilian and military AIR brackets cover front, left, and right at Sparse
type: 'AIR',
}));
initWorldOverlay(env.engine);
initDetection(env.viewer, [{ id: layerId, getDetectableObjects: () => objects }], () => {});
initDetection(env.engine, [{ id: layerId, getDetectableObjects: () => objects }], () => {});
setDetectionTuning({ densityPct: 25 });
setMode('SPARSE');
settleFrame(env);
@@ -1087,7 +1087,7 @@ test('the backdrop feather reaches the canvas as a lighter plate against sky', (
const env = installEnvironment();
try {
initWorldOverlay(env.engine);
initDetection(env.viewer, [backdropLayer()], () => {});
initDetection(env.engine, [backdropLayer()], () => {});
setDetectionStyle('normal');
setMode('DENSE');
settleFrame(env);
@@ -1146,7 +1146,7 @@ test('a transit vehicle is a contact: box, route, mode and operator on the share
try {
initWorldOverlay(env.engine);
initDetection(
env.viewer,
env.engine,
[{ id: 'transit', getDetectableObjects: () => objects }],
() => {},
);
+10 -1
View File
@@ -28,6 +28,8 @@ import {
destroyTrackedReadout,
initTrackedReadout,
} from './trackedReadout.js';
import { trackingEngine } from '../testSupport/viewerEngine.mjs';
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
test('share-Follow absence requires an accepted OpenSky snapshot', async () => {
_setFlightTrackingRefreshOutcomeForTest({ status: 'source-unavailable' });
@@ -183,6 +185,7 @@ test('flights first update forwards caller cancellation into the feed request',
test('nonempty OpenSky payload with zero usable rows cannot prove share target absence', async () => {
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: 'abc123',
entity: { gevLabelModel: { title: 'WARM', details: [] } },
billboard: { show: false },
@@ -238,6 +241,7 @@ test('a newly seen aircraft appears at its delayed position, not ahead of it', a
};
const viewer = { camera: { positionCartographic: null }, scene: {} };
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: 'seed00',
entity: null,
billboard: {
@@ -326,6 +330,7 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA
const billboardCollection = { show: false, remove() {} };
const viewer = { camera: { positionCartographic: null }, scene: {} };
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24,
entity,
billboard,
@@ -475,8 +480,9 @@ test('real civil track path creates no native label and publishes every cached h
globalThis.fetch = async () => ({ ok: false });
_setTrackedOverlayHostForTest(host);
try {
initTrackedReadout(viewer);
initTrackedReadout(trackingEngine(viewer));
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24,
entity: null,
billboard,
@@ -736,6 +742,7 @@ function seedLabelContact({
meta: metaOverrides = {},
}) {
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: LABEL_ICAO,
entity: null,
billboard: {
@@ -944,6 +951,7 @@ test('civil tracked readout: a callsign-less enriched contact reads as its regis
const entity = { gevLabelModel: { title: 'OLD', details: [] } };
seedLabelContact({ callsign: ' ', registration: ' N123AB ', tracked: true });
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: LABEL_ICAO,
entity,
billboard: {
@@ -1082,6 +1090,7 @@ test('a newly seen taxiing aircraft starts on the floor under its delayed positi
};
const viewer = { camera: { positionCartographic: null }, scene: {} };
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: 'seed00',
entity: null,
billboard: {
+8 -7
View File
@@ -1,4 +1,3 @@
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
import { cancelCameraArrival } from './cameraArrival.js';
function cloneLayerParams(value) {
@@ -84,8 +83,8 @@ export class LayerLifecycle {
* @param {object | null} viewer The Cesium viewer (null on Navara).
* @param {{ engine?: import('../globe/contract.js').GlobeEngine,
* allowQaRegistration?: boolean, getSourceAvailability?: Function }} [options]
* `engine` (default: the viewer's registered engine): every module call
* gets `{ signal, engine }` as its second argument.
* `engine`: every module call gets `{ signal, engine }` as its second
* argument.
* `attachSettleTimeoutMs`: how long a scene bind or detach waits for
* asynchronous layer attachments before it continues without them.
*/
@@ -102,7 +101,7 @@ export class LayerLifecycle {
this._catalogSourceAvailability = getSourceAvailability;
this._visibilityTarget = visibilityTarget;
this.viewer = viewer;
this.engine = engine ?? engineFromViewer(viewer) ?? null;
this.engine = engine ?? null;
this._activityListeners = new Set();
this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain }
this._listeners = new Set();
@@ -214,7 +213,7 @@ export class LayerLifecycle {
bindCatalog(viewer, engine, catalog, options = {}) {
if (this._registrationsFinalized) throw new Error('Scene already attached');
this.viewer = viewer;
this.engine = engine ?? engineFromViewer(viewer) ?? null;
this.engine = engine ?? null;
this._catalogSourceAvailability = catalog.getSourceAvailability;
const scene = new AbortController();
this._sceneController = scene;
@@ -1456,7 +1455,9 @@ export class LayerLifecycle {
const entry = this.layers.get(layerId);
if (!entry) return { intentEpoch: null, promise: Promise.resolve() };
const desiredState = Boolean(shouldEnable);
if (!desiredState) cancelCameraArrival(this.viewer);
// A layer leaving ends the camera arrival correction on a globe with a
// viewer (Cesium).
if (!desiredState && this.viewer) cancelCameraArrival(this.engine);
if (entry.destroying) {
return {
intentEpoch: null,
@@ -2487,7 +2488,7 @@ export class LayerLifecycle {
async destroyLayer(layerId) {
const entry = this.layers.get(layerId);
if (!entry || entry.destroying) return false;
cancelCameraArrival(this.viewer);
if (this.viewer) cancelCameraArrival(this.engine);
entry.destroying = true;
// Work parked for the next scene ends now; teardown waits for its queue.
this._settleSceneWaiters(false, entry);
+9
View File
@@ -46,6 +46,7 @@ import {
presentAwarenessDistance,
} from '../layers/awareness/panel.js';
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
const militaryAwarenessSource = readLayerSource(
new URL('./militaryAwareness.js', import.meta.url),
@@ -938,6 +939,7 @@ test('Contacts activation adopts the production military tracked-subject descrip
try {
_setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: id,
entity: null,
billboard: { position, show: true },
@@ -1020,6 +1022,7 @@ test('Contacts activation adopts the production military tracked-subject descrip
} finally {
restores.reverse().forEach((restore) => restore());
_setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: id,
entity: null,
billboard: { position, show: true },
@@ -1047,6 +1050,7 @@ test('production military tracked-subject label falls back to registration befor
try {
_setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: id,
entity: null,
billboard: { position, show: true },
@@ -1100,6 +1104,7 @@ test('production military tracked-subject label falls back to registration befor
} finally {
restores.reverse().forEach((restore) => restore());
_setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: id,
entity: null,
billboard: { position, show: true },
@@ -1146,6 +1151,7 @@ test('a cached Context subject re-reads its label when enrichment lands after se
try {
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: id,
entity: null,
billboard: { position, color: Cesium.Color.WHITE, show: true },
@@ -1234,6 +1240,7 @@ test('a cached Context subject re-reads its label when enrichment lands after se
} finally {
restores.reverse().forEach((restore) => restore());
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: id,
entity: null,
billboard: { position, show: true },
@@ -1256,6 +1263,7 @@ test('Contacts activation adopts the production civilian tracked-subject descrip
try {
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: id,
entity: null,
billboard: { position, show: true },
@@ -1345,6 +1353,7 @@ test('Contacts activation adopts the production civilian tracked-subject descrip
} finally {
restores.reverse().forEach((restore) => restore());
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: id,
entity: null,
billboard: { position, show: true },
@@ -16,6 +16,7 @@ import {
registerMilitaryIcaos,
setMilitaryLayerActive,
} from './militaryRegistry.js';
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
const SHARED_HEX = 'ae5e01';
const CIVIL_HEX = 'a0c1v1';
@@ -84,6 +85,7 @@ test('edge:military: an ICAO in both feeds draws one target and keeps the milita
const civilBillboards = collection();
_armFlightTrackingRestoreForTest(null);
_setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: 'seedm0',
entity: null,
billboard: { show: false, position: Cesium.Cartesian3.ZERO },
@@ -93,6 +95,7 @@ test('edge:military: an ICAO in both feeds draws one target and keeps the milita
tracked: false,
});
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: 'seedf0',
entity: null,
billboard: { show: false, position: Cesium.Cartesian3.ZERO },
@@ -171,6 +174,7 @@ test('edge:military: losing the tracked contact from the owning feed releases th
trackedEntity: entity,
};
_setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: SHARED_HEX,
entity,
billboard: {
+8 -1
View File
@@ -17,6 +17,8 @@ import {
destroyTrackedReadout,
initTrackedReadout,
} from './trackedReadout.js';
import { trackingEngine } from '../testSupport/viewerEngine.mjs';
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
test('share-Follow absence requires an accepted adsb.lol snapshot', async () => {
_setMilitaryTrackingRefreshOutcomeForTest({ status: 'source-unavailable' });
@@ -146,6 +148,7 @@ test('military first update forwards caller cancellation into the feed request',
test('nonempty adsb.lol payload with zero usable rows cannot prove share target absence', async () => {
_setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: 'ae1234',
entity: { gevLabelModel: { title: 'WARM', details: [] } },
billboard: { show: false },
@@ -192,6 +195,7 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p
const billboardCollection = { show: false, remove() {} };
const viewer = { camera: { positionCartographic: null }, scene: {} };
_setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24,
entity,
billboard,
@@ -263,6 +267,7 @@ test('cached military snapshots retain their observation time and expose stale f
const history = [];
const viewer = { camera: { positionCartographic: null }, scene: {} };
_setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: 'ae01ce',
entity: { gevLabelModel: { title: 'RCH451', details: [] } },
billboard: {
@@ -403,8 +408,9 @@ test('real military track path creates no native label and publishes every cache
globalThis.fetch = async () => ({ ok: false });
_setTrackedOverlayHostForTest(host);
try {
initTrackedReadout(viewer);
initTrackedReadout(trackingEngine(viewer));
_setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24,
entity: null,
billboard,
@@ -844,6 +850,7 @@ test('the military tracked descriptor publishes the reported vertical rate', ()
// every military contact as level flight.
const seedRate = (verticalRateMps) =>
_setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: 'ae7f01',
entity: { gevLabelModel: { title: 'TEST', details: [] } },
billboard: {
+3
View File
@@ -12,6 +12,7 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import flightsLayer, { _setTrackedFlightRefreshStateForTest } from './flights.js';
import militaryFlightsLayer, { _setTrackedMilitaryRefreshStateForTest } from './militaryFlights.js';
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
const ICAO = 'abc123';
const OTHER = 'def456';
@@ -41,6 +42,7 @@ const LAYERS = [
visualCenterM: Cesium.Cartesian3.ZERO,
seed({ billboardShow, models = [] }) {
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: ICAO,
entity: null,
billboard: contactBillboard(billboardShow),
@@ -63,6 +65,7 @@ const LAYERS = [
visualCenterM: Cesium.Cartesian3.ZERO,
seed({ billboardShow, models = [] }) {
_setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: ICAO,
entity: null,
billboard: contactBillboard(billboardShow),
+7 -7
View File
@@ -109,17 +109,17 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r
assert.deepEqual(calls, [viewer, viewer, viewer]);
const firmsLayer = createFirmsHeatmapLayer({ id: 'firms', name: 'FIRMS' });
// Flights draws through its renderer: enable asks the fleet to
// restore its order, which the Cesium aircraft renderer wires to the shared
// restorer under the flights' sprite slot.
// Flights draws through its display: enable asks the fleet to restore its
// order; the display's sprite sets take the flights' slot as their draw
// order on every engine (aircraftLayerDisplay).
assert.match(flightsLayer.enable.toString(), /fleet\.restoreOrderOnEnable\(\)/);
assert.match(
readFileSync(new URL('../layers/aircraft/render/cesium.js', import.meta.url), 'utf8'),
/restoreOrderOnEnable: \(\) => restoreSpriteOrderOnEnable\(spriteLayer, viewer\)/,
readFileSync(new URL('../layers/aircraft/render/display.js', import.meta.url), 'utf8'),
/order: SPRITE_LAYER_ORDER\.indexOf\(layerId\)/,
);
assert.match(
readFileSync(new URL('../layers/flights/render/cesium.js', import.meta.url), 'utf8'),
/spriteLayer: 'flights'/,
readFileSync(new URL('../layers/flights/render/display.js', import.meta.url), 'utf8'),
/aircraftLayerDisplay\(\s*'flights',/,
);
assert.match(
firmsLayer.enable.toString(),
+6 -1
View File
@@ -33,6 +33,7 @@ import militaryFlightsLayer, {
import { findCompatibleHistoryIndex } from './militaryAwareness.js';
import { createGevActionRunner } from '../voice/gevActions.js';
import { ANALYST_LAYERS, createAnalystEngine } from './analystEngine.js';
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
/** Strip block and line comments so source pins scan CODE, not prose. */
function stripComments(source) {
@@ -161,6 +162,7 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke
};
const viewer = { camera: { positionCartographic: null }, scene: {} };
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24,
entity,
billboard,
@@ -260,7 +262,7 @@ test('both flight layers keep a converted contact 2D and visible (render invaria
// 4. Orientation contract is untouched: still a screen-projected rotation
// with alignedAxis ZERO, and no new per-frame CallbackProperty.
assert.match(source, /alignedAxis: Cesium\.Cartesian3\.ZERO/, `${name}: alignedAxis stays ZERO`);
assert.match(source, /alignedAxis: geo\.cartesian3\.ZERO/, `${name}: alignedAxis stays ZERO`);
assert.doesNotMatch(source, /isTr3b[\s\S]{0,200}new Cesium\.CallbackProperty/,
`${name}: the Easter egg adds no per-frame CallbackProperty`);
}
@@ -295,6 +297,7 @@ test('conversions are session-scoped and no lifecycle path clears them', async (
setTr3b(icao24, true);
const viewer = { camera: { positionCartographic: null }, scene: {} };
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24,
entity: null,
billboard: {
@@ -459,6 +462,7 @@ test('cockpit class filter matches a converted contact end to end', async () =>
// 2) Real getNearby record for a real (converted) contact in the layer.
const seed = () => _setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24,
entity: null,
billboard: {
@@ -505,6 +509,7 @@ test('military records and detection cards agree with the conversion', () => {
const icao24 = 'ae01ce';
const center = Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 200);
const seed = () => _setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24,
entity: null,
billboard: {
+3
View File
@@ -46,6 +46,7 @@ import militaryFlightsLayer, {
} from './militaryFlights.js';
import { clearTr3bRegistry, setTr3b } from './tr3bRegistry.js';
import { reportMeshFloorCell, _clearMeshFloorCellsForTest } from './groundFloor.js';
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
// Sample altitudes are expressed RELATIVE to the band rather than as absolute
// offsets. The thresholds have already been retuned once (1_000_000 → 150_000
@@ -219,6 +220,7 @@ const LAYERS = [
seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) {
const billboard = contactBillboard();
_setTrackedFlightRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24,
entity: null,
billboard,
@@ -244,6 +246,7 @@ const LAYERS = [
seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) {
const billboard = contactBillboard();
_setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24,
entity: null,
billboard,
+10 -20
View File
@@ -4,7 +4,6 @@ import {
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
import { WORLD_OVERLAY_STYLE } from '../overlays/worldOverlayTokens.js';
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
/**
* @module trackedReadout
@@ -34,9 +33,7 @@ const DEFAULT_TRACKED_OVERLAY_HOST = Object.freeze({
clearSource: clearOverlaySource,
});
let _viewer = null;
// The globe engine, when the readout was given one (or a viewer with a
// registered engine): the followed target is engine.camera.following().
// The globe engine: the followed target is engine.camera.following().
let _engine = null;
let _trackedEntityChangedRemove = null;
let _selectedContext = null;
@@ -149,8 +146,7 @@ function followSubject(handle) {
}
function trackedSubject() {
if (_engine) return followSubject(_engine.camera.following());
return _viewer?.trackedEntity || null;
return _engine ? followSubject(_engine.camera.following()) : null;
}
function activeEntity() {
@@ -262,22 +258,17 @@ const READOUT_CONTEXT_LAYERS = new Set(['alpr-cameras', 'local-adsb']);
/**
* Initialize the model bridge and selection listeners. No render listener is
* installed; the already-initialized world-overlay host owns the frame lane.
* @param {Object} target The globe engine (or a viewer whose engine is
* registered): the readout follows engine.camera.following(), its label
* (plain data) or its Cesium entity. A bare viewer (tests) is read for
* its trackedEntity.
* @param {import('../globe/contract.js').GlobeEngine} engine The readout
* follows engine.camera.following(), its label (plain data) or its Cesium
* entity.
*/
export function initTrackedReadout(target) {
if (!target || _viewer === target) return;
if (_viewer) destroyTrackedReadout();
_viewer = target;
_engine = engineFromViewer(target);
export function initTrackedReadout(engine) {
if (!engine || _engine === engine) return;
if (_engine) destroyTrackedReadout();
_engine = engine;
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, true);
const changed = _engine
? _engine.camera.followChanged
: target.trackedEntityChanged;
_trackedEntityChangedRemove =
changed?.addEventListener?.(() => {
engine.camera.followChanged?.addEventListener?.(() => {
if (trackedSubject()) _selectedContext = null;
syncActiveEntity();
}) || null;
@@ -343,7 +334,6 @@ export function destroyTrackedReadout() {
_selectedContext = null;
clearTrackedSource();
_overlayHost.setVisible(TRACKED_OVERLAY_SOURCE_ID, false);
_viewer = null;
_engine = null;
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import { trackingEngine } from '../testSupport/viewerEngine.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
@@ -157,7 +158,7 @@ test('tracked entity publishes a protected host entry backed by the frame cache'
globalThis.window = fakeWindow;
_setTrackedOverlayHostForTest(recorder.host);
try {
initTrackedReadout(viewer);
initTrackedReadout(trackingEngine(viewer));
const publication = recorder.calls.find(({ op }) => op === 'set');
assert.ok(publication);
assert.equal(publication.sourceId, 'tracked');
@@ -205,7 +206,7 @@ test('selection lifecycle leaves installations to their own entry and retains AL
globalThis.window = fakeWindow;
_setTrackedOverlayHostForTest(recorder.host);
try {
initTrackedReadout(viewer);
initTrackedReadout(trackingEngine(viewer));
const setsBefore = recorder.calls.filter(({ op }) => op === 'set').length;
fakeWindow.dispatchEvent(new CustomEvent('gev:entity-selected', {
detail: { layerId: 'ais-live-vessels', entity: installation },
+27 -5
View File
@@ -1,7 +1,8 @@
// camera.follow on Cesium: the viewer's trackedEntity (Cesium's EntityView
// owns the continuous follow, the user turns and zooms around the target).
// With `native` the layer's own Entity is tracked; without it an internal
// proxy Entity whose position is the caller's `position()`. Each follow gets
// A `native` Entity is tracked itself; otherwise an internal proxy Entity
// (ENU frame) whose position is the caller's `position()`, which shows a
// non-Entity native's tracked-readout fields. Each follow gets
// one exact tracked frame on preUpdate (applyTrackedCameraFrame) and the
// zoom limits that keep the camera outside the minimum range.
// following() also reports a trackedEntity the app set itself, and
@@ -21,6 +22,15 @@ import { toCesium3 } from './convert.js';
const MAX_FRAME_ATTEMPTS = 120;
const entityName = (entity) => (entity ? entity.name || entity.id : undefined);
// What viewer.trackedEntity readers take from a tracked Entity, read through
// a proxy from the layer's own handle.
const READOUT_FIELDS = [
'gevTrackedId',
'gevLabelModel',
'gevDisplayPosition',
'gevVisualPosition',
'gevSelectionOrigin',
];
const ZOOM_INERTIA_STATES = new WeakMap();
function acquireStableTrackedZoom(controller, owner, minRange) {
@@ -257,7 +267,8 @@ export function createCesiumFollow(viewer, { commanded }) {
function start(state, options) {
state.options = options;
let entity = options.native;
const { native } = options;
let entity = native instanceof Cesium.Entity ? native : null;
if (!entity) {
const scratch = new Cesium.Cartesian3();
entity = state.proxy = viewer.entities.add({
@@ -273,6 +284,7 @@ export function createCesiumFollow(viewer, { commanded }) {
: undefined;
}, false),
viewFrom: toCesium3(state.options.viewFrom()),
trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU,
// Cesium's EntityView moves the camera only once the tracked
// entity has a bounding sphere: a 1 px transparent point gives it
// one (an entity without graphics is framed once, then left).
@@ -280,14 +292,24 @@ export function createCesiumFollow(viewer, { commanded }) {
});
// Picks skip it (picker.js).
entity.gevFollowProxy = true;
if (native)
for (const field of READOUT_FIELDS)
Object.defineProperty(entity, field, {
get: () => native[field],
set: (value) => {
native[field] = value;
},
});
}
state.entity = entity;
state.handle.native = options.native;
state.handle.native = native;
state.handle.label = options.label;
viewer.camera.cancelFlight();
viewer.trackedEntity = entity;
const viewFrom =
options.native?.viewFrom ?? toCesium3(state.options.viewFrom());
entity === native && native.viewFrom
? native.viewFrom
: toCesium3(state.options.viewFrom());
state.stopFrame =
applyTrackedCameraFrame(viewer, entity, viewFrom, {
minRange: options.minRange,
+32
View File
@@ -77,6 +77,38 @@ test('without native the follow tracks a proxy entity at position()', () => {
assert.equal(viewer.trackedEntity, undefined);
});
test('a native that is not an Entity is echoed and followed through an ENU proxy that shows its readout fields', () => {
const { viewer, camera, frame } = setup();
const native = {
id: 'flights-tracked-1',
gevTrackedId: 'flights:abc123',
gevLabelModel: { title: 'DAL123' },
gevDisplayPosition: () => TARGET,
};
const handle = camera.follow({
native,
position: () => TARGET,
viewFrom: () => ({ x: 0, y: -500, z: 200 }),
});
const proxy = viewer.trackedEntity;
assert.ok(proxy instanceof Cesium.Entity);
assert.notEqual(proxy, native);
assert.equal(handle.native, native);
assert.equal(camera.following(), handle);
assert.equal(proxy.trackingReferenceFrame, Cesium.TrackingReferenceFrame.ENU);
assert.equal(proxy.gevTrackedId, 'flights:abc123');
native.gevLabelModel = { title: 'DAL124' };
assert.equal(proxy.gevLabelModel.title, 'DAL124', 'read through');
assert.equal(proxy.gevDisplayPosition(), TARGET);
frame();
const [[, offset]] = viewer
.callsOf('camera.lookAtTransform')
.map((call) => call.args);
assert.deepEqual([offset.x, offset.y, offset.z], [0, -500, 200]);
handle.stop();
assert.equal(viewer.entities.contains(proxy), false);
});
test('a camera command or another tracked entity ends the follow', () => {
const { viewer, camera, changes } = setup();
const options = { position: () => TARGET, viewFrom: { x: 0, y: -500, z: 0 } };
+20 -8
View File
@@ -3,10 +3,11 @@
// sprites are a BillboardCollection (screen-aligned, rotation in screen
// space, an image per atlas cell cropped from the set's atlas), points a
// PointPrimitiveCollection, models one Model per instance in a
// PrimitiveCollection. commit() writes the changed instances and requests a
// render; nothing else renders. Picks resolve through identify(): the
// billboard, point or model a Cesium pick names → its set, instance and
// the pickId the layer gave it.
// PrimitiveCollection (the glTF's own axes, no axis correction, lit without
// Cesium's dynamic environment map). commit() writes the changed instances
// and requests a render; nothing else renders. Picks resolve through
// identify(): the billboard, point or model a Cesium pick names → its set,
// instance and the pickId the layer gave it.
import * as Cesium from 'cesium';
import { horizonVisible } from '../cameraMath.js';
@@ -246,6 +247,15 @@ function modelPart(options, store, { factories, requestRender, owners }) {
factories
.loadModel({
url: model.url,
// Resources built at once, not spread over frames behind tile jobs.
asynchronous: false,
// The instance orientation turns the glTF's own axes (the contract's
// model frame), as Navara draws them.
upAxis: Cesium.Axis.Z,
forwardAxis: Cesium.Axis.X,
// Navara has no environment map; Cesium's renders the sky around
// each model and reads it back from the GPU.
environmentMapOptions: { enabled: false },
minimumPixelSize: model.minimumPixelSize,
...modelBlend(model.colorBlend),
allowPicking: pickable,
@@ -274,10 +284,12 @@ function modelPart(options, store, { factories, requestRender, owners }) {
};
return {
primitive: collection,
// Every drawn instance's model has loaded.
ready() {
for (let index = 0; index < store.count; index++)
if (!models[index]?.ready) return false;
// Every drawn instance's model has loaded (with an index, that one's).
ready(index) {
if (index !== undefined)
return index < store.count && models[index]?.ready === true;
for (let at = 0; at < store.count; at++)
if (!models[at]?.ready) return false;
return true;
},
commit(range) {
+20
View File
@@ -301,6 +301,22 @@ test('models load per instance with the set options and follow position, orienta
assert.equal(options.colorBlendAmount, 0.4);
assert.equal(options.allowPicking, true);
assert.equal(options.customShader, undefined, 'lit by default');
assert.equal(options.asynchronous, false, 'built at once');
// The glTF's own axes: no Cesium axis correction (Y_UP_TO_Z_UP ×
// Z_UP_TO_X_UP), so an orientation draws the same on every engine.
assert.equal(options.upAxis, Cesium.Axis.Z);
assert.equal(options.forwardAxis, Cesium.Axis.X);
assert.ok(
Cesium.Matrix4.equals(
Cesium.ModelUtility.getAxisCorrectionMatrix(
options.upAxis,
options.forwardAxis,
new Cesium.Matrix4(),
),
Cesium.Matrix4.IDENTITY,
),
);
assert.deepEqual(options.environmentMapOptions, { enabled: false });
const [first, second] = items(collection);
assert.notEqual(first.modelMatrix, second.modelMatrix);
const translation = Cesium.Matrix4.getTranslation(
@@ -316,8 +332,12 @@ test('models load per instance with the set options and follow position, orienta
assert.equal(second.id, 'second');
assert.equal(set.ready(), false, 'loaded models not yet ready');
first.ready = true;
assert.equal(set.ready(0), true, 'an instance is ready with its model');
assert.equal(set.ready(1), false);
assert.equal(set.ready(), false);
second.ready = true;
assert.equal(set.ready(), true);
assert.equal(set.ready(2), false, 'past the count');
// Shrinking hides the instance past the count; a later write moves it.
set.setCount(1);
set.commit();
+11 -6
View File
@@ -141,8 +141,11 @@
* from the target in its east-north-up frame, metres.
* @property {number} [minRange] Closest the camera comes (150 m).
* @property {'enu'} [frame]
* @property {any} [native] Cesium only (layers' render/cesium.js): the
* Entity to put on viewer.trackedEntity instead of a proxy.
* @property {any} [native] What is followed, as the handle's `native`. On
* Cesium an Entity goes on viewer.trackedEntity itself; anything else (a
* layer's own handle) is followed through a proxy Entity, which shows the
* handle's tracked-readout fields (gevTrackedId, gevLabelModel,
* gevDisplayPosition, gevVisualPosition, gevSelectionOrigin).
* @property {{ id: string, model: object | (() => object), position: () => Cartesian3 }} [label]
* The tracked readout's card (src/data/trackedReadout.js): its id, label
* model ({ title, details, accent, ... }) and anchor, as plain data.
@@ -401,7 +404,8 @@
* @property {number} [rotation] Sprites: screen angle in radians,
* counter-clockwise from screen up (Cesium's Billboard.rotation).
* @property {{ x: number, y: number, z: number, w: number }} [orientation]
* Models: the model frame's rotation into Earth-fixed axes.
* Models: the rotation of the glTF's own axes (as the file stores them,
* +Y up) into Earth-fixed axes; no engine adds an axis correction.
* @property {number[]} [color] rgba in 0..1; multiplies the sprite's texels.
* @property {number} [scale]
* @property {string} [cell] An atlas cell key.
@@ -415,9 +419,10 @@
* @property {() => number} count
* @property {() => void} commit Uploads what changed (one buffer write) and
* requests a render; nothing else draws a frame.
* @property {() => boolean} ready Whether what the set draws has loaded: a
* model set's glTF (Cesium: every drawn instance's model), a sprite set's
* atlas.
* @property {(index?: number) => boolean} ready Whether what the set draws
* has loaded: a model set's glTF (Cesium: every drawn instance's model), a
* sprite set's atlas. With an index, what that instance draws (Cesium: its
* own model; the other engines load a set's glTF once).
* A layer keeps its fallback visual until then.
* @property {() => void} destroy
*
+4 -3
View File
@@ -348,8 +348,9 @@ export function createMoverSet(
commit();
store.clean();
},
// Whether what it draws has loaded (a model's glTF, an atlas image).
ready: () => !destroyed && ready() === true,
// Whether what it draws has loaded (a model's glTF, an atlas image); with
// an index, what that instance draws.
ready: (index) => !destroyed && ready(index) === true,
destroy() {
if (destroyed) return;
destroyed = true;
@@ -385,7 +386,7 @@ export function createMoverRegistry(build) {
sets.delete(options.id);
part.destroy();
},
ready: () => (part.ready ? part.ready() : true),
ready: (index) => (part.ready ? part.ready(index) : true),
});
sets.set(options.id, entry);
return entry.set;
+13
View File
@@ -478,6 +478,11 @@ export function createContractSuite(
set.setCount(3);
set.commit();
ensure(set.count() === 3, `count ${set.count()}`);
ensure(
typeof set.ready() === 'boolean' &&
typeof set.ready(0) === 'boolean',
'ready() and ready(index) answer booleans',
);
ensure(
engine.movers
.list()
@@ -1211,6 +1216,10 @@ export function createContractSuite(
// ready() turns true once the glTF has loaded (the magenta point
// and sprites above can already fill the region before then).
await until(() => models.ready(), 'the model set ready()');
ensure(
models.ready(0) === true,
'the drawn instance is not ready(0)',
);
await until(() => modelPixels() > 150, 'the model drawn');
} finally {
for (const set of sets) set.destroy();
@@ -2548,7 +2557,10 @@ export function createContractSuite(
orientation: { heading: 0, pitch: -0.6, roll: 0 },
});
await settle();
// A layer's own handle as `native`: echoed, whatever the engine.
const followed = { id: 'contract-followed' };
const handle = camera.follow({
native: followed,
position: () => at(step),
viewFrom: { x: 0, y: -1000, z: 400 },
});
@@ -2556,6 +2568,7 @@ export function createContractSuite(
camera.following() === handle,
'following() is not the handle',
);
ensure(handle.native === followed, 'the handle lost its native');
ensure(
changes.at(-1) === handle,
'followChanged did not report the handle',
+1 -2
View File
@@ -20,7 +20,6 @@ import { applicationServices } from './services/application.js';
*/
import * as geo from './geo/index.js';
import { engineFromViewer } from './globe/cesium/engineFromViewer.js';
import { greatCircleKm } from './geoDistance.js';
import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './cityPois.js';
@@ -1236,7 +1235,7 @@ export class IntelHUD {
this._removeCameraMoveStart?.();
this._removeCameraMoveStart = null;
this._summaryRequest?.abort();
this.engine = engine ? engineFromViewer(engine) : null;
this.engine = engine ?? null;
this.viewer = viewer;
if (!this.engine) return;
this._removeCameraMoveStart =
-425
View File
@@ -1,425 +0,0 @@
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../../data/aircraftModelLighting.js';
// The aircraft renderer on Cesium (contract: ./contract.js): the Cesium
// objects the aircraft layers draw with — the fleet BillboardCollection and its sprite-order slot, the model
// PrimitiveCollection and its glTF loads, the tracked Entity (billboard with
// per-frame CallbackProperties, followed through viewer.trackedEntity and
// the one exact tracked frame), the trail entity and its live head
// segment, and the camera/frame reads. Nothing here decides anything: the
// layer's logic decides, this draws.
import * as Cesium from 'cesium';
import { createTrail } from '../../../data/trailRenderer.js';
import {
registerSpriteCollection,
restoreSpriteOrder,
restoreSpriteOrderOnEnable,
} from '../../../data/spriteOrder.js';
import { horizonOccluder } from '../../../data/iconOrientation.js';
import { engineFromViewer } from '../../../globe/cesium/engineFromViewer.js';
import { applyTrackedCameraFrame } from '../../../globe/cesium/follow.js';
// The model shader of the IR boost: boosted
// models render UNLIT so a side-lit airframe does not go black in FLIR/NVG.
// Assigning a customShader to a ready model is a silent no-op, so the layer
// reloads boosted models and the shader is set at creation. One stateless
// instance, shared.
let unlitShader = null;
const irUnlitShader = () =>
(unlitShader ??= new Cesium.CustomShader({
lightingModel: Cesium.LightingModel.UNLIT,
}));
// A colour from the layer (an Rgba or a Cesium.Color) as a Cesium.Color, for
// the entity properties that keep what they are given. Constants convert
// once.
const cesiumColors = new WeakMap();
function cesiumColor(color) {
if (color instanceof Cesium.Color) return color;
let converted = cesiumColors.get(color);
if (!converted) {
converted = new Cesium.Color(
color.red,
color.green,
color.blue,
color.alpha,
);
if (Object.isFrozen(color)) cesiumColors.set(color, converted);
}
return converted;
}
const cesiumNearFar = (scalar) =>
scalar instanceof Cesium.NearFarScalar
? scalar
: new Cesium.NearFarScalar(
scalar.near,
scalar.nearValue,
scalar.far,
scalar.farValue,
);
/**
* @param {object} options
* @param {Cesium.Viewer} options.viewer
* @param {object} [options.engine] The globe engine (default: the viewer's).
* @param {string} options.spriteLayer The fleet's slot in data/spriteOrder.
* @param {number} [options.headingOffsetDeg=0] Model nose vs Cesium heading 0.
* @param {{ billboardCollection?: object, modelCollection?: object }} [options.adopt]
* 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({
viewer,
engine = null,
spriteLayer,
headingOffsetDeg = 0,
adopt = null,
loadModel = (options) => Cesium.Model.fromGltfAsync(options),
}) {
let billboards = adopt?.billboardCollection ?? null;
let models = adopt?.modelCollection ?? null;
const scratchHpr = new Cesium.HeadingPitchRoll(0, 0, 0);
const scratchSphere = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 1.0);
// The frame's culling volume and one tester over it (no closure per call).
let cullingVolume = null;
const inFrustum = (position) => {
Cesium.Cartesian3.clone(position, scratchSphere.center);
return (
cullingVolume.computeVisibility(scratchSphere) !==
Cesium.Intersect.OUTSIDE
);
};
const scratchSize = {
width: undefined,
height: undefined,
scale: undefined,
scaleByDistance: undefined,
};
const scene = () => viewer?.scene;
const camera = () => viewer?.camera;
const view = {
get camera() {
return camera();
},
get scene() {
return scene();
},
get surfaceOwner() {
return viewer;
},
get surfaceScene() {
return viewer?.scene;
},
frameNumber: () => viewer?.scene?.frameState?.frameNumber ?? -1,
viewportHeightPx: () => viewer.scene.canvas.clientHeight,
// The tester stays valid until the next frustumTester() call.
frustumTester() {
const cam = viewer.camera;
cullingVolume = cam.frustum.computeCullingVolume(
cam.positionWC,
cam.directionWC,
cam.upWC,
);
return inFrustum;
},
horizon: () => horizonOccluder(viewer.camera),
worldToWindow: (position, result) =>
Cesium.SceneTransforms.worldToWindowCoordinates(
viewer.scene,
position,
result,
),
requestRender: () => viewer?.scene?.requestRender?.(),
on(event, listener) {
const source =
event === 'moveEnd'
? viewer?.camera?.moveEnd
: event === 'preRender'
? viewer?.scene?.preRender
: event === 'preUpdate'
? viewer?.scene?.preUpdate
: null;
return source ? source.addEventListener(listener) : null;
},
isDestroyed: () => !viewer || viewer.isDestroyed?.() === true,
// Evidence capture (qa-focus-evidence): Cesium's own render loop off,
// then its pending-loop latch closed so manual renders start at once.
holdFrames() {
viewer.useDefaultRenderLoop = false;
},
closeFrameLatch() {
if (viewer?._cesiumWidget)
viewer._cesiumWidget._renderLoopRunning = false;
},
releaseFrames() {
if (viewer) viewer.useDefaultRenderLoop = true;
},
};
const fleet = {
attach() {
billboards = new Cesium.BillboardCollection();
viewer.scene.primitives.add(billboards);
registerSpriteCollection(spriteLayer, billboards);
models = new Cesium.PrimitiveCollection();
viewer.scene.primitives.add(models);
},
// `owner`: the viewer the layer's destroy() was given (the same one).
detach(owner) {
const host = owner ?? viewer;
if (billboards) {
host.scene.primitives.remove(billboards);
billboards = null;
}
if (models) {
host.scene.primitives.remove(models); // removing destroys it + its models
models = null;
}
},
attached: () => !!billboards,
visible: () => !!billboards && billboards.show,
setVisible(visible) {
if (billboards) billboards.show = visible;
},
add: (spec) => billboards.add(spec),
remove: (sprite) => billboards.remove(sprite),
restoreOrder: () => restoreSpriteOrder(viewer),
restoreOrderOnEnable: () => restoreSpriteOrderOnEnable(spriteLayer, viewer),
};
const modelSet = {
live: () => !!models && !models.isDestroyed(),
setVisible(visible) {
if (models) models.show = visible;
},
loadFleet: ({
url,
minimumPixelSize,
scale,
color,
blendAmount,
unlit,
id,
}) =>
loadModel({
url,
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize,
scale,
color,
colorBlendMode: Cesium.ColorBlendMode.MIX,
colorBlendAmount: blendAmount,
customShader: unlit ? irUnlitShader() : undefined,
id, // so scene.pick returns the icao for click-to-track
}),
loadTracked: ({
url,
minimumPixelSize,
scale,
color,
blendAmount,
unlit,
id,
}) =>
loadModel({
url,
asynchronous: false,
environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS,
minimumPixelSize,
scale,
color,
colorBlendMode: Cesium.ColorBlendMode.MIX,
colorBlendAmount: blendAmount,
customShader: unlit ? irUnlitShader() : undefined,
// Pick id (H1): clicking the tracked plane's own model must read as
// that plane, not as empty space.
id,
}),
// Warms Cesium's glTF cache (the retained instance is never drawn).
preload: (url) =>
loadModel({
url,
asynchronous: false,
}),
add: (model) => models.add(model),
remove(model) {
if (models && !models.isDestroyed()) {
try {
models.remove(model);
} catch {
/* gone */
}
}
},
destroy(model) {
try {
model.destroy();
} catch {
/* gone */
}
},
/**
* World transform from a position and a course (pitch/roll 0, ENU frame),
* written into the model's OWN modelMatrix: Model.modelMatrix is a plain
* field Cesium clones per frame, so models never share one mutable
* matrix (a shared scratch stacked every model on the last-written
* plane: the "flickering like mad" bug). `offsetDeg`: this model's nose
* offset when the layer's models differ (default: the layer's).
*/
setPose(model, position, headingDeg, offsetDeg = headingOffsetDeg) {
scratchHpr.heading = Cesium.Math.toRadians((headingDeg || 0) + offsetDeg);
scratchHpr.pitch = 0;
scratchHpr.roll = 0;
return Cesium.Transforms.headingPitchRollToFixedFrame(
position,
scratchHpr,
Cesium.Ellipsoid.WGS84,
undefined,
model.modelMatrix,
);
},
};
const tracked = {
/**
* The tracked aircraft as an Entity: a pure billboard (no model or label
* graphic, so viewer.trackedEntity always has a ready bounding sphere),
* in the ENU tracking frame the close-range camera guard also uses.
* Cesium generates its id: an explicit one would throw when a re-init
* briefly overlaps collections.
*/
create(spec) {
return viewer.entities.add({
position: new Cesium.CallbackProperty(() => spec.position(), false),
trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU,
billboard: {
image: spec.image,
width: spec.width,
height: spec.height,
scale: spec.scale,
color: new Cesium.CallbackProperty(
() => cesiumColor(spec.color()),
false,
),
sizeInMeters: false,
scaleByDistance: cesiumNearFar(spec.scaleByDistance),
alignedAxis: Cesium.Cartesian3.ZERO,
disableDepthTestDistance: spec.disableDepthTestDistance,
rotation: new Cesium.CallbackProperty(() => spec.rotation(), false),
},
});
},
remove(handle) {
viewer.entities.remove(handle);
},
isLive: (handle) => !!viewer?.entities?.contains?.(handle),
setImage(handle, image) {
if (handle?.billboard) handle.billboard.image = image;
},
setViewFrom(handle, offset) {
handle.viewFrom = new Cesium.Cartesian3(offset.x, offset.y, offset.z);
},
/**
* The tracked billboard's drawn size inputs, at the viewer's time. The
* result is reused (read it before the next call).
*/
billboardSize(handle) {
const billboard = handle?.billboard;
if (!billboard) return null;
const time = viewer.clock.currentTime;
scratchSize.width = billboard.width?.getValue(time);
scratchSize.height = billboard.height?.getValue(time);
scratchSize.scale = billboard.scale?.getValue(time);
scratchSize.scaleByDistance = billboard.scaleByDistance?.getValue(time);
return scratchSize;
},
};
const follow = {
// Cancel any flight first: Cesium applies the entity's viewFrom on the
// first frame only from an idle camera.
start(handle) {
viewer.camera.cancelFlight();
viewer.trackedEntity = handle;
return applyTrackedCameraFrame(viewer, handle, handle.viewFrom) || null;
},
refocus(handle) {
viewer.camera.cancelFlight();
viewer.trackedEntity = handle;
return applyTrackedCameraFrame(viewer, handle, handle.viewFrom) || null;
},
// Releasing trackedEntity leaves the camera where the follow put it.
release() {
if (viewer) viewer.trackedEntity = undefined;
},
isFollowing: (handle) => !!viewer && viewer.trackedEntity === handle,
current: () => viewer?.trackedEntity,
onChanged: (listener) =>
viewer.trackedEntityChanged.addEventListener(listener),
};
const trails = {
create: ({ color, width }) => createTrail(viewer, { color, width }),
/**
* The live head segment: last fix → the dead-reckoned aircraft, updated
* per frame through a CallbackProperty (entity polylines update cheaply,
* the trail body rebuilds on setPositions). The 'gev-trail' id namespace
* is claimed by trailRenderer's pick owner.
*/
createHead({ id, show, positions, color, width }) {
if (!viewer) return null;
return viewer.entities.add({
id,
show,
polyline: {
positions: new Cesium.CallbackProperty(() => positions(), false),
width,
material: Cesium.Color.fromCssColorString(color).withAlpha(0.9),
// The head never vanishes into the mesh (dimmed when occluded).
depthFailMaterial:
Cesium.Color.fromCssColorString(color).withAlpha(0.45),
arcType: Cesium.ArcType.GEODESIC,
},
});
},
removeHead(head) {
if (head && viewer && !viewer.isDestroyed()) {
try {
viewer.entities.remove(head);
} catch {
/* already gone */
}
}
},
};
return {
kind: 'cesium',
get engine() {
return engine ?? engineFromViewer(viewer);
},
viewer,
view,
fleet,
models: modelSet,
tracked,
follow,
trails,
/**
* Everything a vertical surface probe must not hit: Cesium excludes
* picked objects, and the layer's billboards and models are its own.
*/
surfaceExclusions: ({ sprites = [], models: modelHandles = [] }) =>
[...sprites, ...modelHandles].filter(Boolean),
destroy() {
billboards = null;
models = null;
},
};
}
-324
View File
@@ -1,324 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { createAircraftCesiumRenderer } from './cesium.js';
import { CYAN, nearFarScale, rgbaFromCss } from '../../shared/color.js';
/** A viewer with what the renderer touches: primitives, entities, camera. */
function stubViewer() {
const added = [];
const removed = [];
const camera = {
positionWC: Cesium.Cartesian3.fromDegrees(-73.78, 40.0, 50_000),
directionWC: Cesium.Cartesian3.normalize(
Cesium.Cartesian3.negate(
Cesium.Cartesian3.fromDegrees(-73.78, 40.0, 50_000),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
),
upWC: Cesium.Cartesian3.UNIT_Z,
frustum: new Cesium.PerspectiveFrustum({ fov: 1, aspectRatio: 1.5 }),
moveEnd: new Cesium.Event(),
cancelled: 0,
cancelFlight() {
this.cancelled += 1;
},
};
camera.frustum.near = 1;
camera.frustum.far = 1e8;
const viewer = {
camera,
scene: {
primitives: {
add: (p) => added.push(p) && p,
remove: (p) => removed.push(p),
},
preRender: new Cesium.Event(),
preUpdate: new Cesium.Event(),
canvas: { clientHeight: 800 },
frameState: { frameNumber: 42 },
screenSpaceCameraController: {},
requestRender() {},
},
entities: new Cesium.EntityCollection(),
clock: { currentTime: Cesium.JulianDate.now() },
trackedEntity: undefined,
trackedEntityChanged: new Cesium.Event(),
isDestroyed: () => false,
};
return { viewer, added, removed };
}
const renderer = (viewer, options = {}) =>
createAircraftCesiumRenderer({
viewer,
spriteLayer: 'flights',
headingOffsetDeg: 180,
...options,
});
test('the Cesium aircraft renderer reports its kind', () => {
const { viewer } = stubViewer();
assert.equal(renderer(viewer).kind, 'cesium');
});
test('the fleet is one BillboardCollection and one model collection, in that order', () => {
const { viewer, added, removed } = stubViewer();
const r = renderer(viewer);
assert.equal(r.fleet.attached(), false);
r.fleet.attach();
assert.ok(added[0] instanceof Cesium.BillboardCollection);
assert.ok(added[1] instanceof Cesium.PrimitiveCollection);
assert.equal(r.fleet.visible(), true);
const sprite = r.fleet.add({
position: geo.cartesian3.fromDegrees(0, 0, 1000),
color: rgbaFromCss('#FFB800').withAlpha(0.5),
scaleByDistance: nearFarScale(1000, 3, 8_000_000, 0.5),
id: 'abc123',
});
assert.ok(sprite instanceof Cesium.Billboard);
assert.equal(sprite.id, 'abc123');
r.fleet.setVisible(false);
assert.equal(r.fleet.visible(), false);
r.models.setVisible(false);
assert.equal(added[1].show, false);
assert.equal(r.models.live(), true);
r.fleet.detach();
assert.deepEqual(removed, [added[0], added[1]]);
assert.equal(r.fleet.attached(), false);
});
test('a model pose is the old heading-pitch-roll frame with the nose offset', () => {
const { viewer } = stubViewer();
const r = renderer(viewer);
const model = { modelMatrix: new Cesium.Matrix4() };
const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000);
const written = r.models.setPose(model, position, 75);
assert.equal(written, model.modelMatrix, 'written into the model own matrix');
const expected = Cesium.Transforms.headingPitchRollToFixedFrame(
position,
new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(75 + 180), 0, 0),
Cesium.Ellipsoid.WGS84,
);
assert.deepEqual(
[...Cesium.Matrix4.toArray(model.modelMatrix)],
[...Cesium.Matrix4.toArray(expected)],
);
});
test('a model pose takes a per-model nose offset (military per-class specs)', () => {
const { viewer } = stubViewer();
const r = renderer(viewer);
const model = { modelMatrix: new Cesium.Matrix4() };
const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000);
r.models.setPose(model, position, 75, 90);
const expected = Cesium.Transforms.headingPitchRollToFixedFrame(
position,
new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(75 + 90), 0, 0),
Cesium.Ellipsoid.WGS84,
);
assert.deepEqual(
[...Cesium.Matrix4.toArray(model.modelMatrix)],
[...Cesium.Matrix4.toArray(expected)],
);
});
test('model loads carry the layer spec; the IR boost loads an UNLIT model', async () => {
const { viewer } = stubViewer();
const r = renderer(viewer);
const calls = [];
const realFrom = Cesium.Model.fromGltfAsync;
Cesium.Model.fromGltfAsync = (options) => {
calls.push(options);
return Promise.resolve({ options });
};
try {
const spec = {
url: '/models/airplane.glb',
minimumPixelSize: 24,
scale: 1,
color: CYAN,
blendAmount: 0.94,
unlit: false,
id: 'abc123',
};
await r.models.loadFleet(spec);
await r.models.loadTracked({ ...spec, unlit: true, minimumPixelSize: 40 });
await r.models.preload('/models/airplane.glb');
} finally {
Cesium.Model.fromGltfAsync = realFrom;
}
assert.equal(calls.length, 3);
for (const call of calls) assert.equal(call.asynchronous, false);
assert.deepEqual(calls[0].environmentMapOptions, { enabled: false });
assert.deepEqual(calls[1].environmentMapOptions, { enabled: false });
assert.equal(calls[0].colorBlendMode, Cesium.ColorBlendMode.MIX);
assert.equal(calls[0].colorBlendAmount, 0.94);
assert.equal(calls[0].customShader, undefined);
assert.equal(calls[0].id, 'abc123');
assert.equal(calls[1].minimumPixelSize, 40);
assert.equal(calls[1].customShader.lightingModel, Cesium.LightingModel.UNLIT);
assert.deepEqual(Object.keys(calls[2]).sort(), ['asynchronous', 'url']);
});
test('the frustum and horizon tests are the Cesium ones', () => {
const { viewer } = stubViewer();
const r = renderer(viewer);
const inFrustum = r.view.frustumTester();
const below = Cesium.Cartesian3.fromDegrees(-73.78, 40.0, 0);
const behind = Cesium.Cartesian3.fromDegrees(106.22, -40.0, 0);
const aboveCamera = Cesium.Cartesian3.fromDegrees(-73.78, 40.0, 200_000);
assert.equal(inFrustum(below), true);
assert.equal(inFrustum(aboveCamera), false);
assert.equal(r.view.horizon().isPointVisible(below), true);
assert.equal(r.view.horizon().isPointVisible(behind), false);
assert.equal(r.view.frameNumber(), 42);
assert.equal(r.view.viewportHeightPx(), 800);
});
test('the tracked aircraft is an ENU-framed billboard entity with live callbacks', () => {
const { viewer } = stubViewer();
const r = renderer(viewer);
let position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000);
let color = CYAN;
const handle = r.tracked.create({
position: () => position,
image: 'data:image/png;base64,',
width: 28,
height: 28,
scale: 1.2,
color: () => color,
scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
rotation: () => 0.25,
});
assert.ok(handle instanceof Cesium.Entity);
assert.equal(r.tracked.isLive(handle), true);
const time = viewer.clock.currentTime;
assert.equal(
handle.trackingReferenceFrame,
Cesium.TrackingReferenceFrame.ENU,
);
assert.equal(handle.position.getValue(time), position);
const billboardColor = handle.billboard.color.getValue(time);
assert.ok(billboardColor instanceof Cesium.Color);
assert.ok(billboardColor.equals(Cesium.Color.CYAN));
color = CYAN.withAlpha(0);
assert.equal(handle.billboard.color.getValue(time).alpha, 0);
assert.equal(handle.billboard.rotation.getValue(time), 0.25);
assert.ok(
Cesium.Cartesian3.equals(
handle.billboard.alignedAxis.getValue(time),
Cesium.Cartesian3.ZERO,
),
);
assert.ok(
handle.billboard.scaleByDistance.getValue(time) instanceof
Cesium.NearFarScalar,
);
assert.deepEqual(r.tracked.billboardSize(handle), {
width: 28,
height: 28,
scale: 1.2,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5),
});
r.tracked.setViewFrom(handle, { x: 0, y: -2400, z: 1650 });
assert.ok(handle.viewFrom.getValue(time) instanceof Cesium.Cartesian3);
position = geo.cartesian3.fromDegrees(-97.6, 30.2, 3000);
assert.equal(handle.position.getValue(time), position);
r.tracked.remove(handle);
assert.equal(r.tracked.isLive(handle), false);
});
test('follow tracks the handle after cancelling a flight; release leaves the camera', () => {
const { viewer } = stubViewer();
const r = renderer(viewer);
const handle = r.tracked.create({
position: () => geo.cartesian3.fromDegrees(-97.7, 30.2, 3000),
image: 'x',
width: 28,
height: 28,
scale: 1,
color: () => CYAN,
scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
rotation: () => 0,
});
r.tracked.setViewFrom(handle, { x: 0, y: -2400, z: 1650 });
const changes = [];
const remove = r.follow.onChanged(() => changes.push(r.follow.current()));
viewer.trackedEntityChanged.raiseEvent(); // the viewer raises on assignment
const stop = r.follow.start(handle);
assert.equal(viewer.camera.cancelled, 1);
assert.equal(viewer.trackedEntity, handle);
assert.equal(r.follow.isFollowing(handle), true);
assert.equal(typeof stop, 'function', 'the one exact tracked frame');
stop();
r.follow.release();
assert.equal(viewer.trackedEntity, undefined);
assert.equal(r.follow.isFollowing(handle), false);
remove();
assert.equal(changes.length, 1);
});
test('the trail head is a geodesic entity polyline in the trail namespace', () => {
const { viewer } = stubViewer();
const r = renderer(viewer);
const ends = [
geo.cartesian3.fromDegrees(0, 0, 1000),
geo.cartesian3.fromDegrees(0.1, 0, 1000),
];
const head = r.trails.createHead({
id: 'gev-trail:fl-head-1',
show: true,
positions: () => ends,
color: '#00d4ff',
width: 2.5,
});
const time = viewer.clock.currentTime;
assert.equal(head.id, 'gev-trail:fl-head-1');
assert.equal(head.polyline.positions.getValue(time), ends);
assert.equal(head.polyline.arcType.getValue(time), Cesium.ArcType.GEODESIC);
const base = Cesium.Color.fromCssColorString('#00d4ff');
assert.ok(
head.polyline.material.getValue(time).color.equals(base.withAlpha(0.9)),
);
assert.ok(
head.polyline.depthFailMaterial
.getValue(time)
.color.equals(base.withAlpha(0.45)),
);
r.trails.removeHead(head);
assert.equal(viewer.entities.contains(head), false);
});
test('an adopted (test) collection is driven as the fleet', () => {
const { viewer } = stubViewer();
const removed = [];
const billboardCollection = {
show: true,
remove: (sprite) => removed.push(sprite),
};
const modelCollection = {
added: [],
add(model) {
this.added.push(model);
},
remove() {},
isDestroyed: () => false,
};
const r = renderer(viewer, {
adopt: { billboardCollection, modelCollection },
});
assert.equal(r.fleet.visible(), true);
r.fleet.remove('sprite');
assert.deepEqual(removed, ['sprite']);
r.models.add('model');
assert.deepEqual(modelCollection.added, ['model']);
assert.deepEqual(
r.surfaceExclusions({ sprites: ['a'], models: ['b', null] }),
['a', 'b'],
);
});
+23 -23
View File
@@ -1,14 +1,13 @@
// The aircraft renderer: what the aircraft layers' logic (flights now,
// military and local ADS-B after it) asks of an engine to draw a fleet of
// aircraft, its 3D models, the tracked aircraft, its trail, and to follow it.
// render/cesium.js implements it over Cesium (the objects the layers used
// to build themselves, one for one); render/navara.js implements it on the
// Navara engine over engine.movers / engine.lines / engine.camera.follow.
// Nothing here imports an engine.
// The aircraft renderer: what the aircraft layers' logic (flights, military
// and local ADS-B) asks of an engine to draw a fleet of aircraft, its 3D
// models, the tracked aircraft, its trail, and to follow it. One
// implementation, render/display.js, draws it on every engine through the
// globe engine contract (engine.movers / engine.lines /
// engine.camera.follow). Nothing here imports an engine.
//
// The logic holds HANDLES and writes plain fields on them; the field names
// are Cesium's (the extraction is a pure move), and an engine other than
// Cesium implements them as setters:
// are Cesium's (the layers were written against its objects), and the
// display implements them as setters:
//
// SpriteHandle (fleet.add) — one aircraft glyph:
// position (Cartesian3), show, rotation (screen angle, rad),
@@ -19,9 +18,10 @@
// scaleByDistance as the engine's own copy). The logic may keep its own
// bookkeeping fields on a handle (prefixed _gev).
// ModelHandle (models.load*) — one glTF instance:
// show, ready (read-only), scale, computedScale (read-only: scale after
// minimumPixelSize), color, id, modelMatrix (16 numbers, column-major,
// written by models.setPose and read for anchors), sceneGraph? (Cesium).
// show, ready (read-only: placed, and its model loaded), scale,
// computedScale (read-only: scale after minimumPixelSize), color, id,
// modelMatrix (16 numbers, column-major, the model frame: written by
// models.setPose and read for anchors).
// TrackedHandle (tracked.create) — the selected aircraft's glyph and the
// follow target: viewFrom (ENU offset, read), and the annotation fields
// the shell reads: gevSelectionOrigin, gevTrackedId, gevLabelModel,
@@ -33,9 +33,9 @@
// camera-shaped (positionWC, positionCartographic, directionWC, upWC,
// rightWC, heading, pitch, roll, frustum.fovy) and `scene` scene-shaped
// ({ camera, canvas.clientHeight, frameState.frameNumber }) for the shared
// helpers that take them (iconOrientation, focusDeemphasis); on Navara
// they are adapters over engine.camera.pose(), with the engine registered
// for the scene (engineFromViewer) so projections go through its projector.
// helpers that take them (iconOrientation, focusDeemphasis): adapters over
// engine.camera.pose(), with the engine registered for the scene
// (engineFromViewer) so projections go through its projector.
/**
* @typedef {object} AircraftView
@@ -51,10 +51,10 @@
* @property {() => void} requestRender
* @property {(event: 'preRender'|'preUpdate'|'moveEnd', listener: Function) => () => void} on
* @property {object} surfaceOwner What groundSnap/meshFloor sample through
* (a viewer registered with its engine, or the engine).
* (the engine).
* @property {object} surfaceScene What sampleMeshFloorCells takes.
* @property {() => void} holdFrames Evidence capture: stop the engine's own
* frame loop (QA only).
* frame loop (QA only; the contract has no frame hold, so it holds none).
* @property {() => void} closeFrameLatch Evidence capture, one task later:
* let manual renders start without waiting for the loop's next frame.
* @property {() => void} releaseFrames
@@ -62,9 +62,7 @@
/**
* @typedef {object} AircraftRenderer
* @property {'cesium'|'navara'} kind
* @property {object|null} engine The globe engine (null in unit tests that
* drive a bare viewer).
* @property {object} engine The globe engine.
* @property {AircraftView} view
* @property {object} fleet attach(), detach(), attached(), visible(),
* setVisible(bool), add(spec) → SpriteHandle, remove(sprite),
@@ -73,18 +71,20 @@
* loadTracked(spec), preload(url) → Promise<ModelHandle>, add(model),
* remove(model), destroy(model), setPose(model, position, headingDeg,
* offsetDeg?) (offsetDeg: the model's nose offset, default the layer's).
* spec: { url, minimumPixelSize, scale, color, blendAmount, unlit, id }.
* spec: { url, minimumPixelSize, scale, radiusM, color, blendAmount,
* unlit, id }.
* @property {object} tracked create(spec) → TrackedHandle, remove(handle),
* isLive(handle), setImage(handle, image), setViewFrom(handle, enu),
* billboardSize(handle) → { width, height, scale, scaleByDistance } | null.
* spec: { image, width, height, scale, scaleByDistance,
* disableDepthTestDistance, position(), color() → colour, rotation() }.
* @property {object} follow start(handle) → stop, refocus(handle) → stop,
* release(), isFollowing(handle), current(), onChanged(listener) → remove.
* release(), isFollowing(handle), current() (the tracked handle, or what
* another layer follows), onChanged(listener) → remove.
* @property {object} trails create({ color, width }) → Trail,
* createHead({ id, show, positions() → Cartesian3[], color, width }) →
* TrailHead, removeHead(head).
* @property {(objects: { sprites: Iterable, models: Iterable }) => object[]} surfaceExclusions
* What a vertical surface probe must not hit: the layer's own drawings.
* What a vertical surface probe must not hit: the layer's own mover sets.
* @property {() => void} destroy
*/
@@ -1,9 +1,9 @@
// The aircraft renderer on the Navara engine (contract: ./contract.js),
// built only on the engine contract: engine.movers (the fleet glyphs, the
// tracked glyph, the 3D models), engine.lines (the trail and its live head),
// The aircraft display (contract: ./contract.js): what the aircraft layers
// (flights, military, local ADS-B) draw, on any globe engine, through the
// engine contract only: engine.movers (the fleet glyphs, the tracked glyph,
// the 3D models), engine.lines (the trail and its live head),
// engine.camera.follow (the follow camera), engine.projector and
// engine.camera.pose() (the view the logic reads). No three, no Cesium: the
// engine draws. Loaded only through the Navara mode's import().
// engine.camera.pose() (the view the logic reads). The engine draws.
//
// Handles keep the Cesium field vocabulary the logic writes (contract.js) as
// setters that write the engine's instance stores; the writes of a frame are
@@ -11,10 +11,10 @@
// microtask for writes outside a frame), so a fleet tick draws in its frame.
//
// Sprites go in banks: one mover set per distance scale and depth mode the
// handles ask for (flights: one), growing by doubling. Models go in banks per
// glTF and look (url, minimum pixels, blend, unlit). The glyph atlas holds
// every aircraftIcon() kind at the fleet and tracked raster sizes, drawn
// once from the same SVGs Cesium's billboards load.
// handles ask for (flights: one), growing by doubling; the tracked glyph has
// its own, drawn over the fleet. Models go in banks per glTF and look (url,
// minimum pixels, blend, unlit). The glyph atlas holds every aircraftIcon()
// kind at the fleet and tracked raster sizes, drawn once from the same SVGs.
import * as geo from '../../../geo/index.js';
import {
@@ -29,21 +29,23 @@ import {
createMoverBank,
StoredColor,
writeFields,
} from '../../shared/render/navaraMoverBank.js';
} from '../../shared/render/moverBank.js';
const BASE_SIZE_PX = 20;
/**
* The model frame → glTF scene axes on Navara: Cesium's own glTF axis
* correction, Y_UP_TO_Z_UP × Z_UP_TO_X_UP (data/modelVisualAnchor.js).
* Navara's glTF loader is three's GLTFLoader, which keeps the scene Y-up,
* so Z_UP_TO_X_UP alone would draw every model 90° off its heading.
* The model frame → the glTF's own axes (the movers' orientation): Cesium's
* glTF axis correction, Y_UP_TO_Z_UP × Z_UP_TO_X_UP (data/modelVisualAnchor.js),
* column-major. setPose keeps modelMatrix in the model frame, as the layers'
* anchors read it.
*/
// Cesium's Axis.Y_UP_TO_Z_UP × Axis.Z_UP_TO_X_UP, column-major.
const NAVARA_GLTF_AXIS = [0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1];
const ATLAS_PAD = 2;
const GLTF_AXIS = [0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1];
// The trail's alpha where it passes the depth test and where the scene hides
// it (as data/trailRenderer.js draws track histories), and the live head's.
const TRAIL_ALPHA = 0.85;
const TRAIL_OCCLUDED_ALPHA = 0.4;
const HEAD_ALPHA = 0.9;
const HEAD_OCCLUDED_ALPHA = 0.45;
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
// ---------------------------------------------------------------------------
@@ -81,8 +83,7 @@ const scalarKey = (scalar) =>
// Follow handle → the tracked handle it follows, for every aircraft layer on
// the engine: a layer whose follow another aircraft layer took reads the
// new owner's tracked handle (its gevSelectionOrigin), as Cesium's
// viewer.trackedEntity gives it the other layer's entity.
// new owner's tracked handle (its gevSelectionOrigin).
const FOLLOWED_TRACKED = new WeakMap();
// The tracked handle whose follow is being started (any aircraft layer):
// the engine raises followChanged inside follow(), before the follow
@@ -90,12 +91,12 @@ const FOLLOWED_TRACKED = new WeakMap();
let followStarting = null;
// ---------------------------------------------------------------------------
// The renderer
// The display
// ---------------------------------------------------------------------------
/**
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {object} options
* @param {import('../../../globe/contract.js').GlobeEngine} options.engine
* @param {string} options.layerId Names the layer's mover sets and lines.
* @param {number} [options.order=0] Draw order of the layer's sprites.
* @param {number} [options.headingOffsetDeg=0] Model nose vs heading 0.
@@ -103,14 +104,19 @@ let followStarting = null;
* Test seam: the glyph atlas.
* @returns {import('./contract.js').AircraftRenderer}
*/
export function createAircraftNavaraRenderer({
export function createAircraftDisplay(
engine,
layerId,
order = 0,
headingOffsetDeg = 0,
drawAtlas = drawAircraftIconAtlas,
}) {
{
layerId,
order = 0,
headingOffsetDeg = 0,
drawAtlas = drawAircraftIconAtlas,
},
) {
if (!engine?.movers || !engine?.lines)
throw new TypeError('The aircraft display needs a globe engine');
let destroyed = false;
let attached = false;
const removers = [];
const iconCells = aircraftIconCells();
const defaultIcon = [...iconCells.keys()][0];
@@ -150,10 +156,11 @@ export function createAircraftNavaraRenderer({
removers.push(layerView.destroy);
const { view, freshCamera } = layerView;
// --- fleet sprites ---------------------------------------------------------
// --- sprites: the fleet and the tracked glyph -------------------------------
let atlas = null;
let fleetVisible = true;
const spriteBanks = new Map();
let spriteBankSeq = 0;
const unbanked = new Set(); // sprites added before the atlas
const spriteWrite = (sprite, set, index) => {
writeFields(set, index, {
@@ -166,20 +173,23 @@ export function createAircraftNavaraRenderer({
});
set.set(index, { pickId: sprite.id });
};
// One bank per role (fleet, tracked), distance scale and depth mode.
const spriteBankFor = (sprite) => {
if (!atlas) return null;
const { tracked } = sprite;
const depth = depthKey(sprite.disableDepthTestDistance);
const key = `${scalarKey(sprite.scaleByDistance)}|${JSON.stringify(depth)}`;
const key = `${tracked ? 'tracked' : 'fleet'}|${scalarKey(sprite.scaleByDistance)}|${JSON.stringify(depth)}`;
let bank = spriteBanks.get(key);
if (!bank) {
const scalar = sprite.scaleByDistance;
const index = spriteBanks.size;
const index = spriteBankSeq++;
bank = createMoverBank(engine, {
options: (capacity, generation) => ({
id: `${layerId}-sprites-${index}-${generation}`,
id: `${layerId}-${tracked ? 'tracked' : 'sprites'}-${index}-${generation}`,
kind: 'sprite',
capacity,
order,
// The tracked glyph over the fleet.
order: tracked ? order + 0.5 : order,
depthTest: depth,
pickable: true,
sprite: {
@@ -193,12 +203,13 @@ export function createAircraftNavaraRenderer({
write: spriteWrite,
onCommit,
});
bank.setVisible(fleetVisible);
bank.setVisible(tracked || fleetVisible);
spriteBanks.set(key, bank);
}
return bank;
};
const placeSprite = (sprite) => {
if (!sprite.live) return;
const bank = spriteBankFor(sprite);
if (!bank) {
unbanked.add(sprite);
@@ -213,9 +224,7 @@ export function createAircraftNavaraRenderer({
atlas = drawn;
const waiting = [...unbanked];
unbanked.clear();
for (const sprite of waiting) if (sprite.live) placeSprite(sprite);
for (const sprite of trackedSprites)
if (sprite.live) placeTracked(sprite);
for (const sprite of waiting) placeSprite(sprite);
engine.render.request(layerId);
})
.catch((error) =>
@@ -223,11 +232,11 @@ export function createAircraftNavaraRenderer({
);
class Sprite {
constructor(spec, bankFor) {
constructor(spec, tracked = false) {
this.gevBank = null;
this.gevIndex = -1;
this.live = true;
this.bankFor = bankFor;
this.tracked = tracked;
this._position = geo.cartesian3.clone(
spec.position ?? geo.cartesian3.ZERO,
);
@@ -242,8 +251,8 @@ export function createAircraftNavaraRenderer({
this._scaleByDistance = spec.scaleByDistance
? { ...spec.scaleByDistance }
: undefined;
this.disableDepthTestDistance = spec.disableDepthTestDistance ?? 0;
this.id = spec.id;
this._disableDepthTestDistance = spec.disableDepthTestDistance ?? 0;
this._id = spec.id;
this.alignedAxis = spec.alignedAxis;
this.sizeInMeters = false;
}
@@ -256,6 +265,18 @@ export function createAircraftNavaraRenderer({
this.gevBank?.update(this, fields);
}
get id() {
return this._id;
}
set id(value) {
this._id = value;
if (this.gevBank?.set) {
this.gevBank.set.set(this.gevIndex, { pickId: value });
this.gevBank.touch();
}
}
get position() {
return this._position;
}
@@ -311,6 +332,7 @@ export function createAircraftNavaraRenderer({
this.write(spriteScaleField);
}
// Glyphs are square: the width sizes them.
get height() {
return this._height;
}
@@ -358,25 +380,49 @@ export function createAircraftNavaraRenderer({
if (scalarKey(next) === scalarKey(this._scaleByDistance)) return;
this._scaleByDistance = next;
// Another distance scale is another set.
if (this.gevBank) this.bankFor(this);
if (this.gevBank) placeSprite(this);
}
get disableDepthTestDistance() {
return this._disableDepthTestDistance;
}
set disableDepthTestDistance(value) {
const next = value ?? 0;
if (next === this._disableDepthTestDistance) return;
this._disableDepthTestDistance = next;
// Another depth mode is another set.
if (this.gevBank) placeSprite(this);
}
}
const fleetBanks = () =>
[...spriteBanks.entries()]
.filter(([key]) => key.startsWith('fleet|'))
.map(([, bank]) => bank);
const fleet = {
attach() {},
detach() {
for (const bank of spriteBanks.values()) bank.destroy();
spriteBanks.clear();
unbanked.clear();
attach() {
attached = !destroyed;
},
attached: () => !destroyed,
visible: () => !destroyed && fleetVisible,
detach() {
attached = false;
for (const [key, bank] of [...spriteBanks])
if (key.startsWith('fleet|')) {
bank.destroy();
spriteBanks.delete(key);
}
for (const sprite of unbanked)
if (!sprite.tracked) unbanked.delete(sprite);
},
attached: () => attached && !destroyed,
visible: () => attached && !destroyed && fleetVisible,
setVisible(visible) {
fleetVisible = visible;
for (const bank of spriteBanks.values()) bank.setVisible(visible);
for (const bank of fleetBanks()) bank.setVisible(visible);
},
add(spec) {
const sprite = new Sprite(spec, (s) => placeSprite(s));
const sprite = new Sprite(spec);
placeSprite(sprite);
return sprite;
},
@@ -386,6 +432,7 @@ export function createAircraftNavaraRenderer({
unbanked.delete(sprite);
sprite.gevBank?.remove(sprite);
},
// Draw order is each set's `order` (the layer's data/spriteOrder slot).
restoreOrder() {},
restoreOrderOnEnable() {},
};
@@ -447,18 +494,19 @@ export function createAircraftNavaraRenderer({
this._show = true;
this._scale = spec.scale ?? 1;
this._color = new StoredColor(spec.color);
this.id = spec.id;
this._id = spec.id;
this.modelMatrix = geo.matrix4.create();
this.translation = geo.cartesian3.create();
this.orientation = geo.quaternion.create(0, 0, 0, 1);
this.radiusM = spec.radiusM ?? 1;
}
/** Placed, and the engine has loaded what this instance draws. */
get ready() {
return this.placed && Boolean(this.gevBank?.ready());
return this.placed && Boolean(this.gevBank?.set?.ready(this.gevIndex));
}
/** The scale drawn: Cesium's minimumPixelSize kept on the CPU. */
/** The scale drawn: minimumPixelSize kept on the CPU. */
get computedScale() {
const minimum = this.spec.minimumPixelSize ?? 0;
if (!(minimum > 0)) return this._scale;
@@ -479,6 +527,18 @@ export function createAircraftNavaraRenderer({
return pixels < minimum ? this._scale * (minimum / pixels) : this._scale;
}
get id() {
return this._id;
}
set id(value) {
this._id = value;
if (this.gevBank?.set) {
this.gevBank.set.set(this.gevIndex, { pickId: value });
this.gevBank.touch();
}
}
get show() {
return this._show;
}
@@ -508,14 +568,14 @@ export function createAircraftNavaraRenderer({
}
const models = {
live: () => !destroyed,
live: () => attached && !destroyed,
setVisible(visible) {
modelsVisible = visible;
for (const bank of modelBanks.values()) bank.setVisible(visible);
},
loadFleet: (spec) => Promise.resolve(new ModelHandle(spec)),
loadTracked: (spec) => Promise.resolve(new ModelHandle(spec)),
// The engine loads each glTF once per bank; nothing to warm.
// The engine loads each glTF itself; nothing to warm.
preload: (url) => Promise.resolve({ url, destroy() {} }),
add(model) {
modelBankFor(model).add(model);
@@ -526,9 +586,10 @@ export function createAircraftNavaraRenderer({
destroy(model) {
model?.gevBank?.remove(model);
},
/** The same HPR frame as Cesium (modelMatrix); the instance turned by
* Navara's glTF axes (NAVARA_GLTF_AXIS). `offsetDeg`: this model's
* nose offset when the layer's models differ (default: the layer's). */
/** The heading-pitch-roll frame (modelMatrix, the model frame); the
* instance turned to the glTF's own axes (GLTF_AXIS). `offsetDeg`: this
* model's nose offset when the layer's models differ (default: the
* layer's). */
setPose(model, position, headingDeg, offsetDeg = headingOffsetDeg) {
scratchHpr.heading = geo.math.toRadians((headingDeg || 0) + offsetDeg);
geo.transforms.headingPitchRollToFixedFrame(
@@ -540,7 +601,7 @@ export function createAircraftNavaraRenderer({
);
geo.matrix4.multiplyTransformation(
model.modelMatrix,
NAVARA_GLTF_AXIS,
GLTF_AXIS,
scratchCorrected,
);
geo.quaternion.fromRotationMatrix(
@@ -564,37 +625,19 @@ export function createAircraftNavaraRenderer({
// --- the tracked glyph -----------------------------------------------------
const trackedSprites = new Set();
let trackedBank = null;
const trackedBankFor = () => {
if (!atlas) return null;
trackedBank ??= createMoverBank(engine, {
options: (capacity, generation) => ({
id: `${layerId}-tracked-${generation}`,
kind: 'sprite',
capacity: Math.max(capacity, 4),
order: order + 0.5,
depthTest: 'always',
pickable: true,
sprite: {
atlas,
sizePx: BASE_SIZE_PX,
scaleByDistance: [1000, 3.0, 8000000, 0.5],
},
}),
write: spriteWrite,
onCommit,
});
return trackedBank;
};
const placeTracked = (sprite) => trackedBankFor()?.add(sprite);
let trackedSeq = 0;
// Each frame (before drawing): the tracked glyphs read their callbacks.
// A followed glyph takes the position its follow centred this frame
// (Cesium's follow reads it before the frame's events).
removers.push(
engine.frames.preRender.addEventListener(() =>
inFrameDo(() => {
for (const sprite of trackedSprites) {
const { spec } = sprite;
const position = spec.position();
const position = sprite.gevFollowSampled
? sprite.gevFollowSample
: spec.position();
sprite.gevFollowSampled = false;
if (position) sprite.position = position;
sprite.rotation = spec.rotation();
sprite.color = spec.color();
@@ -614,24 +657,26 @@ export function createAircraftNavaraRenderer({
scale: spec.scale,
color: spec.color(),
rotation: spec.rotation(),
scaleByDistance: spec.scaleByDistance,
disableDepthTestDistance: spec.disableDepthTestDistance,
id: `${layerId}-tracked-${++trackedSeq}`,
},
() => {},
true,
);
// The tracked glyph's distance scale is its bank's (as Cesium's).
sprite._scaleByDistance = spec.scaleByDistance;
sprite.spec = spec;
sprite.resumeAircraftFollow = () => follow.start(sprite);
sprite.viewFrom = null;
sprite.gevFollowSample = geo.cartesian3.create();
sprite.gevFollowSampled = false;
trackedSprites.add(sprite);
placeTracked(sprite);
placeSprite(sprite);
return sprite;
},
remove(handle) {
if (!handle) return;
handle.live = false;
trackedSprites.delete(handle);
unbanked.delete(handle);
handle.gevBank?.remove(handle);
if (
handle.gevFollowHandle &&
@@ -658,22 +703,30 @@ export function createAircraftNavaraRenderer({
};
// --- follow ---------------------------------------------------------------
const ownFollows = FOLLOWED_TRACKED; // follow handle → tracked handle
// The follow carries the tracked readout's card as plain data (the
// handle's annotation fields, read when the card paints).
const followOf = (handle) => {
const card = {
id: handle.gevTrackedId,
model: () => handle.gevLabelModel,
position: () => handle.gevDisplayPosition?.() ?? handle.spec.position(),
};
return engine.camera.follow({
const followOf = (handle) =>
engine.camera.follow({
native: handle,
position: () => handle.spec.position(),
position() {
const position = handle.spec.position();
if (position) {
geo.cartesian3.clone(position, handle.gevFollowSample);
handle.gevFollowSampled = true;
}
return position;
},
viewFrom: () => handle.viewFrom,
label: card,
label:
typeof handle.gevTrackedId === 'string'
? {
id: handle.gevTrackedId,
model: () => handle.gevLabelModel,
position: () =>
handle.gevDisplayPosition?.() ?? handle.spec.position(),
}
: undefined,
});
};
const follow = {
start(handle) {
const outer = followStarting;
@@ -685,9 +738,9 @@ export function createAircraftNavaraRenderer({
followStarting = outer;
}
handle.gevFollowHandle = followHandle;
ownFollows.set(followHandle, handle);
// Cesium returns the one-frame camera framing's disposer; the engine's
// follow frames itself.
FOLLOWED_TRACKED.set(followHandle, handle);
// The engine's follow frames itself (Cesium's one exact tracked frame
// included) and ends with it: nothing for the caller to stop.
return () => {};
},
refocus(handle) {
@@ -699,17 +752,35 @@ export function createAircraftNavaraRenderer({
isFollowing: (handle) =>
Boolean(handle?.gevFollowHandle) &&
engine.camera.following() === handle.gevFollowHandle,
// What is followed: an aircraft layer's tracked handle, else what the
// follow is of (another layer's entity), else the follow itself.
current() {
if (followStarting) return followStarting;
const handle = engine.camera.following();
return handle ? (ownFollows.get(handle) ?? handle) : undefined;
return handle
? (FOLLOWED_TRACKED.get(handle) ?? handle.native ?? handle)
: undefined;
},
onChanged: (listener) =>
engine.camera.followChanged.addEventListener(listener),
};
// --- trails ----------------------------------------------------------------
// The layer's lines and heads, released with the display.
const lines = new Set();
const heads = new Set();
let lineSeq = 0;
const lineOf = (options) => {
const line = engine.lines.createLine(options);
lines.add(line);
return line;
};
const dropLine = (line) => {
if (line && lines.delete(line)) line.destroy();
};
const samePositions = (a, b) =>
a.length === b.length &&
a.every((position, index) => geo.cartesian3.equals(position, b[index]));
const trails = {
create({ color, width = 2.5 }) {
const [r, g, b] = rgbaFromCss(color).rgba();
@@ -718,7 +789,7 @@ export function createAircraftNavaraRenderer({
let visible = true;
let current = [];
const ensure = () =>
(line ??= engine.lines.createLine({
(line ??= lineOf({
id,
widthPx: width,
color: [r, g, b, TRAIL_ALPHA],
@@ -727,6 +798,8 @@ export function createAircraftNavaraRenderer({
pickId: id,
}));
return {
// Fewer than 2 distinct positions draw nothing; consecutive
// near-duplicates are dropped.
setPositions(cartesians) {
if (destroyed) return;
const positions = [];
@@ -754,28 +827,37 @@ export function createAircraftNavaraRenderer({
line?.setPositions(current);
},
destroy() {
line?.destroy();
dropLine(line);
line = null;
},
};
},
// The live head segment: its positions are read each frame while shown.
createHead({ id, show, positions, color, width = 2.5 }) {
if (destroyed) return null;
const [r, g, b] = rgbaFromCss(color).rgba();
const line = engine.lines.createLine({
const line = lineOf({
id: `${id}`,
widthPx: width,
color: [r, g, b, 0.9],
depthFailAlpha: 0.5,
color: [r, g, b, HEAD_ALPHA],
depthFailAlpha: HEAD_OCCLUDED_ALPHA / HEAD_ALPHA,
arc: 'geodesic',
pickId: id,
});
let shown = show !== false;
line.show(shown);
// Written only when they move: each write requests a render.
let drawn = [];
const remove = engine.frames.preRender.addEventListener(() => {
if (!shown) return;
line.setPositions(positions());
const next = (positions() ?? []).map((position) =>
geo.cartesian3.clone(position),
);
if (samePositions(next, drawn)) return;
drawn = next;
line.setPositions(next);
});
return {
const head = {
id,
get show() {
return shown;
@@ -785,10 +867,13 @@ export function createAircraftNavaraRenderer({
line.show(shown);
},
remove() {
if (!heads.delete(head)) return;
remove();
line.destroy();
dropLine(line);
},
};
heads.add(head);
return head;
},
removeHead(head) {
head?.remove();
@@ -796,7 +881,6 @@ export function createAircraftNavaraRenderer({
};
return {
kind: 'navara',
engine,
view,
fleet,
@@ -806,41 +890,45 @@ export function createAircraftNavaraRenderer({
trails,
/** The atlas is drawn (tests, QA). */
whenReady: () => atlasReady,
/** A vertical surface probe skips the layer's own mover sets (by id). */
/** A vertical surface probe skips the layer's own mover sets. */
surfaceExclusions: () =>
[...spriteBanks.values(), ...modelBanks.values(), trackedBank]
.map((bank) => bank?.set?.id)
[...spriteBanks.values(), ...modelBanks.values()]
.map((bank) => bank.set)
.filter(Boolean),
destroy() {
if (destroyed) return;
flush();
destroyed = true;
attached = false;
for (const remove of removers.splice(0)) remove?.();
fleet.detach();
for (const bank of spriteBanks.values()) bank.destroy();
spriteBanks.clear();
unbanked.clear();
for (const bank of modelBanks.values()) bank.destroy();
modelBanks.clear();
trackedBank?.destroy();
trackedBank = null;
trackedSprites.clear();
for (const head of [...heads]) head.remove();
for (const line of [...lines]) dropLine(line);
},
};
}
/**
* The Navara registry factory (src/app/layers/renderers.js) of one aircraft
* layer: its sprite slot (data/spriteOrder's order on Cesium) and its
* model's nose offset.
* The registry factory of one aircraft layer (src/app/layers/renderers.js
* and the Navara mode register the same one): its sprite slot
* (data/spriteOrder's order) and its model's nose offset. `drawAtlas` is a
* test seam.
* @param {string} layerId
* @param {number} headingOffsetDeg
*/
export function aircraftLayerNavaraRenderer(layerId, headingOffsetDeg) {
export function aircraftLayerDisplay(layerId, headingOffsetDeg) {
/** @param {import('../../../globe/contract.js').GlobeEngine} engine */
return (engine) =>
createAircraftNavaraRenderer({
engine,
return (engine, { drawAtlas } = {}) =>
createAircraftDisplay(engine, {
layerId,
order: SPRITE_LAYER_ORDER.indexOf(layerId),
headingOffsetDeg,
...(drawAtlas ? { drawAtlas } : {}),
});
}
+854
View File
@@ -0,0 +1,854 @@
// The aircraft display on the engine contract, on the fake engine and on the
// Cesium engine (real Cesium collections, billboards, models and
// polylines over a stub scene: they draw nothing but hold what the engine
// writes): the fleet's sprite sets, handle writes committed once per frame,
// the tracked glyph's per-frame callbacks, the follow and its readout label,
// model poses, loads and readiness, the view, the trail lines and teardown.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
import { aircraftIcon, TRACKED_ICON_PX } from '../../../data/aircraftIcons.js';
import { CYAN, WHITE, nearFarScale, rgbaFromCss } from '../../shared/color.js';
import {
aircraftAtlasLayout,
aircraftIconCells,
aircraftLayerDisplay,
createAircraftDisplay,
} from './display.js';
const layout = aircraftAtlasLayout();
const drawAtlas = async () => ({
canvas: { width: layout.width, height: layout.height },
cells: layout.cells,
});
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
const AXIS = Cesium.Matrix4.multiplyTransformation(
Cesium.Axis.Y_UP_TO_Z_UP,
Cesium.Axis.Z_UP_TO_X_UP,
new Cesium.Matrix4(),
);
const SCALE_BY_DISTANCE = nearFarScale(1000, 3.0, 8000000, 0.5);
function fakeHarness() {
const engine = createFakeEngine();
return {
name: 'the fake engine',
engine,
frame: () => engine.fake.frame(),
destroy: () => engine.destroy(),
};
}
function cesiumHarness() {
const { engine, ...rest } = createCesiumMoversEngine();
return {
name: 'the Cesium engine',
cesium: true,
engine,
...rest,
destroy: () => engine.destroy(),
};
}
const HARNESSES = [fakeHarness, cesiumHarness];
async function setup(makeHarness, options = {}) {
const harness = makeHarness();
harness.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.7, 30.2, 50_000),
});
const display = createAircraftDisplay(harness.engine, {
layerId: 'flights',
order: 8,
headingOffsetDeg: 180,
drawAtlas,
...options,
});
await display.whenReady();
display.fleet.attach();
return { ...harness, display };
}
const setsOf = (engine, prefix) =>
engine.movers.list().filter(({ id }) => id.startsWith(prefix));
const near = (actual, expected, label, tolerance = 1e-6) =>
assert.ok(
Math.abs(actual - expected) < tolerance,
`${label}: ${actual} vs ${expected}`,
);
const fleetSpec = (overrides = {}) => ({
position: geo.cartesian3.fromDegrees(-97.7, 30.2, 3000),
image: aircraftIcon('helicopter'),
width: 20,
height: 20,
scale: 1.5,
rotation: 0.3,
color: rgbaFromCss('#FFB800'),
scaleByDistance: SCALE_BY_DISTANCE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
id: 'abc123',
show: true,
...overrides,
});
const trackedSpec = (state) => ({
position: () => state.position,
image: aircraftIcon('airliner', TRACKED_ICON_PX),
width: 28,
height: 28,
scale: 1.2,
color: () => state.color,
scaleByDistance: SCALE_BY_DISTANCE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
rotation: () => 0.25,
});
test('the display needs a globe engine', () => {
assert.throws(() => createAircraftDisplay(null, { layerId: 'x' }), TypeError);
});
test('the atlas has every glyph at the fleet and tracked sizes', () => {
const cells = aircraftIconCells();
for (const key of cells.values())
assert.ok(layout.cells[key], `${key} in the atlas`);
for (const [, [u0, v0, u1, v1]] of Object.entries(layout.cells))
assert.ok(u0 >= 0 && v0 >= 0 && u1 <= 1 && v1 <= 1 && u1 > u0 && v1 > v0);
assert.equal(
cells.get(aircraftIcon('quadjet', TRACKED_ICON_PX)),
`quadjet@${TRACKED_ICON_PX}`,
);
});
test("a layer's factory puts its sprites in its data/spriteOrder slot", async () => {
const engine = createFakeEngine();
const display = aircraftLayerDisplay('military', 90)(engine, { drawAtlas });
await display.whenReady();
display.fleet.attach();
display.fleet.add(fleetSpec());
await Promise.resolve();
const [listed] = setsOf(engine, 'military-sprites');
assert.equal(engine.fake.moverSet(listed.id).options.order, 8);
display.destroy();
engine.destroy();
});
for (const makeHarness of HARNESSES) {
const { name } = makeHarness();
test(`fleet sprites draw as the layer asked, written once per frame (${name})`, async () => {
const h = await setup(makeHarness);
const { engine, display } = h;
assert.equal(display.fleet.attached(), true);
assert.equal(display.fleet.visible(), true);
const spec = fleetSpec();
const sprite = display.fleet.add(spec);
assert.equal(sprite.id, 'abc123');
assert.equal(sprite.width, 20);
await settle();
const [listed] = setsOf(engine, 'flights-sprites');
assert.equal(listed.count, 1);
if (h.cesium) {
const collection = h.primitive(listed.id);
assert.ok(collection instanceof Cesium.BillboardCollection);
const billboard = collection.get(0);
assert.ok(Cesium.Cartesian3.equals(billboard.position, spec.position));
assert.equal(billboard.id, 'abc123');
near(billboard.scale, 1.5, 'scale');
near(billboard.rotation, 0.3, 'rotation');
assert.equal(billboard.width, 20);
assert.equal(billboard.height, 20);
assert.equal(
billboard.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.ok(
Cesium.NearFarScalar.equals(
billboard.scaleByDistance,
new Cesium.NearFarScalar(1000, 3, 8000000, 0.5),
),
);
assert.ok(
billboard.color.equalsEpsilon(
Cesium.Color.fromCssColorString('#FFB800'),
1e-6,
),
);
assert.equal(billboard.show, true);
} else {
const set = engine.fake.moverSet(listed.id);
assert.equal(set.options.depthTest, 'always');
assert.deepEqual(
set.options.sprite.scaleByDistance,
[1000, 3, 8000000, 0.5],
);
assert.equal(set.options.sprite.sizePx, 20);
assert.equal(set.options.order, 8);
assert.equal(set.options.pickable, true);
const { store } = set;
assert.equal(store.positions[0], spec.position.x);
assert.equal(store.pickIds[0], 'abc123');
assert.equal(store.cellKeys[store.cells[0]], 'helicopter@64');
near(store.scales[0], 1.5, 'scale');
near(store.rotations[0], 0.3, 'rotation');
near(store.colors[1], 184 / 255, 'green');
}
// Writes inside a frame listener commit at its end, once.
const commits = h.cesium
? null
: () => engine.fake.moverSet(listed.id).commits;
const before = commits?.();
const remove = display.view.on('preRender', () => {
sprite.color = WHITE.withAlpha(0.45);
sprite.scale = 2;
sprite.width = 24;
sprite.image = aircraftIcon('helicopter', TRACKED_ICON_PX);
sprite.show = false;
});
h.frame();
remove();
if (commits) assert.equal(commits(), before + 1);
assert.equal(sprite.color.alpha, 0.45);
if (h.cesium) {
const billboard = h.primitive(listed.id).get(0);
near(billboard.color.alpha, 0.45, 'alpha');
near(billboard.scale, 2.4, 'scale × width/20');
assert.equal(billboard.show, false);
} else {
const { store } = engine.fake.moverSet(listed.id);
near(store.colors[3], 0.45, 'alpha');
near(store.scales[0], 2.4, 'scale × width/20');
assert.equal(
store.cellKeys[store.cells[0]],
`helicopter@${TRACKED_ICON_PX}`,
);
assert.equal(store.shows[0], 0);
}
// Removal moves the last sprite into the hole.
const other = display.fleet.add(
fleetSpec({ image: aircraftIcon('light'), id: 'def456' }),
);
display.fleet.remove(sprite);
await settle();
assert.equal(setsOf(engine, 'flights-sprites')[0].count, 1);
assert.equal(other.gevIndex, 0);
if (h.cesium) assert.equal(h.primitive(listed.id).get(0).id, 'def456');
else
assert.equal(engine.fake.moverSet(listed.id).store.pickIds[0], 'def456');
// Another depth mode is another set.
other.disableDepthTestDistance = 0;
await settle();
assert.equal(setsOf(engine, 'flights-sprites').length, 2);
display.fleet.setVisible(false);
await settle();
assert.equal(display.fleet.visible(), false);
assert.ok(setsOf(engine, 'flights-sprites').every(({ count }) => !count));
display.fleet.detach();
assert.equal(display.fleet.attached(), false);
assert.equal(setsOf(engine, 'flights-sprites').length, 0);
display.destroy();
assert.equal(engine.movers.list().length, 0, 'no set left behind');
h.destroy();
});
test(`a model pose is the heading-pitch-roll frame with the nose offset; the instance turns the glTF's own axes (${name})`, async () => {
const h = await setup(makeHarness);
const { engine, display } = h;
const model = await display.models.loadFleet({
url: '/models/airplane.glb',
minimumPixelSize: 24,
scale: 1,
radiusM: 34.41,
color: WHITE,
blendAmount: 0.94,
unlit: false,
id: 'abc123',
});
model.show = false;
display.models.add(model);
const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000);
const written = display.models.setPose(model, position, 75);
assert.equal(written, model.modelMatrix, 'the model own matrix');
const expected = Cesium.Transforms.headingPitchRollToFixedFrame(
position,
new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(75 + 180), 0, 0),
Cesium.Ellipsoid.WGS84,
);
for (let index = 0; index < 16; index++)
near(model.modelMatrix[index], expected[index], `modelMatrix ${index}`);
// What draws: the model frame × Cesium's glTF axis correction, which
// Cesium's own Model applied to the model frame (the movers load the
// glTF uncorrected).
const drawn = Cesium.Matrix4.multiplyTransformation(
expected,
AXIS,
new Cesium.Matrix4(),
);
await settle();
const [listed] = setsOf(engine, 'flights-models');
assert.equal(listed.count, 1);
if (h.cesium) {
const [instance] = h.loads;
for (let index = 0; index < 12; index++)
near(instance.modelMatrix[index], drawn[index], `drawn ${index}`);
assert.equal(instance.show, false, 'admitted hidden');
assert.equal(instance.id, 'abc123');
assert.equal(model.ready, false, 'until the model has loaded');
instance.ready = true;
assert.equal(model.ready, true);
} else {
const q = Cesium.Quaternion.fromRotationMatrix(
Cesium.Matrix4.getMatrix3(drawn, new Cesium.Matrix3()),
);
const { store, options } = engine.fake.moverSet(listed.id);
assert.equal(options.model.minimumPixelSize, 24);
assert.deepEqual(options.model.colorBlend, { mode: 'mix', amount: 0.94 });
for (const [k, value] of ['x', 'y', 'z', 'w'].entries())
near(store.orientations[k], q[value], value, 1e-9);
assert.equal(store.shows[0], 0, 'admitted hidden');
assert.equal(model.ready, true, 'the fake engine draws at once');
}
model.show = true;
await settle();
if (h.cesium) assert.equal(h.loads[0].show, true);
else
assert.equal(
engine.fake.moverSet(listed.id).store.shows[0],
1,
'shown once placed',
);
assert.ok(model.computedScale >= model.scale);
// A per-model nose offset (military per-class specs).
display.models.setPose(model, position, 75, 90);
const offset = Cesium.Transforms.headingPitchRollToFixedFrame(
position,
new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(75 + 90), 0, 0),
Cesium.Ellipsoid.WGS84,
);
for (let index = 0; index < 16; index++)
near(model.modelMatrix[index], offset[index], `offset ${index}`);
display.models.setVisible(false);
await settle();
assert.equal(setsOf(engine, 'flights-models')[0].count, 0);
assert.equal(display.models.live(), true);
display.destroy();
assert.equal(display.models.live(), false);
assert.equal(engine.movers.list().length, 0);
h.destroy();
});
test(`the tracked glyph reads its callbacks every frame; the follow centres it and carries its readout label (${name})`, async () => {
const h = await setup(makeHarness);
const { engine, display } = h;
const state = {
position: geo.cartesian3.fromDegrees(-97.7, 30.2, 3000),
color: CYAN,
};
const handle = display.tracked.create(trackedSpec(state));
handle.gevTrackedId = 'flights:abc123';
handle.gevLabelModel = { title: 'DAL123' };
handle.gevDisplayPosition = () => state.position;
assert.equal(display.tracked.isLive(handle), true);
assert.deepEqual(display.tracked.billboardSize(handle), {
width: 28,
height: 28,
scale: 1.2,
scaleByDistance: { ...SCALE_BY_DISTANCE },
});
display.tracked.setViewFrom(handle, { x: 0, y: -2400, z: 1650 });
assert.deepEqual(
[handle.viewFrom.x, handle.viewFrom.y, handle.viewFrom.z],
[0, -2400, 1650],
);
h.frame();
await settle();
const [listed] = setsOf(engine, 'flights-tracked');
assert.equal(listed.count, 1);
const read = () => {
if (h.cesium) {
const billboard = h.primitive(listed.id).get(0);
return {
x: billboard.position.x,
alpha: billboard.color.alpha,
rotation: billboard.rotation,
id: billboard.id,
};
}
const { store } = engine.fake.moverSet(listed.id);
return {
x: store.positions[0],
alpha: store.colors[3],
rotation: store.rotations[0],
id: store.pickIds[0],
};
};
assert.equal(read().id, handle.id);
near(read().rotation, 0.25, 'rotation');
if (h.cesium) {
const billboard = h.primitive(listed.id).get(0);
assert.equal(
billboard.disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.equal(billboard.width, 20);
near(billboard.scale, 1.2 * (28 / 20), 'scale × width/20');
}
state.position = geo.cartesian3.fromDegrees(-97.6, 30.2, 3000);
state.color = CYAN.withAlpha(0);
h.frame();
assert.equal(read().x, state.position.x, 'the position, per frame');
assert.equal(read().alpha, 0, 'the colour, per frame');
const stop = display.follow.start(handle);
assert.equal(typeof stop, 'function');
const following = engine.camera.following();
assert.equal(following, handle.gevFollowHandle);
assert.equal(following.native, handle, 'cockpit follows the glyph');
assert.equal(following.label.id, 'flights:abc123');
assert.equal(following.label.model(), handle.gevLabelModel);
assert.equal(display.follow.isFollowing(handle), true);
assert.equal(display.follow.current(), handle);
if (h.cesium) {
const proxy = h.viewer.trackedEntity;
assert.ok(proxy instanceof Cesium.Entity);
assert.equal(
proxy.trackingReferenceFrame,
Cesium.TrackingReferenceFrame.ENU,
);
assert.equal(proxy.gevTrackedId, 'flights:abc123');
const time = h.viewer.clock.currentTime;
assert.ok(
Cesium.Cartesian3.equals(proxy.viewFrom.getValue(time), {
x: 0,
y: -2400,
z: 1650,
}),
);
}
// The glyph draws where the follow centred in its frame.
const centred = state.position;
assert.equal(following.target(), centred);
state.position = geo.cartesian3.fromDegrees(-97.5, 30.2, 3000);
if (h.cesium) {
h.frame();
assert.equal(read().x, centred.x, "the follow's sample");
h.frame();
} else h.frame();
assert.equal(read().x, state.position.x);
display.follow.release();
assert.equal(engine.camera.following(), null);
assert.equal(display.follow.current(), undefined);
assert.equal(display.follow.isFollowing(handle), false);
if (h.cesium) assert.equal(h.viewer.trackedEntity, undefined);
handle.resumeAircraftFollow();
assert.equal(display.follow.current(), handle);
display.tracked.remove(handle);
assert.equal(engine.camera.following(), null);
assert.equal(display.tracked.isLive(handle), false);
await settle();
assert.equal(setsOf(engine, 'flights-tracked')[0].count, 0);
display.destroy();
h.destroy();
});
test(`a tracked glyph hidden through its follow's target stays hidden, as the Cockpit rides it (${name})`, async () => {
const h = await setup(makeHarness);
const { engine, display } = h;
const state = {
position: geo.cartesian3.fromDegrees(-97.7, 30.2, 3000),
color: CYAN,
};
const handle = display.tracked.create(trackedSpec(state));
display.tracked.setViewFrom(handle, { x: 0, y: -2400, z: 1650 });
display.follow.start(handle);
h.frame();
await settle();
const [listed] = setsOf(engine, 'flights-tracked');
const drawn = () =>
h.cesium
? h.primitive(listed.id).get(0).show
: engine.fake.moverSet(listed.id).store.shows[0] === 1;
assert.equal(drawn(), true);
// The Cockpit hides what the engine follows, then releases the follow.
const ridden = engine.camera.following().native;
assert.equal(ridden, handle);
ridden.show = false;
engine.camera.following().stop();
state.position = geo.cartesian3.fromDegrees(-97.6, 30.2, 3000);
h.frame();
await settle();
assert.equal(drawn(), false, 'not drawn while ridden');
// Another distance scale moves it to another set, still hidden.
handle.scaleByDistance = nearFarScale(1000, 2.0, 8000000, 0.5);
await settle();
const moved = setsOf(engine, 'flights-tracked').find(
({ count }) => count === 1,
);
const shownIn = (set) =>
h.cesium
? h.primitive(set.id).get(0).show
: engine.fake.moverSet(set.id).store.shows[0] === 1;
assert.equal(shownIn(moved), false);
ridden.show = true;
ridden.resumeAircraftFollow();
h.frame();
await settle();
assert.equal(shownIn(moved), true, 'drawn again on exit');
assert.equal(display.follow.current(), handle);
display.destroy();
h.destroy();
});
test(`another layer's follow reads as what it follows (${name})`, async () => {
const h = await setup(makeHarness);
const { engine, display } = h;
const changes = [];
const remove = display.follow.onChanged(() =>
changes.push(display.follow.current()),
);
const native = h.cesium
? new Cesium.Entity({
position: geo.cartesian3.fromDegrees(0, 0, 400_000),
viewFrom: new Cesium.Cartesian3(0, -900, 400),
})
: { gevSelectionOrigin: 'user' };
if (h.cesium) {
native.gevSelectionOrigin = 'user';
h.viewer.entities.add(native);
}
engine.camera.follow({
native,
position: () => geo.cartesian3.fromDegrees(0, 0, 400_000),
viewFrom: { x: 0, y: -900, z: 400 },
});
assert.equal(display.follow.current(), native);
assert.equal(display.follow.current().gevSelectionOrigin, 'user');
assert.deepEqual(changes, [native]);
display.follow.release();
assert.equal(engine.camera.following(), null);
remove();
display.destroy();
h.destroy();
});
test(`trails are geodesic lines in the trail pick namespace; the head follows each frame (${name})`, async () => {
const h = await setup(makeHarness);
const { engine, display } = h;
const trail = display.trails.create({ color: '#00d4ff', width: 2.5 });
const a = geo.cartesian3.fromDegrees(0, 0, 1000);
const b = geo.cartesian3.fromDegrees(0.1, 0, 1000);
trail.setPositions([a, a, b]);
const [body] = engine.lines.list();
assert.match(body.id, /^gev-trail:flights-/);
assert.equal(body.points, 2, 'near-duplicates dropped');
const base = Cesium.Color.fromCssColorString('#00d4ff');
const alphas = (id) => {
if (h.cesium) {
const primitive = h.primitive(id);
assert.equal(primitive.allowPicking, true);
assert.equal(
primitive.geometryInstances.geometry._arcType,
Cesium.ArcType.GEODESIC,
);
assert.equal(primitive.geometryInstances.id, id);
return [
primitive.appearance.material.uniforms.color.alpha,
primitive.depthFailAppearance.material.uniforms.color.alpha,
];
}
const { options } = engine.fake.line(id);
assert.equal(options.arc, 'geodesic');
assert.equal(options.pickId, id);
return [options.color[3], options.color[3] * options.depthFailAlpha];
};
// 0.85 where it passes the depth test, 0.4 where the scene hides it.
const [bodyAlpha, bodyHidden] = alphas(body.id);
near(bodyAlpha, 0.85, 'trail alpha');
near(bodyHidden, 0.4, 'trail occluded alpha');
if (h.cesium) {
const { color } = h.primitive(body.id).appearance.material.uniforms;
near(color.red, base.red, 'trail red');
}
trail.setPositions([a]);
assert.equal(engine.lines.list()[0].points, 0, 'one point draws nothing');
trail.setVisible(false);
assert.equal(engine.lines.list()[0].shown, false);
trail.setVisible(true);
let ends = [a, b];
const head = display.trails.createHead({
id: 'gev-trail:fl-head-1',
show: true,
positions: () => ends,
color: '#00d4ff',
width: 2.5,
});
assert.equal(head.id, 'gev-trail:fl-head-1');
h.frame();
const headLine = engine.lines
.list()
.find(({ id }) => id === 'gev-trail:fl-head-1');
assert.equal(headLine.points, 2);
// 0.9 where it passes the depth test, 0.45 where the scene hides it.
const [headAlpha, headHidden] = alphas(headLine.id);
near(headAlpha, 0.9, 'head alpha');
near(headHidden, 0.45, 'head occluded alpha');
head.show = false;
assert.equal(head.show, false);
ends = [];
h.frame();
assert.equal(
engine.lines.list().find(({ id }) => id === headLine.id).points,
2,
'a hidden head is not read',
);
display.trails.removeHead(head);
trail.destroy();
assert.equal(engine.lines.list().length, 0);
// The display releases what the layer left.
display.trails.createHead({
id: 'gev-trail:fl-head-2',
positions: () => [a, b],
color: '#00d4ff',
});
display.trails.create({ color: '#00d4ff' }).setPositions([a, b]);
assert.equal(engine.lines.list().length, 2);
display.destroy();
assert.equal(engine.lines.list().length, 0);
h.frame();
h.destroy();
});
test(`the view: camera, frustum, horizon, frame number and viewport (${name})`, async () => {
const h = await setup(makeHarness);
const { engine, display } = h;
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-73.78, 40.0, 50_000),
});
const { view } = display;
near(view.camera.positionCartographic.height, 50_000, 'height', 1);
const inFrustum = view.frustumTester();
const below = geo.cartesian3.fromDegrees(-73.78, 40.0, 0);
const behind = geo.cartesian3.fromDegrees(106.22, -40.0, 0);
const aboveCamera = geo.cartesian3.fromDegrees(-73.78, 40.0, 200_000);
assert.equal(inFrustum(below), true);
assert.equal(inFrustum(aboveCamera), false);
assert.equal(view.horizon().isPointVisible(below), true);
assert.equal(view.horizon().isPointVisible(behind), false);
const screen = view.worldToWindow(below);
const { width, height } = engine.host.size();
near(screen.x, width / 2, 'centre x', 1);
near(screen.y, height / 2, 'centre y', 1);
assert.equal(view.viewportHeightPx(), height);
const frame = view.frameNumber();
h.frame();
assert.equal(view.frameNumber(), frame + 1);
let moved = 0;
const remove = view.on('preUpdate', () => moved++);
h.frame();
remove();
h.frame();
assert.equal(moved, 1);
display.destroy();
h.destroy();
});
test(`the surface probe skips the layer's own mover sets (${name})`, async () => {
const h = await setup(makeHarness);
const { engine, display } = h;
display.fleet.add(fleetSpec());
const model = await display.models.loadFleet({
url: '/models/airplane.glb',
id: 'abc123',
});
display.models.add(model);
await settle();
const excluded = display.surfaceExclusions({
sprites: [],
models: [model],
});
assert.deepEqual(
excluded.map((set) => set.id).sort(),
engine.movers
.list()
.map(({ id }) => id)
.sort(),
);
// The engine's surface takes them (Cesium: hidden for the sample).
assert.doesNotThrow(() =>
engine.surface.sampleHeight(
geo.cartographic.fromDegrees(-97.7, 30.2, 0),
excluded,
),
);
display.destroy();
h.destroy();
});
}
test('the bank grows past its capacity without losing sprites', async () => {
const { engine, display, destroy } = await setup(fakeHarness);
for (let index = 0; index < 1500; index++)
display.fleet.add({
position: geo.cartesian3.fromDegrees(index * 0.01, 0, 1000),
image: aircraftIcon('airliner'),
id: `a${index}`,
scaleByDistance: SCALE_BY_DISTANCE,
});
await Promise.resolve();
const sets = setsOf(engine, 'flights-sprites');
assert.equal(sets.length, 1);
assert.equal(sets[0].count, 1500);
assert.ok(sets[0].capacity >= 1500);
const { store } = engine.fake.moverSet(sets[0].id);
assert.equal(store.pickIds[1499], 'a1499');
display.destroy();
destroy();
});
test('sprites added before the atlas draw once it is', async () => {
const engine = createFakeEngine();
let finish;
const display = createAircraftDisplay(engine, {
layerId: 'flights',
drawAtlas: () => new Promise((resolve) => (finish = resolve)),
});
display.fleet.attach();
const sprite = display.fleet.add(fleetSpec());
await Promise.resolve();
assert.equal(setsOf(engine, 'flights-sprites').length, 0);
sprite.scale = 2;
finish(await drawAtlas());
await display.whenReady();
await Promise.resolve();
const [listed] = setsOf(engine, 'flights-sprites');
assert.equal(listed.count, 1);
near(engine.fake.moverSet(listed.id).store.scales[0], 2, 'latest scale');
display.destroy();
engine.destroy();
});
test('unchanged sprite style does not upload or request another frame', async () => {
const { engine, display, destroy } = await setup(fakeHarness);
const sprite = display.fleet.add({
id: 'same',
position: geo.cartesian3.fromDegrees(0, 0, 1000),
image: aircraftIcon('light'),
});
await Promise.resolve();
const [listed] = setsOf(engine, 'flights-sprites');
const part = engine.fake.moverSet(listed.id);
const before = part.commits;
const color = sprite.color;
for (let n = 0; n < 1000; n++) {
sprite.show = true;
sprite.rotation = 0;
sprite.scale = 1;
sprite.width = 20;
sprite.color = WHITE;
}
await Promise.resolve();
assert.equal(part.commits, before);
assert.equal(sprite.color, color);
display.destroy();
destroy();
});
test('reused sprite field records are copied, including a mutated color getter', async () => {
const { engine, display, destroy } = await setup(fakeHarness);
const a = display.fleet.add({
id: 'a',
position: geo.cartesian3.fromDegrees(0, 0, 1000),
image: aircraftIcon('light'),
});
const b = display.fleet.add({
id: 'b',
position: a.position,
image: aircraftIcon('light'),
});
await Promise.resolve();
const [listed] = setsOf(engine, 'flights-sprites');
const { store } = engine.fake.moverSet(listed.id);
const position = geo.cartesian3.create(7, 8, 9);
a.position = position;
position.x = 20;
b.position = position;
a.color.alpha = 0.25;
// eslint-disable-next-line no-self-assign
a.color = a.color;
b.color = WHITE.withAlpha(0.75);
await Promise.resolve();
assert.deepEqual([...store.positions.subarray(0, 6)], [7, 8, 9, 20, 8, 9]);
assert.equal(store.colors[3], 0.25);
assert.equal(store.colors[7], 0.75);
display.destroy();
destroy();
});
test("model loads on Cesium carry the layer's look; the IR boost is unlit; readiness is per model", async () => {
const h = await setup(cesiumHarness);
const { display, loads } = h;
const spec = {
url: '/models/airplane.glb',
minimumPixelSize: 24,
scale: 1,
color: CYAN,
blendAmount: 0.94,
unlit: false,
id: 'abc123',
};
const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000);
const fleet = await display.models.loadFleet(spec);
const second = await display.models.loadFleet({ ...spec, id: 'def456' });
const boosted = await display.models.loadTracked({
...spec,
unlit: true,
minimumPixelSize: 40,
id: 'ghi789',
});
for (const model of [fleet, second, boosted]) {
display.models.add(model);
display.models.setPose(model, position, 10);
}
await settle();
assert.equal(loads.length, 3, 'one Cesium model per instance');
const [first, other, unlit] = loads;
for (const { options } of loads) {
assert.equal(options.asynchronous, false);
assert.deepEqual(options.environmentMapOptions, { enabled: false });
assert.equal(options.url, '/models/airplane.glb');
assert.equal(options.colorBlendMode, Cesium.ColorBlendMode.MIX);
assert.equal(options.colorBlendAmount, 0.94);
assert.equal(options.allowPicking, true);
}
assert.equal(first.options.minimumPixelSize, 24);
assert.equal(first.options.customShader, undefined);
assert.equal(unlit.options.minimumPixelSize, 40);
assert.equal(
unlit.options.customShader.lightingModel,
Cesium.LightingModel.UNLIT,
);
assert.equal(first.id, 'abc123', 'picks name the aircraft');
assert.equal(unlit.id, 'ghi789');
assert.ok(first.color.equalsEpsilon(Cesium.Color.CYAN, 1e-6));
// Readiness is each model's own: one loading model leaves the others up.
first.ready = true;
unlit.ready = true;
assert.equal(fleet.ready, true);
assert.equal(second.ready, false);
assert.equal(boosted.ready, true);
other.ready = true;
assert.equal(second.ready, true);
// A removed model's place goes to the last one, ready with its model.
display.models.remove(fleet);
await settle();
assert.equal(second.gevIndex, 0);
assert.equal(second.ready, true);
display.destroy();
h.destroy();
});
-357
View File
@@ -1,357 +0,0 @@
// The aircraft renderer on the engine contract (Navara's), over the fake
// engine: the contract's members, sprite banks with the glyph atlas, handle
// writes committed once per frame, the tracked glyph's per-frame callbacks,
// the follow and its readout label, model poses and the trail lines.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../../data/aircraftIcons.js';
import { CYAN, WHITE, nearFarScale, rgbaFromCss } from '../../shared/color.js';
import {
aircraftAtlasLayout,
aircraftIconCells,
createAircraftNavaraRenderer,
} from './navara.js';
const layout = aircraftAtlasLayout();
const drawAtlas = async () => ({
canvas: { width: layout.width, height: layout.height },
cells: layout.cells,
});
async function setup() {
const engine = createFakeEngine();
const renderer = createAircraftNavaraRenderer({
engine,
layerId: 'flights',
order: 8,
headingOffsetDeg: 180,
drawAtlas,
});
await renderer.whenReady();
return { engine, renderer };
}
const setsOf = (engine, prefix) =>
engine.movers.list().filter(({ id }) => id.startsWith(prefix));
test('the Navara aircraft renderer reports its kind', async () => {
const { engine, renderer } = await setup();
assert.equal(renderer.kind, 'navara');
renderer.destroy();
engine.destroy();
});
test('the atlas has every glyph at the fleet and tracked sizes', () => {
const cells = aircraftIconCells();
for (const key of cells.values())
assert.ok(layout.cells[key], `${key} in the atlas`);
for (const [, [u0, v0, u1, v1]] of Object.entries(layout.cells))
assert.ok(u0 >= 0 && v0 >= 0 && u1 <= 1 && v1 <= 1 && u1 > u0 && v1 > v0);
assert.equal(
cells.get(aircraftIcon('quadjet', TRACKED_ICON_PX)),
`quadjet@${TRACKED_ICON_PX}`,
);
});
test('fleet sprites write the mover store, once per frame', async () => {
const { engine, renderer } = await setup();
const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000);
const sprite = renderer.fleet.add({
position,
image: aircraftIcon('helicopter'),
width: 20,
height: 20,
scale: 1.5,
rotation: 0.3,
color: rgbaFromCss('#FFB800'),
scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
id: 'abc123',
show: true,
});
await Promise.resolve();
const [listed] = setsOf(engine, 'flights-sprites');
const set = engine.fake.moverSet(listed.id);
assert.equal(set.options.depthTest, 'always');
assert.deepEqual(set.options.sprite.scaleByDistance, [1000, 3, 8000000, 0.5]);
assert.equal(set.options.sprite.sizePx, 20);
assert.equal(set.options.order, 8);
const { store } = set;
assert.equal(store.count, 1);
assert.equal(store.positions[0], position.x);
assert.equal(store.pickIds[0], 'abc123');
assert.equal(store.cellKeys[store.cells[0]], 'helicopter@64');
assert.ok(Math.abs(store.scales[0] - 1.5) < 1e-6);
assert.ok(Math.abs(store.rotations[0] - 0.3) < 1e-6);
assert.ok(Math.abs(store.colors[1] - 184 / 255) < 1e-6);
// Writes inside a frame listener commit at its end, once.
const before = set.commits;
const remove = renderer.view.on('preRender', () => {
sprite.color = WHITE.withAlpha(0.45);
sprite.scale = 2;
sprite.width = 24;
sprite.image = aircraftIcon('helicopter', TRACKED_ICON_PX);
sprite.show = false;
});
engine.fake.frame();
remove();
assert.equal(engine.fake.moverSet(listed.id).commits, before + 1);
assert.ok(Math.abs(store.colors[3] - 0.45) < 1e-6);
assert.ok(Math.abs(store.scales[0] - 2.4) < 1e-6, 'scale × width/20');
assert.equal(store.cellKeys[store.cells[0]], `helicopter@${TRACKED_ICON_PX}`);
assert.equal(store.shows[0], 0);
assert.equal(sprite.color.alpha, 0.45);
// Removal moves the last sprite into the hole.
const other = renderer.fleet.add({
position,
image: aircraftIcon('light'),
id: 'def456',
scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
renderer.fleet.remove(sprite);
await Promise.resolve();
assert.equal(store.count, 1);
assert.equal(store.pickIds[0], 'def456');
assert.equal(other.gevIndex, 0);
renderer.fleet.setVisible(false);
await Promise.resolve();
assert.equal(store.count, 0);
assert.equal(renderer.fleet.visible(), false);
renderer.destroy();
assert.equal(setsOf(engine, 'flights').length, 0, 'no set left behind');
engine.destroy();
});
test('the bank grows past its capacity without losing sprites', async () => {
const { engine, renderer } = await setup();
const sprites = [];
for (let index = 0; index < 1500; index++)
sprites.push(
renderer.fleet.add({
position: geo.cartesian3.fromDegrees(index * 0.01, 0, 1000),
image: aircraftIcon('airliner'),
id: `a${index}`,
scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5),
}),
);
await Promise.resolve();
const sets = setsOf(engine, 'flights-sprites');
assert.equal(sets.length, 1);
assert.equal(sets[0].count, 1500);
assert.ok(sets[0].capacity >= 1500);
const { store } = engine.fake.moverSet(sets[0].id);
assert.equal(store.pickIds[1499], 'a1499');
renderer.destroy();
engine.destroy();
});
test('the tracked glyph reads its callbacks every frame; follow carries its readout label', async () => {
const { engine, renderer } = await setup();
let position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000);
let color = CYAN;
const handle = renderer.tracked.create({
position: () => position,
image: aircraftIcon('airliner', TRACKED_ICON_PX),
width: 28,
height: 28,
scale: 1,
color: () => color,
scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
rotation: () => 0.5,
});
handle.gevTrackedId = 'flights:abc123';
handle.gevLabelModel = { title: 'DAL123' };
handle.gevDisplayPosition = () => position;
renderer.tracked.setViewFrom(handle, { x: 0, y: -2400, z: 1650 });
engine.fake.frame();
const [listed] = setsOf(engine, 'flights-tracked');
const { store } = engine.fake.moverSet(listed.id);
assert.equal(store.pickIds[0], handle.id);
assert.ok(Math.abs(store.rotations[0] - 0.5) < 1e-6);
position = geo.cartesian3.fromDegrees(-97.6, 30.2, 3000);
color = CYAN.withAlpha(0);
engine.fake.frame();
assert.equal(
store.positions[0],
position.x,
'the position callback, per frame',
);
assert.equal(store.colors[3], 0, 'the colour callback, per frame');
const stop = renderer.follow.start(handle);
assert.equal(typeof stop, 'function');
const following = engine.camera.following();
assert.equal(following, handle.gevFollowHandle);
assert.equal(
following.native,
handle,
'cockpit follows the rendered aircraft',
);
assert.equal(following.label.id, 'flights:abc123');
assert.equal(following.label.model(), handle.gevLabelModel);
assert.equal(renderer.follow.isFollowing(handle), true);
assert.equal(renderer.follow.current(), handle);
renderer.follow.release();
assert.equal(engine.camera.following(), null);
assert.equal(renderer.follow.current(), undefined);
handle.resumeAircraftFollow();
assert.equal(renderer.follow.current(), handle);
renderer.tracked.remove(handle);
assert.equal(engine.camera.following(), null);
assert.equal(renderer.tracked.isLive(handle), false);
renderer.destroy();
engine.destroy();
});
test("a model pose is the Cesium model frame, turned to Navara's glTF axes", async () => {
const { engine, renderer } = await setup();
const model = await renderer.models.loadFleet({
url: '/models/airplane.glb',
minimumPixelSize: 24,
scale: 1,
radiusM: 34.41,
color: WHITE,
blendAmount: 0.94,
unlit: false,
id: 'abc123',
});
model.show = false;
renderer.models.add(model);
const position = geo.cartesian3.fromDegrees(-97.7, 30.2, 3000);
renderer.models.setPose(model, position, 75);
// modelMatrix is Cesium's (what the trail anchors read).
const expected = Cesium.Transforms.headingPitchRollToFixedFrame(
position,
new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(75 + 180), 0, 0),
Cesium.Ellipsoid.WGS84,
);
for (let index = 0; index < 16; index++)
assert.ok(Math.abs(model.modelMatrix[index] - expected[index]) < 1e-6);
// The instance orientation: modelMatrix × Cesium's glTF axis correction
// (Y_UP_TO_Z_UP × Z_UP_TO_X_UP; Navara's loader keeps the glTF Y-up).
const corrected = Cesium.Matrix4.multiplyTransformation(
expected,
Cesium.Matrix4.multiplyTransformation(
Cesium.Axis.Y_UP_TO_Z_UP,
Cesium.Axis.Z_UP_TO_X_UP,
new Cesium.Matrix4(),
),
new Cesium.Matrix4(),
);
const q = Cesium.Quaternion.fromRotationMatrix(
Cesium.Matrix4.getMatrix3(corrected, new Cesium.Matrix3()),
);
await Promise.resolve();
const [listed] = setsOf(engine, 'flights-models');
const set = engine.fake.moverSet(listed.id);
assert.equal(set.options.model.minimumPixelSize, 24);
assert.deepEqual(set.options.model.colorBlend, { mode: 'mix', amount: 0.94 });
const { store } = set;
for (const [k, value] of ['x', 'y', 'z', 'w'].entries())
assert.ok(
Math.abs(store.orientations[k] - q[value]) < 1e-9,
`${value}: ${[...store.orientations.slice(0, 4)]} vs ${[q.x, q.y, q.z, q.w]}`,
);
assert.equal(store.shows[0], 0, 'admitted hidden');
model.show = true;
await Promise.resolve();
assert.equal(store.shows[0], 1);
assert.equal(model.ready, true, 'the fake engine draws at once');
assert.ok(model.computedScale >= model.scale);
renderer.destroy();
engine.destroy();
});
test('trails are engine lines in the trail pick namespace; the head follows each frame', async () => {
const { engine, renderer } = await setup();
const trail = renderer.trails.create({ color: '#00d4ff', width: 2.5 });
const a = geo.cartesian3.fromDegrees(0, 0, 1000);
const b = geo.cartesian3.fromDegrees(0.1, 0, 1000);
trail.setPositions([a, a, b]);
const lines = engine.lines.list();
assert.equal(lines.length, 1);
const line = engine.fake.line(lines[0].id);
assert.match(lines[0].id, /^gev-trail:/);
assert.equal(line.options.pickId, lines[0].id);
assert.ok(Math.abs(line.options.color[3] - 0.85) < 1e-9);
assert.equal(line.options.arc, 'geodesic');
let ends = [a, b];
const head = renderer.trails.createHead({
id: 'gev-trail:fl-head-1',
show: true,
positions: () => ends,
color: '#00d4ff',
width: 2.5,
});
engine.fake.frame();
assert.equal(engine.lines.list().length, 2);
head.show = false;
ends = [];
renderer.trails.removeHead(head);
trail.destroy();
assert.equal(engine.lines.list().length, 0);
renderer.destroy();
engine.destroy();
});
test('unchanged sprite style does not upload or request another frame', async () => {
const { engine, renderer } = await setup();
const sprite = renderer.fleet.add({
id: 'same',
position: geo.cartesian3.fromDegrees(0, 0, 1000),
image: aircraftIcon('light'),
});
await Promise.resolve();
const [listed] = setsOf(engine, 'flights-sprites');
const part = engine.fake.moverSet(listed.id);
const before = part.commits;
const color = sprite.color;
for (let n = 0; n < 1000; n++) {
sprite.show = true;
sprite.rotation = 0;
sprite.scale = 1;
sprite.width = 20;
sprite.color = WHITE;
}
await Promise.resolve();
assert.equal(part.commits, before);
assert.equal(sprite.color, color);
renderer.destroy();
engine.destroy();
});
test('reused sprite field records are copied, including a mutated color getter', async () => {
const { engine, renderer } = await setup();
const a = renderer.fleet.add({
id: 'a',
position: geo.cartesian3.fromDegrees(0, 0, 1000),
image: aircraftIcon('light'),
});
const b = renderer.fleet.add({
id: 'b',
position: a.position,
image: aircraftIcon('light'),
});
await Promise.resolve();
const [listed] = setsOf(engine, 'flights-sprites');
const { store } = engine.fake.moverSet(listed.id);
const position = geo.cartesian3.create(7, 8, 9);
a.position = position;
position.x = 20;
b.position = position;
a.color.alpha = 0.25;
a.color = a.color;
b.color = WHITE.withAlpha(0.75);
await Promise.resolve();
assert.deepEqual([...store.positions.subarray(0, 6)], [7, 8, 9, 20, 8, 9]);
assert.equal(store.colors[3], 0.25);
assert.equal(store.colors[7], 0.75);
renderer.destroy();
engine.destroy();
});
@@ -11,6 +11,7 @@ export function createLifecycle({
services,
parts,
source,
engine,
}) {
const {
registerSpriteCollection,
@@ -78,7 +79,7 @@ export function createLifecycle({
// Through the shared ledger: this is a single number every layer's
// camera.changed listener shares, and a layer leaving must not hand
// back a coarse default while this one is still driving off it.
claimCameraSensitivity(viewer.camera, 'bikeshare', 0.05);
claimCameraSensitivity(engine ?? viewer.camera, 'bikeshare', 0.05);
layerState._cameraChangedAttached = true;
}
@@ -116,7 +117,7 @@ export function createLifecycle({
viewer.camera.changed.removeEventListener(
parts.viewport.onCameraChanged,
);
releaseCameraSensitivity(viewer.camera, 'bikeshare');
releaseCameraSensitivity(engine ?? viewer.camera, 'bikeshare');
layerState._cameraChangedAttached = false;
}
@@ -140,7 +141,7 @@ export function createLifecycle({
viewer.camera.changed.removeEventListener(
parts.viewport.onCameraChanged,
);
releaseCameraSensitivity(viewer.camera, 'bikeshare');
releaseCameraSensitivity(engine ?? viewer.camera, 'bikeshare');
layerState._cameraChangedAttached = false;
}
unregisterSpriteCollection?.('bikeshare', layerState._pointCollection);
@@ -172,7 +173,7 @@ export function createLifecycle({
viewer.camera.changed.removeEventListener(
parts.viewport.onCameraChanged,
);
releaseCameraSensitivity(viewer.camera, 'bikeshare');
releaseCameraSensitivity(engine ?? viewer.camera, 'bikeshare');
layerState._cameraChangedAttached = false;
}
parts.ingestion.abortAllInFlight();
+3 -4
View File
@@ -150,8 +150,8 @@ test('voice context reads a track that rounds to north as 0°, not 360°', () =>
test('a live flight keeps observations, selection and freshness across renderer lifetimes', async () => {
const { createFakeEngine } =
await import('../../globe/testing/fakeEngine.js');
const { aircraftAtlasLayout, createAircraftNavaraRenderer } =
await import('../aircraft/render/navara.js');
const { aircraftAtlasLayout, createAircraftDisplay } =
await import('../aircraft/render/display.js');
const { createApplicationFlights } =
await import('../../app/layers/flights.js');
const first = createFakeEngine();
@@ -203,8 +203,7 @@ test('a live flight keeps observations, selection and freshness across renderer
},
renderer: {
create: ({ engine }) =>
createAircraftNavaraRenderer({
engine,
createAircraftDisplay(engine, {
layerId: 'flights',
order: 1,
headingOffsetDeg: 180,
-23
View File
@@ -1,23 +0,0 @@
// The civil flights layer's renderer on Cesium: the shared aircraft renderer
// (src/layers/aircraft/render/cesium.js) with the flights' sprite-order slot
// and airplane.glb's nose offset. The flights' presentation choices (amber
// for known military, the TR-3B glyph, the IR boost as an unlit white
// model) are the layer's logic and reach the renderer as plain data.
import { createAircraftCesiumRenderer } from '../../aircraft/render/cesium.js';
import { MODEL_HEADING_OFFSET_DEG } from '../policy.js';
/**
* Registry factory (src/app/layers/renderers.js): `(engine, options)`.
* @param {object|null} engine The Cesium globe engine (null in unit tests).
* @param {{ viewer: object, adopt?: object }} options
*/
export function createFlightsCesiumRenderer(engine, { viewer, adopt } = {}) {
return createAircraftCesiumRenderer({
viewer,
engine,
spriteLayer: 'flights',
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
adopt,
});
}
+14
View File
@@ -0,0 +1,14 @@
// The civil flights layer's display, the same on every engine: the aircraft
// display (src/layers/aircraft/render/display.js) in the flights' sprite
// slot with airplane.glb's nose offset. The flights' presentation choices
// (amber for known military, the TR-3B glyph, the IR boost as an unlit
// white model) are the layer's logic and reach the display as plain data.
import { aircraftLayerDisplay } from '../../aircraft/render/display.js';
import { MODEL_HEADING_OFFSET_DEG } from '../policy.js';
/** Registry factory (src/app/layers/renderers.js): `(engine, options)`. */
export const createFlightsDisplay = aircraftLayerDisplay(
'flights',
MODEL_HEADING_OFFSET_DEG,
);
-11
View File
@@ -1,11 +0,0 @@
// The civil flights layer's renderer on the Navara engine: the shared
// aircraft renderer with airplane.glb's nose offset. Registered for 'navara'
// by the Navara mode after its import() (src/app/navaraMode.js).
import { aircraftLayerNavaraRenderer } from '../../aircraft/render/navara.js';
import { MODEL_HEADING_OFFSET_DEG } from '../policy.js';
export const createFlightsNavaraRenderer = aircraftLayerNavaraRenderer(
'flights',
MODEL_HEADING_OFFSET_DEG,
);
+85 -6
View File
@@ -1,7 +1,8 @@
// The flights logic draws only through its renderer: with a renderer that
// is plain data (no Cesium), the layer builds its fleet, selects a contact
// on a click through the engine's selection dispatcher, tracks and follows
// it, and releases it on an empty click.
// it, and releases it on an empty click; then the same through its display
// on the fake engine and on the Cesium engine.
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -129,9 +130,9 @@ test('the flights logic draws, selects and follows only through its renderer', a
assert.ok(renderer.calls.some(([name]) => name === 'destroy'));
});
test('the flights logic draws and follows on the Navara renderer (fake engine)', async () => {
const { createAircraftNavaraRenderer, aircraftAtlasLayout } =
await import('../aircraft/render/navara.js');
test('the flights logic draws and follows on the aircraft display (fake engine)', async () => {
const { createAircraftDisplay, aircraftAtlasLayout } =
await import('../aircraft/render/display.js');
const layout = aircraftAtlasLayout();
const engine = createFakeEngine();
let renderer = null;
@@ -144,8 +145,7 @@ test('the flights logic draws and follows on the Navara renderer (fake engine)',
militaryRegistry,
renderer: {
create: ({ engine: given }) =>
(renderer = createAircraftNavaraRenderer({
engine: given,
(renderer = createAircraftDisplay(given, {
layerId: 'flights',
order: 8,
headingOffsetDeg: 180,
@@ -214,3 +214,82 @@ test('the flights logic draws and follows on the Navara renderer (fake engine)',
assert.equal(engine.movers.list().length, 0, 'no mover set left behind');
engine.destroy();
});
test('the flights logic draws, tracks and follows on the Cesium engine through the same display', async () => {
const { createCesiumMoversEngine } =
await import('../../testSupport/cesiumMoversEngine.mjs');
const { createFlightsDisplay } = await import('./render/display.js');
const { aircraftAtlasLayout } = await import('../aircraft/render/display.js');
const layout = aircraftAtlasLayout();
const { engine, viewer, frame, primitive } = createCesiumMoversEngine();
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.5, 30.3, 400_000),
});
let display = null;
const layer = createApplicationFlights({
surface: defaultSurface,
source: {
label: 'Fixture aircraft',
getSnapshot: async () => snapshotOf(['abc123', 'def456']),
},
militaryRegistry,
renderer: {
// The registry's factory, with the test's atlas.
create: ({ engine: given }) =>
(display = createFlightsDisplay(given, {
drawAtlas: async () => ({
canvas: { width: layout.width, height: layout.height },
cells: layout.cells,
}),
})),
},
});
layer.init(viewer, { engine });
layer.enable();
try {
await display.whenReady();
await layer.update(null);
await new Promise((resolve) => setTimeout(resolve, 0));
const [sprites] = engine.movers
.list()
.filter(({ id }) => id.startsWith('flights-sprites'));
assert.equal(sprites.count, 2);
const billboards = primitive(sprites.id);
assert.equal(billboards.length, 2, 'one Cesium billboard per contact');
assert.deepEqual([billboards.get(0).id, billboards.get(1).id].sort(), [
'abc123',
'def456',
]);
assert.deepEqual(
layer
.qaDrawn()
.map((row) => row.id)
.sort(),
['abc123', 'def456'],
);
// Tracking follows through Cesium's trackedEntity, with the readout.
assert.equal(layer.trackById('def456', { origin: 'voice' }), true);
const following = engine.camera.following();
assert.equal(following?.label?.id, 'flights:def456');
assert.equal(viewer.trackedEntity?.gevTrackedId, 'flights:def456');
frame();
await new Promise((resolve) => setTimeout(resolve, 0));
const tracked = engine.movers
.list()
.find(({ id }) => id.startsWith('flights-tracked'));
assert.equal(tracked.count, 1);
assert.equal(
primitive(tracked.id).get(0).disableDepthTestDistance,
Number.POSITIVE_INFINITY,
);
assert.equal(layer.stopTracking(), true);
assert.equal(engine.camera.following(), null);
assert.equal(viewer.trackedEntity, undefined);
} finally {
layer.disable();
layer.destroy(viewer);
}
assert.equal(engine.movers.list().length, 0, 'no mover set left behind');
assert.equal(engine.lines.list().length, 0, 'no line left behind');
engine.destroy();
});
+3 -3
View File
@@ -36,9 +36,9 @@ export function createFlightState({ source, services }) {
flightState._scratchGroundPos = geo.cartesian3.create();
/** @type {import('../aircraft/render/contract.js').AircraftRenderer|null}
* The engine's aircraft renderer for this lifecycle (render/cesium.js on
* Cesium): the fleet sprites, the models and their collection, the
* tracked visual, the trail, the follow camera and the view reads. */
* The engine's aircraft renderer for this lifecycle (render/display.js,
* on every engine): the fleet sprites, the models, the tracked visual,
* the trail, the follow camera and the view reads. */
flightState._renderer = null;
+9 -4
View File
@@ -1,4 +1,4 @@
import { CYAN_TRANSPARENT } from './policy.js';
import { CYAN_TRANSPARENT, MODEL_HEADING_OFFSET_DEG } from './policy.js';
import { CYAN } from '../shared/color.js';
export function createTesting({
@@ -47,6 +47,8 @@ export function createTesting({
* @param {object} state.billboard
* @param {object} state.billboardCollection
* @param {object} state.viewer
* @param {(options: object) => object} state.renderer The test's renderer
* over the viewer and collections (testSupport/aircraftViewerRenderer).
* @param {Array<Object>} [state.history=[]]
* @param {boolean} [state.tracked=true]
* @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed
@@ -64,11 +66,14 @@ export function createTesting({
tracked = true,
models = [],
modelCollection = null,
renderer: createRenderer,
}) {
// The production renderer over the test's viewer and (fake) collections.
flightState._renderer = services.renderer.create({
// The test's renderer over its viewer and (fake) collections.
flightState._renderer = createRenderer({
viewer,
adopt: { billboardCollection, modelCollection },
billboardCollection,
modelCollection,
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
});
flightState.enabled = flightState._renderer.fleet.visible();
flightState._billboards = new Map([[icao24, billboard]]);
@@ -1,6 +1,5 @@
import * as Cesium from 'cesium';
import { LAYER_ID } from '../../policy.js';
import { engineFromViewer } from '../../../../globe/cesium/engineFromViewer.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
@@ -14,10 +13,10 @@ export function createLifecycle({
}) {
const { registerPickOwner, unregisterPickOwner } = services.picking;
const { clearSelectedEntityContextForLayer } = services.context;
const cameraOwner = (viewer) => layerState.engine ?? viewer?.camera;
const methods = {
init(viewer, { engine = null } = {}) {
engine ||= engineFromViewer(viewer);
layerState.viewer = viewer;
layerState.engine = engine;
layerState.dataSource = new Cesium.CustomDataSource(
@@ -55,7 +54,7 @@ export function createLifecycle({
});
parts.namedMarkers?.enable();
parts.namedMarkers?.sync(layerState.records || []);
claimCameraSensitivity(layerState.viewer.camera, LAYER_ID, 0.01);
claimCameraSensitivity(cameraOwner(layerState.viewer), LAYER_ID, 0.01);
registerPickOwner(LAYER_ID, (id) => layerState.recordById.has(id));
layerState.dataSource.show = true;
// DataLayerManager invokes update() immediately after enable(), which owns
@@ -67,7 +66,7 @@ export function createLifecycle({
services.credits?.hideOsmCredit?.(layerState.viewer, LAYER_ID);
services.credits?.hideGooglePlacesCredit?.(layerState.viewer, LAYER_ID);
parts.namedMarkers?.hide();
releaseCameraSensitivity(layerState.viewer?.camera, LAYER_ID);
releaseCameraSensitivity(cameraOwner(layerState.viewer), LAYER_ID);
layerState.contextAnchor = null;
layerState.contextPosition = null;
layerState.coverage = { kind: 'viewport' };
@@ -99,7 +98,7 @@ export function createLifecycle({
services.credits?.hideOsmCredit?.(viewer, LAYER_ID);
services.credits?.hideGooglePlacesCredit?.(viewer, LAYER_ID);
unregisterPickOwner(LAYER_ID);
releaseCameraSensitivity(viewer?.camera, LAYER_ID);
releaseCameraSensitivity(cameraOwner(viewer), LAYER_ID);
layerState.surfaceChangedRemove?.();
layerState.surfaceChangedRemove = null;
layerState.moveEndRemove?.();
+21 -39
View File
@@ -8,8 +8,10 @@ import * as Cesium from 'cesium';
import { createLocalAdsbLayer } from './index.js';
import { createLocalReceiverFeeds } from './feeds.js';
import { createLocalAdsbCesiumRenderer } from './render/cesium.js';
import { aircraftAtlasLayout } from '../aircraft/render/display.js';
import { createLocalAdsbDisplay } from './render/display.js';
import * as selection from '../../data/selectionDispatcher.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { readShareFollowSubject } from '../../ui/shareFollow.js';
const flush = () => new Promise((resolve) => setImmediate(resolve));
@@ -81,13 +83,20 @@ function fakeFeeds() {
};
}
function fakeServices() {
const layout = aircraftAtlasLayout();
const drawAtlas = async () => ({
canvas: { width: layout.width, height: layout.height },
cells: layout.cells,
});
function fakeServices(registered = []) {
let selected = null;
return {
render: { governorRequestRender() {} },
context: {
registerEntityContext(entity, metadata) {
entity.__gevContextId = metadata.id;
if (!registered.includes(entity)) registered.push(entity);
},
selectEntityContext(entity) {
selected = { id: entity.__gevContextId, entity };
@@ -101,40 +110,15 @@ function fakeServices() {
},
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
selection,
// The layer's display (on a fake engine).
renderer: {
create: ({ engine = null, ...options }) =>
createLocalAdsbCesiumRenderer(engine, options),
create: ({ engine }) => createLocalAdsbDisplay(engine, { drawAtlas }),
},
detection: { markSourcesChanged() {} },
overlays: { refreshReadout() {} },
};
}
function fakeViewer() {
const sources = [];
const camera = {
rightWC: new Cesium.Cartesian3(0, 1, 0),
upWC: new Cesium.Cartesian3(0, 0, 1),
};
return {
sources,
viewer: {
camera,
scene: { camera },
entities: {
add: (entity) => entity,
remove() {},
},
dataSources: {
add(source) {
sources.push(source);
},
remove() {},
},
},
};
}
function record(overrides = {}) {
return {
icao: 'abc123',
@@ -165,7 +149,7 @@ test('edge:local-adsb: an unplugged receiver ends one session and a scene switch
services: fakeServices(),
now: () => clock.now,
});
layer.init(fakeViewer().viewer);
layer.init(null, { engine: createFakeEngine() });
await layer.enable();
assert.ok(layer.selectAircraft('abc123'));
@@ -183,7 +167,7 @@ test('edge:local-adsb: an unplugged receiver ends one session and a scene switch
layer.detachRenderer();
clock.now += 1_000;
await layer.update();
layer.attachRenderer(fakeViewer().viewer);
layer.attachRenderer(null, { engine: createFakeEngine() });
await layer.update();
assert.deepEqual(receiver.modeCalls, ['adsb'], 'the tuner is asked once');
@@ -241,22 +225,20 @@ test('edge:local-adsb: a selected local aircraft never reaches a share link', as
mode: 'adsb',
aircraft: [record({ lat: 10, lon: 20 })],
});
const { viewer, sources } = fakeViewer();
const registered = [];
const layer = createLocalAdsbLayer({
receiver,
feeds: fakeFeeds(),
services: fakeServices(),
services: fakeServices(registered),
now: () => 100_000,
});
t.after(() => layer.destroy());
layer.init(viewer);
layer.init(null, { engine: createFakeEngine() });
await layer.enable();
assert.ok(layer.selectAircraft('abc123'));
const marker = sources
.flatMap((source) => source.entities.values)
.find((entity) =>
String(entity.gevTrackedId || '').startsWith('local-adsb:'),
);
const marker = registered.find((entity) =>
String(entity.gevTrackedId || '').startsWith('local-adsb:'),
);
assert.ok(marker, 'the local marker carries its tracked id');
const shareViewer = {
+8 -17
View File
@@ -78,17 +78,14 @@ const DEFAULT_DISPLAY = Object.freeze({
* @param {object} [options.feeds] Decoder-feed session (start, stop,
* getState, subscribe).
* @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`).
* (`selectionDispatcherFor`), renderer (`create({ viewer, engine })`: the
* engine's Local ADS-B display), detection and readout operations
* supplied by the application; optionally `display` (`getParams()` →
* `{ models3d, models3dMode }`), `enrichment` (a source with
* `getEnrichment`), `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.
* @returns {object} Data-layer module.
*/
export function createLocalAdsbLayer({
@@ -97,7 +94,6 @@ export function createLocalAdsbLayer({
services,
now = Date.now,
resolveAsset = (url) => url,
loadModel,
}) {
if (typeof receiver?.getState !== 'function')
throw new TypeError('Local ADS-B requires a receiver session');
@@ -115,7 +111,7 @@ export function createLocalAdsbLayer({
} = services.context;
const { registerPickOwner, unregisterPickOwner } = services.picking;
/** The engine's Local ADS-B renderer for this lifecycle (render/*.js). */
/** The engine's Local ADS-B display for this lifecycle (render/display.js). */
let renderer = null;
let enabled = false;
let unsubscribe = null;
@@ -637,12 +633,7 @@ export function createLocalAdsbLayer({
},
attachRenderer(nextViewer, { engine = null } = {}) {
renderer = services.renderer.create({
viewer: nextViewer,
engine,
loadModel,
createTrail: services.trails?.createTrail ?? null,
});
renderer = services.renderer.create({ viewer: nextViewer, engine });
renderer.attach();
if (renderer.models) {
models = createLocalAdsbModels({
+203 -269
View File
@@ -12,10 +12,12 @@ import {
localAdsbReceiverLine,
localAdsbStatus,
} from './index.js';
import { createLocalAdsbCesiumRenderer } from './render/cesium.js';
import { createLocalAdsbDisplay } from './render/display.js';
import { aircraftAtlasLayout } from '../aircraft/render/display.js';
import * as selection from '../../data/selectionDispatcher.js';
import { registerViewerEngine } from '../../globe/cesium/engineFromViewer.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import * as geo from '../../geo/index.js';
import { rgbaFromCss } from '../shared/color.js';
import {
registerPickOwner,
unregisterPickOwner,
@@ -54,8 +56,29 @@ function fakeReceiver(initial = {}) {
};
}
function fakeServices() {
const calls = { registered: [], selected: [], cleared: [], removed: [] };
const layout = aircraftAtlasLayout();
const drawAtlas = async () => ({
canvas: { width: layout.width, height: layout.height },
cells: layout.cells,
});
const MAGENTA = rgbaFromCss('#ff4fd8').rgba();
const rgba = (color) => [color.red, color.green, color.blue, color.alpha];
const near = (a, b, label) =>
assert.ok(
a.every((value, index) => Math.abs(value - b[index]) < 1e-6),
`${label}: ${a} vs ${b}`,
);
const settle = () => new Promise((resolve) => setImmediate(resolve));
function fakeServices({ display = (engine) => engine } = {}) {
// Marker handles by id, as the layer registers their context.
const calls = {
registered: [],
selected: [],
cleared: [],
removed: [],
markers: new Map(),
};
let selected = null;
return {
calls,
@@ -65,6 +88,7 @@ function fakeServices() {
registerEntityContext(entity, metadata) {
entity.__gevContextId = metadata.id;
calls.registered.push(metadata);
calls.markers.set(metadata.id, entity);
},
selectEntityContext(entity) {
selected = { id: entity.__gevContextId, entity };
@@ -81,12 +105,12 @@ 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).
// As src/app/layers/localAdsb.js composes them: the layer's display
// (on a fake engine), and the engine's selection dispatcher (clicks).
selection,
renderer: {
create: ({ engine = null, ...options }) =>
createLocalAdsbCesiumRenderer(engine, options),
create: ({ engine }) =>
display(createLocalAdsbDisplay(engine, { drawAtlas })),
},
detection: { markSourcesChanged() {} },
overlays: { refreshReadout() {} },
@@ -94,28 +118,29 @@ function fakeServices() {
};
}
function fakeViewer() {
const sources = [];
const camera = {
rightWC: new Cesium.Cartesian3(0, 1, 0),
upWC: new Cesium.Cartesian3(0, 0, 1),
};
/** A fake engine whose camera looks down on (0°, 0°) from 50 km. */
function fakeEngine() {
const engine = createFakeEngine();
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(0, 0, 50_000),
});
return engine;
}
/** The ids of the markers the display draws, and its sprite and ring sets. */
function drawn(engine) {
const sets = engine.movers.list();
const sprites = sets.find(({ id }) => id.startsWith('local-adsb-sprites'));
const rings = sets.find(({ id }) => id.startsWith('local-adsb-rings'));
const models = sets.find(({ id }) => id.startsWith('local-adsb-models'));
const set = (listed) => (listed ? engine.fake.moverSet(listed.id) : null);
return {
sources,
viewer: {
camera,
scene: { camera },
entities: {
add: (entity) => entity,
remove() {},
},
dataSources: {
add(source) {
sources.push(source);
},
remove() {},
},
},
ids: sprites ? set(sprites).store.pickIds.slice(0, sprites.count) : [],
sprites: set(sprites),
rings: set(rings),
models: set(models),
count: (listed) => listed?.count ?? 0,
listed: { sprites, rings, models },
};
}
@@ -171,52 +196,19 @@ function fakeFeeds(initial = {}) {
};
}
/** A viewer whose camera looks down on (0°, 0°) from 50 km, with primitives. */
function modelViewer(primitives) {
const base = fakeViewer();
const camera = {
...base.viewer.camera,
positionWC: new Cesium.Cartesian3(6_378_137 + 50_000, 0, 0),
positionCartographic: new Cesium.Cartographic(0, 0, 50_000),
directionWC: new Cesium.Cartesian3(-1, 0, 0),
frustum: {
computeCullingVolume: () => ({
computeVisibility: () => Cesium.Intersect.INSIDE,
}),
},
};
return {
...base,
viewer: {
...base.viewer,
camera,
scene: {
camera,
primitives: {
add(collection) {
primitives.push(collection);
return collection;
},
remove() {},
},
},
},
};
}
async function enabledLayer(receiver, clock, feeds = null, options = {}) {
const { services, calls } = fakeServices();
const { sources, viewer } = options.viewer || fakeViewer();
const { services, calls } = fakeServices(options);
const engine = options.engine || fakeEngine();
const layer = createLocalAdsbLayer({
receiver,
feeds,
services: { ...services, ...options.services },
now: () => clock.now,
loadModel: options.loadModel,
});
layer.init(viewer);
layer.init(null, { engine });
await layer.enable();
return { layer, calls, sources };
const marker = (icao) => calls.markers.get(`local-adsb:${icao}`);
return { layer, calls, engine, marker };
}
test('enabling Local ADS-B asks the shared tuner for 1090 MHz; disabling leaves it alone', async (t) => {
@@ -238,43 +230,38 @@ test('enabling Local ADS-B asks the shared tuner for 1090 MHz; disabling leaves
test('local aircraft use their class silhouette and scale in magenta', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
const { layer, sources } = await enabledLayer(receiver, clock);
const { layer, engine, marker } = await enabledLayer(receiver, clock);
t.after(() => layer.destroy());
receiver.set({
aircraft: [record(), record({ icao: 'a0b702', category: 'A7' })],
});
await layer.update();
await settle();
const entity = sources[0].entities.getById('local-adsb:abc123');
const entity = marker('abc123');
assert.ok(entity);
assert.equal(entity.point, undefined);
assert.equal(entity.label, undefined);
assert.equal(entity.billboard.image.getValue(), aircraftIcon('airliner'));
assert.equal(entity.billboard.width.getValue(), 20);
assert.ok(
Cesium.Color.equals(
entity.billboard.color.getValue(),
Cesium.Color.fromCssColorString('#ff4fd8'),
),
assert.equal(entity.ring, null, 'no UAT ring');
assert.equal(entity.sprite.image, aircraftIcon('airliner'));
assert.equal(entity.sprite.width, 20);
near(rgba(entity.sprite.color), MAGENTA, 'magenta');
// Screen-aligned glyphs over the scene, scaled with distance as Cesium's
// billboards were.
const { sprites } = drawn(engine);
assert.equal(sprites.options.depthTest, 'always');
assert.deepEqual(
sprites.options.sprite.scaleByDistance,
[1000, 3, 8_000_000, 0.5],
);
assert.ok(
Cesium.Cartesian3.equals(
entity.billboard.alignedAxis.getValue(),
Cesium.Cartesian3.ZERO,
),
);
assert.equal(entity.billboard.rotation.getValue(), 0);
assert.equal(entity.spec.rotation(), 0);
assert.equal(entity.gevLabelModel.title, 'LOCAL1');
assert.equal(typeof entity.gevDisplayPosition, 'function');
const heli = sources[0].entities.getById('local-adsb:a0b702');
assert.equal(heli.billboard.image.getValue(), aircraftIcon('helicopter'));
assert.equal(heli.billboard.scale.getValue(), CLASS_SCALE_2D.helicopter);
assert.ok(
Cesium.Color.equals(
heli.billboard.color.getValue(),
Cesium.Color.fromCssColorString('#ff4fd8'),
),
const heli = marker('a0b702');
assert.equal(heli.sprite.image, aircraftIcon('helicopter'));
assert.equal(heli.sprite.scale, CLASS_SCALE_2D.helicopter);
near(
rgba(heli.sprite.color),
MAGENTA,
'the helicopter keeps the local magenta',
);
assert.equal(heli.gevLabelModel.details[1], 'Helicopter · A7');
@@ -291,10 +278,7 @@ test('local aircraft use their class silhouette and scale in magenta', async (t)
],
});
await layer.update();
assert.equal(
heli.billboard.scale.getValue(),
CLASS_SCALE_2D.helicopter * 0.8,
);
assert.equal(heli.sprite.scale, CLASS_SCALE_2D.helicopter * 0.8);
const [detectable] = layer.getDetectableObjects();
assert.equal(detectable.sourceId, 'a0b702');
@@ -304,12 +288,12 @@ test('local aircraft use their class silhouette and scale in magenta', async (t)
test('the heading slews toward a new track instead of snapping', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
const { layer, sources } = await enabledLayer(receiver, clock);
const { layer, marker } = await enabledLayer(receiver, clock);
t.after(() => layer.destroy());
receiver.set({ aircraft: [record()] });
await layer.update();
const entity = sources[0].entities.getById('local-adsb:abc123');
assert.equal(entity.billboard.rotation.getValue(), 0);
const entity = marker('abc123');
assert.equal(entity.spec.rotation(), 0);
clock.now = 101_000;
receiver.set({
@@ -323,7 +307,7 @@ test('the heading slews toward a new track instead of snapping', async (t) => {
],
});
await layer.update();
const first = entity.billboard.rotation.getValue();
const first = entity.spec.rotation();
assert.ok(
first > -Cesium.Math.PI_OVER_TWO && first <= 0,
'one frame does not swing the nose 90°',
@@ -333,8 +317,7 @@ test('the heading slews toward a new track instead of snapping', async (t) => {
await layer.update();
}
assert.ok(
Math.abs(entity.billboard.rotation.getValue() + Cesium.Math.PI_OVER_TWO) <
1e-6,
Math.abs(entity.spec.rotation() + Cesium.Math.PI_OVER_TWO) < 1e-6,
'the nose settles on the new track',
);
});
@@ -342,13 +325,12 @@ test('the heading slews toward a new track instead of snapping', async (t) => {
test('markers move in real time between fixes and never jump on a new fix', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
const { layer, sources } = await enabledLayer(receiver, clock);
const { layer, marker } = await enabledLayer(receiver, clock);
t.after(() => layer.destroy());
const latitude = () =>
Cesium.Math.toDegrees(
Cesium.Cartographic.fromCartesian(
sources[0].entities.getById('local-adsb:abc123').position.getValue(),
).latitude,
Cesium.Cartographic.fromCartesian(marker('abc123').spec.position())
.latitude,
);
// 180 kt due north: 1 s covers 92.6 m, 0.000833° of latitude.
const degPerSecond = (180 * 1852) / 3600 / 111_195;
@@ -386,7 +368,7 @@ test('markers move in real time between fixes and never jump on a new fix', asyn
test('an implausible jump in a feed record is ignored by the display', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
const { layer, sources } = await enabledLayer(receiver, clock);
const { layer, marker } = await enabledLayer(receiver, clock);
t.after(() => layer.destroy());
receiver.set({ aircraft: [record({ groundSpeedKt: 120, trackDeg: 0 })] });
await layer.update();
@@ -403,9 +385,8 @@ test('an implausible jump in a feed record is ignored by the display', async (t)
});
await layer.update();
const lat = Cesium.Math.toDegrees(
Cesium.Cartographic.fromCartesian(
sources[0].entities.getById('local-adsb:abc123').position.getValue(),
).latitude,
Cesium.Cartographic.fromCartesian(marker('abc123').spec.position())
.latitude,
);
assert.ok(lat < 0.001, `stayed on its track (${lat})`);
assert.equal(layer.getStats().rejectedPositions, 1);
@@ -414,22 +395,14 @@ test('an implausible jump in a feed record is ignored by the display', async (t)
test('selecting an aircraft draws a magenta trail of the fixes the receiver heard', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
const trails = [];
const { layer } = await enabledLayer(receiver, clock, null, {
services: {
trails: {
createTrail(viewer, options) {
const trail = { options, positions: [], destroyed: false };
trails.push(trail);
return {
setPositions: (positions) => (trail.positions = positions),
destroy: () => (trail.destroyed = true),
};
},
},
},
});
const { layer, engine } = await enabledLayer(receiver, clock);
t.after(() => layer.destroy());
// The trail body (the head is its own line, `-head-`).
const trails = () =>
engine.lines
.list()
.filter(({ id }) => /^gev-trail:local-adsb-\d/.test(id))
.map(({ id }) => engine.fake.line(id));
for (let second = 0; second < 4; second += 1) {
clock.now = 100_000 + second * 1_000;
receiver.set({
@@ -444,25 +417,36 @@ test('selecting an aircraft draws a magenta trail of the fixes the receiver hear
await layer.update();
if (second === 1) assert.equal(layer.selectAircraft('abc123'), true);
}
assert.equal(trails.length, 1);
assert.equal(trails[0].options.color, '#ff4fd8');
assert.equal(trails[0].options.width, 2.5);
assert.equal(trails[0].positions.length, 4, 'every heard fix, oldest first');
assert.ok(
Cesium.Cartographic.fromCartesian(trails[0].positions[0]).latitude <
Cesium.Cartographic.fromCartesian(trails[0].positions[3]).latitude,
);
const [trail] = trails();
assert.equal(trails().length, 1);
near(trail.options.color.slice(0, 3), MAGENTA.slice(0, 3), 'magenta');
assert.equal(trail.options.widthPx, 2.5);
const flat = trail.positions;
assert.equal(flat.length / 3, 4, 'every heard fix, oldest first');
const at = (index) =>
Cesium.Cartographic.fromCartesian(
new Cesium.Cartesian3(
flat[index * 3],
flat[index * 3 + 1],
flat[index * 3 + 2],
),
);
assert.ok(at(0).latitude < at(3).latitude);
clock.now = 200_000;
await layer.update();
assert.equal(trails[0].destroyed, true, 'the trail leaves with the aircraft');
assert.equal(trails().length, 0, 'the trail leaves with the aircraft');
});
test('adsbdb type, operator and route reach the card for the selected aircraft only', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
const queries = [];
const { layer, sources } = await enabledLayer(receiver, clock, null, {
const { layer, marker } = await enabledLayer(receiver, clock, null, {
services: {
// 3D off: a model-eligible aircraft is enriched for its class too.
display: {
getParams: () => ({ models3d: false, models3dMode: 'proximity' }),
},
enrichment: {
async getEnrichment(query) {
queries.push(`${query.kind}:${query.id}`);
@@ -490,7 +474,7 @@ test('adsbdb type, operator and route reach the card for the selected aircraft o
assert.equal(layer.selectAircraft('a0b702'), true);
await new Promise((resolve) => setTimeout(resolve, 400));
assert.deepEqual([...queries].sort(), ['route:EVC145', 'type:a0b702']);
const entity = sources[0].entities.getById('local-adsb:a0b702');
const entity = marker('a0b702');
assert.deepEqual(entity.gevLabelModel.details.slice(0, 4), [
'ICAO A0B702 · EVC145',
'Helicopter · A7',
@@ -509,76 +493,48 @@ test('adsbdb type, operator and route reach the card for the selected aircraft o
test('the DISPLAY 3D toggle gives local aircraft magenta class models', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
const loads = [];
const display = { models3d: true, models3dMode: 'proximity' };
const primitives = [];
const { layer, sources } = await enabledLayer(receiver, clock, null, {
const { layer, engine, marker } = await enabledLayer(receiver, clock, null, {
services: { display: { getParams: () => display } },
viewer: modelViewer(primitives),
loadModel: async (options) => {
const model = {
...options,
ready: true,
show: true,
modelMatrix: new Cesium.Matrix4(),
destroyed: false,
destroy() {
this.destroyed = true;
},
isDestroyed() {
return this.destroyed;
},
update() {},
};
loads.push(model);
return model;
},
});
t.after(() => layer.destroy());
receiver.set({ aircraft: [record({ icao: 'a0b702', category: 'A7' })] });
await layer.update();
await new Promise((resolve) => setImmediate(resolve));
await settle();
await layer.update();
assert.equal(loads.length, 1);
const [model] = loads;
await settle();
const { models } = drawn(engine);
assert.ok(models, 'a model set');
assert.equal(
model.url,
models.options.model.url,
'/models/bell206.glb',
'the Flights helicopter model',
);
assert.equal(model.scale, 1);
assert.equal(model.id, 'local-adsb:a0b702');
assert.ok(
Cesium.Color.equals(
model.color,
Cesium.Color.fromCssColorString('#ff4fd8'),
),
);
assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.MIX);
assert.equal(model.colorBlendAmount, 0.94);
assert.equal(model.show, true);
const heli = sources[0].entities.getById('local-adsb:a0b702');
assert.equal(
heli.billboard.show.getValue(),
false,
'the model is the visual',
);
const origin = Cesium.Matrix4.getTranslation(
model.modelMatrix,
new Cesium.Cartesian3(),
);
assert.ok(Cesium.Cartesian3.distance(origin, heli.position.getValue()) < 1);
assert.deepEqual(models.options.model.colorBlend, {
mode: 'mix',
amount: 0.94,
});
assert.equal(models.store.count, 1);
assert.equal(models.store.scales[0], 1);
assert.equal(models.store.pickIds[0], 'local-adsb:a0b702');
near([...models.store.colors.slice(0, 4)], MAGENTA, 'magenta');
assert.equal(models.store.shows[0], 1, 'the model is drawn');
const heli = marker('a0b702');
assert.equal(heli.spec.show(), false, 'the model is the visual');
const origin = new Cesium.Cartesian3(...models.store.positions.slice(0, 3));
assert.ok(Cesium.Cartesian3.distance(origin, heli.spec.position()) < 1);
display.models3d = false;
await layer.update();
assert.equal(model.destroyed || !primitives[0].contains(model), true);
assert.equal(heli.billboard.show.getValue(), true, 'the billboard is back');
await settle();
assert.equal(drawn(engine).models.store.count, 0, 'the model is gone');
assert.equal(heli.spec.show(), true, 'the billboard is back');
});
test('markers drop when the position is 60 s old and records when silent 60 s', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
const { layer, sources } = await enabledLayer(receiver, clock);
const { layer, engine } = await enabledLayer(receiver, clock);
t.after(() => layer.destroy());
receiver.set({
aircraft: [
@@ -593,7 +549,8 @@ test('markers drop when the position is 60 s old and records when silent 60 s',
],
});
await layer.update();
const entities = () => sources[0].entities.values.map((entity) => entity.id);
await settle();
const entities = () => drawn(engine).ids;
assert.deepEqual(
entities(),
['local-adsb:abc123'],
@@ -608,6 +565,7 @@ test('markers drop when the position is 60 s old and records when silent 60 s',
assert.deepEqual(entities(), ['local-adsb:abc123']);
clock.now = 160_000;
await layer.update();
await settle();
assert.deepEqual(entities(), [], 'a stale position is not drawn');
assert.deepEqual(layer.getDetectableObjects(), []);
});
@@ -765,7 +723,7 @@ test('feed records merge with browser SDR records by ICAO and UAT-only aircraft
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const feeds = fakeFeeds();
const clock = { now: 100_000 };
const { layer, sources } = await enabledLayer(receiver, clock, feeds);
const { layer, engine, marker } = await enabledLayer(receiver, clock, feeds);
t.after(() => layer.destroy());
assert.deepEqual(feeds.calls, ['start'], 'the feeds poll while enabled');
@@ -784,33 +742,29 @@ test('feed records merge with browser SDR records by ICAO and UAT-only aircraft
],
});
await layer.update();
const shared = sources[0].entities.getById('local-adsb:abc123');
const uat = sources[0].entities.getById('local-adsb:a1b2c3');
assert.equal(sources[0].entities.values.length, 2);
await settle();
const shared = marker('abc123');
const uat = marker('a1b2c3');
assert.equal(drawn(engine).ids.length, 2);
assert.equal(
Cesium.Cartographic.fromCartesian(
shared.position.getValue(),
).latitude.toFixed(4),
Cesium.Cartographic.fromCartesian(shared.spec.position()).latitude.toFixed(
4,
),
Cesium.Math.toRadians(1).toFixed(4),
'the newer feed position wins',
);
assert.equal(shared.point, undefined, 'heard on 1090 too: no UAT ring');
assert.equal(shared.ring, null, 'heard on 1090 too: no UAT ring');
assert.equal(
shared.gevLabelModel.details.at(-1),
'Heard by your receiver · 1090 MHz + 978 MHz UAT · browser SDR + decoder feed',
);
assert.ok(uat.point, 'a UAT-only aircraft carries the ring');
assert.equal(uat.point.outlineWidth.getValue(), 1.5);
assert.ok(
Cesium.Color.equals(uat.point.color.getValue(), Cesium.Color.TRANSPARENT),
);
assert.ok(
Cesium.Color.equals(
uat.billboard.color.getValue(),
Cesium.Color.fromCssColorString('#ff4fd8'),
),
'same magenta marker family',
);
assert.ok(uat.ring, 'a UAT-only aircraft carries the ring');
const { rings } = drawn(engine);
assert.equal(rings.store.count, 1);
assert.equal(rings.store.pickIds[0], 'local-adsb:a1b2c3');
assert.equal(rings.options.point.outlinePx, 1.5);
assert.deepEqual([...rings.store.colors.slice(0, 4)], [0, 0, 0, 0]);
near(rgba(uat.sprite.color), MAGENTA, 'same magenta marker family');
assert.equal(
uat.gevLabelModel.details.at(-1),
'Heard by your receiver · 978 MHz UAT · decoder feed',
@@ -1009,60 +963,50 @@ test('row status names two same-band feeds by their ordinal labels', () => {
);
});
function fakeModel(options) {
return {
...options,
ready: true,
show: true,
modelMatrix: new Cesium.Matrix4(),
destroyed: false,
destroy() {
this.destroyed = true;
},
isDestroyed() {
return this.destroyed;
},
update() {},
};
}
test('the last aircraft expiring removes its 3D model and cancels a pending load', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
const primitives = [];
const loads = [];
const deferred = [];
const { layer } = await enabledLayer(receiver, clock, null, {
// The display's loads, the second held until the test lets it finish.
const holdLoads = (display) => {
const load = display.models.loadFleet;
display.models.loadFleet = async (spec) => {
const model = await load(spec);
loads.push(model);
if (spec.id === 'local-adsb:abc123') return model;
return new Promise((resolve) => deferred.push(() => resolve(model)));
};
return display;
};
const { layer, engine } = await enabledLayer(receiver, clock, null, {
display: holdLoads,
services: {
display: {
getParams: () => ({ models3d: true, models3dMode: 'proximity' }),
},
},
viewer: modelViewer(primitives),
loadModel: (options) => {
const model = fakeModel(options);
loads.push(model);
if (options.id === 'local-adsb:abc123') return Promise.resolve(model);
return new Promise((resolve) => deferred.push(() => resolve(model)));
},
});
t.after(() => layer.destroy());
receiver.set({ aircraft: [record({ category: 'A7' })] });
await layer.update();
await new Promise((resolve) => setImmediate(resolve));
await settle();
await layer.update();
await settle();
const [model] = loads;
assert.equal(model.show, true, 'the model is the visual');
assert.ok(primitives[0].contains(model));
assert.equal(drawn(engine).models.store.count, 1);
// Its position ages out: no markers remain, so no per-frame model pass.
clock.now = 160_000;
await layer.update();
await settle();
assert.equal(
model.destroyed || !primitives[0].contains(model),
true,
drawn(engine).models.store.count,
0,
'the expired aircraft leaves no model behind',
);
assert.equal(model.gevBank, null);
// A load still in flight when its aircraft expires is never admitted.
clock.now = 200_000;
@@ -1076,34 +1020,31 @@ test('the last aircraft expiring removes its 3D model and cancels a pending load
],
});
await layer.update();
await settle();
clock.now = 260_000;
await layer.update();
assert.equal(deferred.length, 1, 'a load was in flight');
deferred[0]();
await new Promise((resolve) => setImmediate(resolve));
await settle();
const late = loads.at(-1);
assert.equal(late.id, 'local-adsb:def456');
assert.equal(primitives[0].contains(late), false);
assert.equal(late.destroyed, true);
assert.equal(late.gevBank, null, 'never admitted');
assert.equal(drawn(engine).models.store.count, 0);
});
/**
* 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.
* A fake engine that 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 engine = createFakeEngine();
const engine = fakeEngine();
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,
engine,
release() {
unregister();
engine.destroy();
},
click() {
@@ -1119,7 +1060,7 @@ test('clicking the selected aircraft keeps it selected and republishes its card'
const clock = { now: 100_000 };
const harness = clickHarness();
const { layer, calls } = await enabledLayer(receiver, clock, null, {
viewer: harness.viewer,
engine: harness.engine,
});
t.after(() => {
layer.destroy();
@@ -1127,9 +1068,8 @@ test('clicking the selected aircraft keeps it selected and republishes its card'
});
receiver.set({ aircraft: [record()] });
await layer.update();
const entity =
harness.viewer.sources[0].entities.getById('local-adsb:abc123');
harness.scene.pickResult = { id: entity, primitive: {} };
// The engine names the marker's sprite by its pick id.
harness.scene.pickResult = { id: 'local-adsb:abc123' };
harness.click();
assert.deepEqual(calls.selected, ['local-adsb:abc123']);
// Its card may have been displaced (another readout, a camera move): a
@@ -1144,7 +1084,7 @@ test('a click on a local aircraft under an unowned or trail pick still selects i
const clock = { now: 100_000 };
const harness = clickHarness();
const { layer, calls } = await enabledLayer(receiver, clock, null, {
viewer: harness.viewer,
engine: harness.engine,
});
// The app's pick owners: the trails, and a Flights contact.
registerPickOwner('trails', (id) => String(id).startsWith('gev-trail:'));
@@ -1159,33 +1099,27 @@ test('a click on a local aircraft under an unowned or trail pick still selects i
aircraft: [record(), record({ icao: 'def456', lat: 0.01 })],
});
await layer.update();
const [first, second] = ['abc123', 'def456'].map((icao) =>
harness.viewer.sources[0].entities.getById(`local-adsb:${icao}`),
const [first, second] = ['abc123', 'def456'].map(
(icao) => `local-adsb:${icao}`,
);
// A photoreal tile (no pick id) drawn over the 3D model at this pixel.
harness.scene.pickResult = { primitive: { tileset: true } };
harness.scene.drillResult = [
harness.scene.pickResult,
{ primitive: { id: 'local-adsb:abc123' }, id: 'local-adsb:abc123' },
{ id: 'local-adsb:abc123' },
];
harness.click();
assert.deepEqual(calls.selected, ['local-adsb:abc123']);
// The selected aircraft's trail crosses another local aircraft's marker.
harness.scene.pickResult = { id: { id: 'gev-trail:local-adsb-head-1' } };
harness.scene.drillResult = [
harness.scene.pickResult,
{ id: second, primitive: {} },
];
harness.scene.pickResult = { id: 'gev-trail:local-adsb-head-1' };
harness.scene.drillResult = [harness.scene.pickResult, { id: second }];
harness.click();
assert.deepEqual(calls.selected, ['local-adsb:abc123', 'local-adsb:def456']);
// Another layer's own contact on top is that layer's click.
harness.scene.pickResult = { id: 'abc123', primitive: {} };
harness.scene.drillResult = [
harness.scene.pickResult,
{ id: first, primitive: {} },
];
harness.scene.pickResult = { id: 'abc123' };
harness.scene.drillResult = [harness.scene.pickResult, { id: first }];
harness.click();
assert.equal(calls.selected.length, 2, 'the Flights contact is not taken');
});
@@ -1214,7 +1148,7 @@ test('globe handoff retains the receiver, feed, trail fixes and selection', asyn
});
await layer.update();
assert.equal(layer.getAnalystRecords()[0].icao24, 'abc123');
layer.attachRenderer(fakeViewer().viewer);
layer.attachRenderer(null, { engine: fakeEngine() });
assert.deepEqual(receiver.modeCalls, ['adsb']);
assert.deepEqual(feeds.calls, ['start']);
assert.equal(closes, 0);
+1 -2
View File
@@ -29,8 +29,7 @@ const specKey = (spec) => `${spec.url}@${spec.scale}`;
* 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.renderer The layer's renderer (render/cesium.js,
* render/navara.js).
* @param {object} options.renderer The layer's display (render/display.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.
-217
View File
@@ -1,217 +0,0 @@
// The Local ADS-B layer's renderer on Cesium: 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;
}
@@ -1,16 +1,27 @@
// The Local ADS-B layer's renderer on the Navara engine,
// with render/cesium.js's interface: the shared aircraft renderer
// (src/layers/aircraft/render/navara.js) draws the magenta markers (its
// fleet glyphs), the Flights models and the trail; the markers' per-frame
// position, rotation and visibility (Cesium's CallbackProperties and the
// 2,000 km display distance) are written each frame; the 978 MHz UAT ring
// is its own sprite set (Cesium's 24 px transparent point with a 1.5 px
// outline). Registered for 'navara' by the Navara mode after its import()
// (src/app/navaraMode.js). The camera never follows a local aircraft.
// The Local ADS-B layer's display, the same on every engine: the aircraft
// display (src/layers/aircraft/render/display.js) draws the magenta markers
// (its fleet glyphs), the Flights models and the trail; the markers'
// position, rotation and visibility are read each frame, within a 2,000 km
// display distance; the 978 MHz UAT ring is a point set (24 px, transparent,
// a 1.5 px outline). The camera never follows a local aircraft.
//
// The display:
// view camera, scene, horizon(), frustumTester(),
// surfaceOwner, requestRender(), on('preRender', fn)
// attach(), setVisible(bool), detach()
// markers.add({ id, image, position(), rotation(), show() }) → handle
// (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 display's models (live, loadFleet,
// add, remove, destroy, setPose)
// trails.create({ color, width }) → Trail, createHead({ id, positions(),
// color, width }), removeHead(head)
// engine, destroy()
import * as geo from '../../../geo/index.js';
import { createAircraftNavaraRenderer } from '../../aircraft/render/navara.js';
import { createMoverBank } from '../../shared/render/navaraMoverBank.js';
import { createAircraftDisplay } from '../../aircraft/render/display.js';
import { createMoverBank } from '../../shared/render/moverBank.js';
import { MODEL_HEADING_OFFSET_DEG } from '../../flights/policy.js';
import { nearFarScale, rgbaFromCss } from '../../shared/color.js';
import {
@@ -24,81 +35,63 @@ export const LOCAL_ADSB_DISPLAY_DISTANCE_M = 2_000_000;
const MAGENTA = Object.freeze(rgbaFromCss(LOCAL_ADSB_COLOR));
const RING = rgbaFromCss(LOCAL_ADSB_UAT_RING_COLOR).rgba();
const TRANSPARENT = [0, 0, 0, 0];
const SCALE_BY_DISTANCE = Object.freeze(
nearFarScale(1000, 3.0, 8_000_000, 0.5),
);
// Cesium's point: 24 px plus a 1.5 px outline on each side, the outline
// drawn on the outer 1.5 px.
const RING_PIXEL_SIZE = 24;
const RING_OUTLINE_PX = 1.5;
const RING_TOTAL_PX = RING_PIXEL_SIZE + 2 * RING_OUTLINE_PX;
// Under the aircraft layers' sprites (data/spriteOrder).
const MARKER_ORDER = -1;
/** The ring's atlas: a white ring on a 64 px canvas (tinted per instance). */
function drawUatRingAtlas(size = 64) {
const canvas = globalThis.document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
const scale = size / RING_TOTAL_PX;
const outer = (RING_TOTAL_PX / 2) * scale;
const band = RING_OUTLINE_PX * scale;
context.strokeStyle = '#ffffff';
context.lineWidth = band;
context.beginPath();
context.arc(size / 2, size / 2, outer - band / 2, 0, Math.PI * 2);
context.stroke();
return { canvas, cells: { ring: [0, 0, 1, 1] } };
}
/**
* Registry factory (src/app/layers/renderers.js): `(engine, options)`.
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {{ drawAtlas?: Function, drawRing?: Function }} [options] Test seams.
* @param {{ drawAtlas?: Function }} [options] Test seam: the glyph atlas.
*/
export function createLocalAdsbNavaraRenderer(
engine,
{ drawAtlas, drawRing = drawUatRingAtlas } = {},
) {
const aircraft = createAircraftNavaraRenderer({
engine,
export function createLocalAdsbDisplay(engine, { drawAtlas } = {}) {
const aircraft = createAircraftDisplay(engine, {
layerId: LAYER_ID,
// Under every sprite collection, as Cesium draws the layer's entities
// (data/spriteOrder raises the collections over the data sources).
order: MARKER_ORDER,
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
...(drawAtlas ? { drawAtlas } : {}),
});
const view = aircraft.view;
// Hidden until the layer shows it.
aircraft.fleet.setVisible(false);
const handles = new Set();
let visible = false;
let ringBank = null;
let ringDirty = false;
let removeFrame = null;
const scratch = geo.cartesian3.create();
const ringWrite = (handle, set, index) => {
set.set(index, {
position: handle.sprite.position,
color: RING,
cell: 'ring',
color: TRANSPARENT,
show: handle.ringShown,
pickId: handle.id,
});
};
const commitRings = () => {
ringDirty = false;
ringBank?.commit();
};
const ringBankFor = () => {
if (ringBank) return ringBank;
const atlas = drawRing();
ringBank = createMoverBank(engine, {
options: (capacity, generation) => ({
id: `${LAYER_ID}-rings-${generation}`,
kind: 'sprite',
kind: 'point',
capacity,
order: MARKER_ORDER + 0.25,
depthTest: 'always',
pickable: true,
sprite: {
atlas,
sizePx: RING_TOTAL_PX,
point: {
sizePx: RING_PIXEL_SIZE,
outlinePx: RING_OUTLINE_PX,
outlineColor: RING,
scaleByDistance: [
SCALE_BY_DISTANCE.near,
SCALE_BY_DISTANCE.nearValue,
@@ -108,13 +101,19 @@ export function createLocalAdsbNavaraRenderer(
},
}),
write: ringWrite,
onCommit: (bank) => queueMicrotask(() => bank.commit()),
// Committed at the end of the frame pass, or in a microtask.
onCommit: () => {
if (ringDirty) return;
ringDirty = true;
queueMicrotask(commitRings);
},
});
ringBank.setVisible(visible);
return ringBank;
};
// Each frame: Cesium's per-frame callbacks and its display distance.
// Each frame: the markers' position, rotation and visibility, and the
// display distance.
const frame = () => {
const camera = view.camera.positionWC;
const limitSq = LOCAL_ADSB_DISPLAY_DISTANCE_M ** 2;
@@ -138,7 +137,7 @@ export function createLocalAdsbNavaraRenderer(
});
}
}
ringBank?.commit();
commitRings();
};
const markers = {
@@ -193,16 +192,17 @@ export function createLocalAdsbNavaraRenderer(
};
return {
kind: 'navara',
engine,
view,
markers,
models: aircraft.models,
trails: aircraft.trails,
/** The model tint (the layer's magenta). */
modelColor: MAGENTA,
dataSource: null,
whenReady: () => aircraft.whenReady(),
attach() {
aircraft.fleet.attach();
removeFrame ??= view.on('preRender', frame);
},
setVisible(next) {
+20 -11
View File
@@ -1,14 +1,14 @@
// The Local ADS-B logic on its Navara renderer over a
// fake engine: magenta markers in the shared aircraft renderer's glyph sets,
// the UAT ring as its own set, a click through the engine's selection
// The Local ADS-B logic on its display over a
// fake engine: magenta markers in the aircraft display's glyph sets,
// the UAT ring as its own point set, a click through the engine's selection
// dispatcher selects (the camera never follows), empty map releases, and the
// layer leaves no mover set behind.
import assert from 'node:assert/strict';
import test from 'node:test';
import { createLocalAdsbLayer } from './index.js';
import { createLocalAdsbNavaraRenderer } from './render/navara.js';
import { aircraftAtlasLayout } from '../aircraft/render/navara.js';
import { createLocalAdsbDisplay } from './render/display.js';
import { aircraftAtlasLayout } from '../aircraft/render/display.js';
import * as selection from '../../data/selectionDispatcher.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import * as geo from '../../geo/index.js';
@@ -49,7 +49,7 @@ const record = (icao, lat, band = '1090') => ({
source: 'feed',
});
test('local aircraft draw, ring and select on the Navara renderer; the camera never follows', async () => {
test('local aircraft draw, ring and select on the display; the camera never follows', async () => {
const engine = createFakeEngine();
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(0, 0, 60_000),
@@ -76,15 +76,11 @@ test('local aircraft draw, ring and select on the Navara renderer; the camera ne
selection,
renderer: {
create: ({ engine: given }) =>
(renderer = createLocalAdsbNavaraRenderer(given, {
(renderer = createLocalAdsbDisplay(given, {
drawAtlas: async () => ({
canvas: { width: layout.width, height: layout.height },
cells: layout.cells,
}),
drawRing: () => ({
canvas: { width: 64, height: 64 },
cells: { ring: [0, 0, 1, 1] },
}),
})),
},
},
@@ -115,6 +111,19 @@ test('local aircraft draw, ring and select on the Navara renderer; the camera ne
const rings = sets().filter(({ id }) => id.startsWith('local-adsb-rings'));
assert.equal(rings.length, 1);
assert.equal(rings[0].count, 1, 'a ring for the UAT-only aircraft');
// The ring: a transparent 24 px point with a 1.5 px outline, over the
// markers, drawn through the scene.
const ring = engine.fake.moverSet(rings[0].id);
assert.equal(ring.options.kind, 'point');
assert.equal(ring.options.point.sizePx, 24);
assert.equal(ring.options.point.outlinePx, 1.5);
assert.equal(ring.options.depthTest, 'always');
assert.deepEqual([...ring.store.colors.slice(0, 4)], [0, 0, 0, 0]);
assert.equal(ring.store.pickIds[0], 'local-adsb:def456');
assert.equal(
engine.fake.moverSet(sprites[0].id).options.depthTest,
'always',
);
assert.equal(layer.getAllPositions().length, 2);
// A click selects through the dispatcher; the camera does not follow.
+5 -6
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { createMilitaryFlightLayer } from './index.js';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
import { aircraftViewerRenderer } from '../../testSupport/aircraftViewerRenderer.mjs';
function services() {
const names = [
@@ -31,17 +32,13 @@ function services() {
// The application's own scene services, as src/app/layers/militaryFlights.js
// composes them; only the surface samplers and registry are stubbed.
async function sceneServices() {
const { createMilitaryCesiumRenderer } = await import('./render/cesium.js');
// The application's own scene services, as src/app/layers/militaryFlights.js
// composes them; only the surface samplers and registry are stubbed.
return {
picking: await import('../../data/pickRegistry.js'),
selection: await import('../../data/selectionDispatcher.js'),
// The layer's Cesium renderer, as the app creates it without an engine.
renderer: {
create: ({ viewer, adopt }) =>
createMilitaryCesiumRenderer(null, { viewer, adopt }),
},
// Seeded through the testing seam with the test's own renderer.
renderer: { create: () => null },
aircraftPresentation: await import('../../data/tr3bRegistry.js'),
camera: await import('../../data/trackedCamera.js'),
labels: await import('../../data/detectionDraw.js'),
@@ -186,6 +183,7 @@ async function firstPlacement(appClockLagMs) {
});
const added = [];
layer.testing._setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: 'seed00',
entity: null,
meta: { rawLat: 0, rawLon: 0, onGround: false },
@@ -289,6 +287,7 @@ test('a vanished military contact fades while position-less rows keep theirs', a
});
const viewer = { camera: { positionCartographic: null }, scene: {} };
layer.testing._setTrackedMilitaryRefreshStateForTest({
renderer: aircraftViewerRenderer,
icao24: 'seed00',
entity: null,
meta: { rawLat: 0, rawLon: 0, onGround: false },
-24
View File
@@ -1,24 +0,0 @@
// The military flights layer's renderer on Cesium: the shared aircraft
// renderer (src/layers/aircraft/render/cesium.js) with the military's
// sprite-order slot and jet.glb's nose offset (the per-class specs pass
// their own offset to setPose). The military's presentation choices (amber
// #FFB800 fleet, #FFD166 tracked, the IR boost as an unlit white model) are
// the layer's logic and reach the renderer as plain data.
import { createAircraftCesiumRenderer } from '../../aircraft/render/cesium.js';
import { MODEL_HEADING_OFFSET_DEG } from '../policy.js';
/**
* Registry factory (src/app/layers/renderers.js): `(engine, options)`.
* @param {object|null} engine The Cesium globe engine (null in unit tests).
* @param {{ viewer: object, adopt?: object }} options
*/
export function createMilitaryCesiumRenderer(engine, { viewer, adopt } = {}) {
return createAircraftCesiumRenderer({
viewer,
engine,
spriteLayer: 'military',
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
adopt,
});
}
+15
View File
@@ -0,0 +1,15 @@
// The military flights layer's display, the same on every engine: the
// aircraft display (src/layers/aircraft/render/display.js) in the military's
// sprite slot with jet.glb's nose offset (the per-class specs pass their own
// offset to setPose). The military's presentation choices (amber #FFB800
// fleet, #FFD166 tracked, the IR boost as an unlit white model) are the
// layer's logic and reach the display as plain data.
import { aircraftLayerDisplay } from '../../aircraft/render/display.js';
import { MODEL_HEADING_OFFSET_DEG } from '../policy.js';
/** Registry factory (src/app/layers/renderers.js): `(engine, options)`. */
export const createMilitaryDisplay = aircraftLayerDisplay(
'military',
MODEL_HEADING_OFFSET_DEG,
);
-11
View File
@@ -1,11 +0,0 @@
// The military flights layer's renderer on the Navara engine: the shared
// aircraft renderer with jet.glb's nose offset. Registered for 'navara' by
// the Navara mode after its import() (src/app/navaraMode.js).
import { aircraftLayerNavaraRenderer } from '../../aircraft/render/navara.js';
import { MODEL_HEADING_OFFSET_DEG } from '../policy.js';
export const createMilitaryNavaraRenderer = aircraftLayerNavaraRenderer(
'military',
MODEL_HEADING_OFFSET_DEG,
);
+4 -5
View File
@@ -138,9 +138,9 @@ test('the military logic draws, selects and follows only through its renderer',
assert.equal(keyListeners.size, 0, 'the dispatcher left the keyboard');
});
test('the military logic draws and follows on the Navara renderer; the follow passes between flights and military', async () => {
const { createAircraftNavaraRenderer, aircraftAtlasLayout } =
await import('../aircraft/render/navara.js');
test('the military logic draws and follows on the aircraft display; the follow passes between flights and military', async () => {
const { createAircraftDisplay, aircraftAtlasLayout } =
await import('../aircraft/render/display.js');
const { createApplicationFlights } =
await import('../../app/layers/flights.js');
const { openSkySnapshot } = await import('../../sources/live/aircraft.js');
@@ -153,8 +153,7 @@ test('the military logic draws and follows on the Navara renderer; the follow pa
const renderers = {};
const navara = (layerId, order) => ({
create: ({ engine: given }) =>
(renderers[layerId] = createAircraftNavaraRenderer({
engine: given,
(renderers[layerId] = createAircraftDisplay(given, {
layerId,
order,
headingOffsetDeg: 180,
+3 -3
View File
@@ -33,9 +33,9 @@ export function createFlightState({ source, services }) {
flightState._scratchGroundPos = geo.cartesian3.create();
/** @type {import('../aircraft/render/contract.js').AircraftRenderer|null}
* The engine's aircraft renderer for this lifecycle (render/cesium.js on
* Cesium): the fleet sprites, the models and their collection, the
* tracked visual, the trail, the follow camera and the view reads. */
* The engine's aircraft renderer for this lifecycle (render/display.js,
* on every engine): the fleet sprites, the models, the tracked visual,
* the trail, the follow camera and the view reads. */
flightState._renderer = null;
+13 -4
View File
@@ -1,4 +1,8 @@
import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js';
import {
AMBER_TRANSPARENT,
MODEL_HEADING_OFFSET_DEG,
TRACKED_ICON_COLOR,
} from './policy.js';
export function createTesting({
flightState,
@@ -18,6 +22,8 @@ export function createTesting({
* @param {object} state.billboard
* @param {object} state.billboardCollection
* @param {object} state.viewer
* @param {(options: object) => object} state.renderer The test's renderer
* over the viewer and collections (testSupport/aircraftViewerRenderer).
* @param {Array<Object>} [state.history=[]]
* @param {boolean} [state.tracked=true]
* @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed
@@ -35,12 +41,15 @@ export function createTesting({
tracked = true,
models = [],
modelCollection = null,
renderer: createRenderer,
}) {
flightState.enabled = billboardCollection?.show !== false;
// The production renderer over the test's viewer and (fake) collections.
flightState._renderer = services.renderer.create({
// The test's renderer over its viewer and (fake) collections.
flightState._renderer = createRenderer({
viewer,
adopt: { billboardCollection, modelCollection },
billboardCollection,
modelCollection,
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
});
flightState._billboards = new Map([[icao24, billboard]]);
flightState._cullPositions.clear();
+128
View File
@@ -0,0 +1,128 @@
// A growing bank of engine.movers sprites, points or models that layer
// renderers share (on any engine, through the contract): handles hold their
// index into one mover set, which is rebuilt at double the capacity when it
// fills.
const FIRST_CAPACITY = 1024;
// Reused set() arguments (set() copies them; no object per write).
const fieldsNoPick = {
position: undefined,
orientation: undefined,
color: undefined,
rotation: undefined,
scale: undefined,
cell: undefined,
show: undefined,
};
/** Writes `fields` of the mover at `index` (set() copies them). */
export function writeFields(set, index, fields) {
const scratch = fieldsNoPick;
for (const key in fields) scratch[key] = fields[key];
set.set(index, scratch);
for (const key in fields) scratch[key] = undefined;
}
/**
* A growing bank of one kind of mover: handles hold an index; removal moves
* the last handle into the hole. `options(capacity, generation)` gives the
* createSet options; `write(handle, set, index)` writes a handle whole.
*/
export function createMoverBank(engine, { options, write, onCommit }) {
const handles = [];
let set = null;
let generation = 0;
let capacity = 0;
let visible = true;
let dirty = false;
const build = (wanted) => {
const next = engine.movers.createSet(options(wanted, ++generation));
set?.destroy();
set = next;
capacity = wanted;
handles.forEach((handle, index) => write(handle, set, index));
set.setCount(visible ? handles.length : 0);
};
const bank = {
handles,
get set() {
return set;
},
ready: () => Boolean(set?.ready()),
add(handle) {
if (handle.gevBank === bank) return;
handle.gevBank?.remove(handle);
handle.gevBank = bank;
handle.gevIndex = handles.length;
handles.push(handle);
if (!set || handles.length > capacity)
build(Math.max(FIRST_CAPACITY, capacity * 2, handles.length));
else {
write(handle, set, handle.gevIndex);
if (visible) set.setCount(handles.length);
}
bank.touch();
},
remove(handle) {
if (handle.gevBank !== bank) return;
const index = handle.gevIndex;
const last = handles.pop();
if (last !== handle) {
handles[index] = last;
last.gevIndex = index;
write(last, set, index);
}
handle.gevBank = null;
handle.gevIndex = -1;
set?.setCount(visible ? handles.length : 0);
bank.touch();
},
/** Writes `fields` of one handle (when it is in this bank). */
update(handle, fields) {
if (handle.gevBank !== bank || !set) return;
writeFields(set, handle.gevIndex, fields);
bank.touch();
},
setVisible(next) {
visible = next;
set?.setCount(visible ? handles.length : 0);
bank.touch();
},
visible: () => visible,
touch() {
if (!dirty) {
dirty = true;
onCommit(bank);
}
},
commit() {
if (!dirty) return;
dirty = false;
set?.commit();
},
destroy() {
for (const handle of handles) {
handle.gevBank = null;
handle.gevIndex = -1;
}
handles.length = 0;
set?.destroy();
set = null;
},
};
return bank;
}
/** Plain colour copy (the stored value a handle's colour getter returns). */
export class StoredColor {
constructor(color) {
this.red = color?.red ?? 1;
this.green = color?.green ?? 1;
this.blue = color?.blue ?? 1;
this.alpha = color?.alpha ?? 1;
}
withAlpha(alpha) {
return new StoredColor({ ...this, alpha });
}
}
+2 -127
View File
@@ -1,127 +1,2 @@
// A growing bank of engine.movers sprites or models that Navara renderers
// share: handles hold their index into one mover set, which is rebuilt at
// double the capacity when it fills.
const FIRST_CAPACITY = 1024;
// Reused set() arguments (set() copies them; no object per write).
const fieldsNoPick = {
position: undefined,
orientation: undefined,
color: undefined,
rotation: undefined,
scale: undefined,
cell: undefined,
show: undefined,
};
/** Writes `fields` of the mover at `index` (set() copies them). */
export function writeFields(set, index, fields) {
const scratch = fieldsNoPick;
for (const key in fields) scratch[key] = fields[key];
set.set(index, scratch);
for (const key in fields) scratch[key] = undefined;
}
/**
* A growing bank of one kind of mover: handles hold an index; removal moves
* the last handle into the hole. `options(capacity, generation)` gives the
* createSet options; `write(handle, set, index)` writes a handle whole.
*/
export function createMoverBank(engine, { options, write, onCommit }) {
const handles = [];
let set = null;
let generation = 0;
let capacity = 0;
let visible = true;
let dirty = false;
const build = (wanted) => {
const next = engine.movers.createSet(options(wanted, ++generation));
set?.destroy();
set = next;
capacity = wanted;
handles.forEach((handle, index) => write(handle, set, index));
set.setCount(visible ? handles.length : 0);
};
const bank = {
handles,
get set() {
return set;
},
ready: () => Boolean(set?.ready()),
add(handle) {
if (handle.gevBank === bank) return;
handle.gevBank?.remove(handle);
handle.gevBank = bank;
handle.gevIndex = handles.length;
handles.push(handle);
if (!set || handles.length > capacity)
build(Math.max(FIRST_CAPACITY, capacity * 2, handles.length));
else {
write(handle, set, handle.gevIndex);
if (visible) set.setCount(handles.length);
}
bank.touch();
},
remove(handle) {
if (handle.gevBank !== bank) return;
const index = handle.gevIndex;
const last = handles.pop();
if (last !== handle) {
handles[index] = last;
last.gevIndex = index;
write(last, set, index);
}
handle.gevBank = null;
handle.gevIndex = -1;
set?.setCount(visible ? handles.length : 0);
bank.touch();
},
/** Writes `fields` of one handle (when it is in this bank). */
update(handle, fields) {
if (handle.gevBank !== bank || !set) return;
writeFields(set, handle.gevIndex, fields);
bank.touch();
},
setVisible(next) {
visible = next;
set?.setCount(visible ? handles.length : 0);
bank.touch();
},
visible: () => visible,
touch() {
if (!dirty) {
dirty = true;
onCommit(bank);
}
},
commit() {
if (!dirty) return;
dirty = false;
set?.commit();
},
destroy() {
for (const handle of handles) {
handle.gevBank = null;
handle.gevIndex = -1;
}
handles.length = 0;
set?.destroy();
set = null;
},
};
return bank;
}
/** Plain colour copy (the stored value a handle's colour getter returns). */
export class StoredColor {
constructor(color) {
this.red = color?.red ?? 1;
this.green = color?.green ?? 1;
this.blue = color?.blue ?? 1;
this.alpha = color?.alpha ?? 1;
}
withAlpha(alpha) {
return new StoredColor({ ...this, alpha });
}
}
// The mover bank under the name the Navara renderers import it by.
export { createMoverBank, StoredColor, writeFields } from './moverBank.js';
+3 -3
View File
@@ -12,7 +12,7 @@ import { createContactTrailRenderer } from '../../../data/render/cesiumContactTr
import { CAMERA_PERCENTAGE_CHANGED } from '../policy.js';
/** Native resource owner; feed/playback/selection policy stays in the layer. */
export function createTransitCesiumRenderer({ state, services }) {
export function createTransitCesiumRenderer({ state, services, engine }) {
let viewer = null;
const banks = [];
const removers = new Set();
@@ -99,13 +99,13 @@ export function createTransitCesiumRenderer({ state, services }) {
},
claimCamera() {
claimCameraSensitivity(
viewer?.camera,
engine ?? viewer?.camera,
'transit',
CAMERA_PERCENTAGE_CHANGED,
);
},
releaseCamera() {
releaseCameraSensitivity(viewer?.camera, 'transit');
releaseCameraSensitivity(engine ?? viewer?.camera, 'transit');
},
onCameraChanged: (fn) => own(viewer.camera.changed.addEventListener(fn)),
onPreRender: (fn) => own(viewer.scene.preRender.addEventListener(fn)),
@@ -8,6 +8,7 @@ import { createWildlifeSightingsSource } from './source.js';
import { wildlifeGridCellId } from './gridViewport.js';
import { LayerLifecycle } from '../../data/lifecycle.js';
import { layerFeedState } from '../../data/feedState.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
const NOW = Date.UTC(2026, 10, 4, 18);
const rectangle = { south: 10, west: 10, north: 12, east: 12 };
@@ -20,31 +21,26 @@ function harness(source = { getSnapshot: async () => [] }) {
let notifications = 0;
let managerRequests = 0;
const timers = new Map();
const cameraListeners = new Set();
const document = { hidden: false };
// The camera sees `bounds` (degrees) from an exact `camera.height`.
const engine = createFakeEngine({ width: 1400, height: 900 });
const camera = { height: 30_000 };
engine.camera.height = () => camera.height;
engine.fake.script(
'viewRectangle',
() =>
bounds &&
Object.fromEntries(
Object.entries(bounds).map(([key, value]) => [
key,
(value * Math.PI) / 180,
]),
),
);
const state = {
viewer: {
camera: {
moveEnd: {
addEventListener(fn) {
cameraListeners.add(fn);
return () => cameraListeners.delete(fn);
},
},
positionCartographic: { height: 30_000 },
computeViewRectangle: () =>
bounds &&
Object.fromEntries(
Object.entries(bounds).map(([key, value]) => [
key,
(value * Math.PI) / 180,
]),
),
},
},
engine,
enabled: true,
destroyed: false,
points: {},
rows: new Map(),
renderedRows: [],
rowById: new Map(),
@@ -118,6 +114,8 @@ function harness(source = { getSnapshot: async () => [] }) {
});
return {
state,
engine,
camera,
layer,
parts,
timers,
@@ -129,9 +127,7 @@ function harness(source = { getSnapshot: async () => [] }) {
},
notifications: () => notifications,
managerRequests: () => managerRequests,
moveEnd: () => {
for (const listener of cameraListeners) listener();
},
moveEnd: () => engine.fake.emit('camera.moveEnd'),
async advance(ms) {
time += ms;
for (const [id, timer] of [...timers]) {
@@ -347,8 +343,8 @@ test('SF WHALES failure keeps chosen error chip, drawn rows and manager DEGRADED
},
});
h.bounds({ south: 37.2, west: -123, north: 38.2, east: -122 });
h.state.viewer.camera.positionCartographic.height = 250_000;
const manager = new LayerLifecycle(h.state.viewer);
h.camera.height = 250_000;
const manager = new LayerLifecycle(null, { engine: h.engine });
manager.register({ ...h.layer, init: () => true, updateInterval: 0 });
h.layer.attachDataManager(manager);
try {
@@ -491,7 +487,7 @@ test('a selected observation survives omitted samples, zoom, changed filters and
await h.layer.update();
assert.equal(h.state.rows.get(sighting.stableId), sighting);
assert.equal(h.state.selectedId, sighting.stableId);
h.state.viewer.camera.positionCartographic.height = 3_500_000;
h.camera.height = 3_500_000;
h.bounds({ south: 40, west: 40, north: 42, east: 42 });
await h.layer.update();
assert.equal(h.state.query.scope, 'global');
@@ -554,7 +550,7 @@ test('all heights and the sampled fallback use the same sightings-in-view count
h.state.gridTotal = 1000;
h.state.sightingCount = 870;
for (const height of [30_000, 1_500_000]) {
h.state.viewer.camera.positionCartographic.height = height;
h.camera.height = height;
assert.equal(h.layer.getStats().count, 870);
assert.equal(h.layer.getStats().countLabel, '870 sightings in view');
}
@@ -676,8 +672,8 @@ test('Europe density tiles publish before a slow observation page and survive it
},
});
h.bounds({ south: 44, west: -8, north: 55, east: 24 });
h.state.viewer.scene = { canvas: { clientWidth: 1280, clientHeight: 800 } };
h.state.viewer.camera.positionCartographic.height = 1_500_000;
h.engine.fake.setSize(1280, 800);
h.camera.height = 1_500_000;
const updating = h.layer.update();
assert.equal(tileOptions.tiles.length, 3);
const first = { id: 'grid:first', lat: 48, lon: 8, cellCount: 4 };
@@ -800,12 +796,11 @@ test('an empty tile retires its old zoom cells while uncovered density survives
},
});
h.bounds({ south: 44, west: -8, north: 55, east: 24 });
h.state.viewer.scene = { canvas: { clientWidth: 160, clientHeight: 100 } };
h.state.viewer.camera.positionCartographic.height = 1_500_000;
h.engine.fake.setSize(160, 100);
h.camera.height = 1_500_000;
await h.layer.update();
first = false;
h.state.viewer.scene.canvas.clientWidth = 1280;
h.state.viewer.scene.canvas.clientHeight = 800;
h.engine.fake.setSize(1280, 800);
const updating = h.layer.update();
const zoom = tileOptions.tiles[0].z;
assert.ok(zoom > oldZoom);
+4 -20
View File
@@ -1,7 +1,7 @@
import { drainWildlifeWork, WILDLIFE_WORK_UNIT } from './performance.js';
import { wildlifeIconicGroup } from './model.js';
/** Incremental billboard reconciliation, independent of Cesium and the DOM.
/** Incremental badge reconciliation, independent of the engine and the DOM.
* A refresh changes only added/removed contacts and properties that differ.
* Position sampling and appearance construction run only for changed rows. */
export function createWildlifeBillboardModel({
@@ -58,9 +58,8 @@ export function createWildlifeBillboardModel({
writes += 1;
}
for (const key of Object.keys(appearance)) {
// Cesium clones Color and NearFarScalar values on assignment. Our
// last descriptor, rather than getter reference identity, records
// whether the appearance has actually changed.
// The last descriptor, rather than getter reference identity,
// records whether the appearance has actually changed.
if (old?.appearance?.[key] === appearance[key]) continue;
point[key] = appearance[key];
writes += 1;
@@ -95,20 +94,5 @@ export function createWildlifeBillboardModel({
previous.clear();
}
// Font readiness replaces just the cached texture. Never resample anchors,
// clear the collection or allocate replacement billboards for this swap.
function refreshImages(imageForGroup) {
let writes = 0;
for (const [id, old] of previous) {
const point = pointById.get(id);
const image = imageForGroup(old.group);
if (!point || old.appearance.image === image) continue;
point.image = image;
old.appearance = { ...old.appearance, image };
writes += 1;
}
return writes;
}
return { update, updateWork, clear, refreshImages };
return { update, updateWork, clear };
}
+9 -6
View File
@@ -21,6 +21,7 @@ import {
} from './policy.js';
import { formatAge } from './photoText.js';
import { createSelection } from './selection.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { setLocale } from '../../i18n/index.js';
import {
altitudeFade,
@@ -439,6 +440,11 @@ test('compact ages follow shared translated minute, hour and day forms', () => {
});
function ambientFixture(rows, screens) {
const engine = createFakeEngine({ width: 1200, height: 900 });
// Positions here are screen points already; heights are exact.
engine.fake.script('worldToWindow', (position) => position);
const camera = { height: 30_000 };
engine.camera.height = () => camera.height;
const state = {
enabled: true,
renderedRows: rows,
@@ -451,10 +457,7 @@ function ambientFixture(rows, screens) {
),
pointById: new Map(),
rowById: new Map(rows.map((row) => [row.stableId, row])),
viewer: {
scene: { canvas: { clientWidth: 1200, clientHeight: 900 } },
camera: { positionCartographic: { height: 30_000 } },
},
engine,
};
const photos = [];
const painted = [];
@@ -464,7 +467,6 @@ function ambientFixture(rows, screens) {
state,
services: {
now: () => now,
project: (_scene, position) => position,
overlayHost: {
setEntries(id, _entries, options) {
entries.set(id, _entries);
@@ -483,6 +485,7 @@ function ambientFixture(rows, screens) {
});
return {
state,
camera,
selection,
photos,
painted,
@@ -606,7 +609,7 @@ test('pans retain every on-screen photo and refreshes only fill free slots', ()
test('ambient show/hide thresholds have hysteresis without small-zoom toggles', () => {
const a = row();
const f = ambientFixture([a], new Map([[a.stableId, { x: 150, y: 150 }]]));
const height = f.state.viewer.camera.positionCartographic;
const height = f.camera;
height.height = WILDLIFE_CARD_SHOW_MAX_METERS - 1;
f.selection.refreshAmbient();
assert.equal(f.state.ambientRows.length, 1);
+1 -2
View File
@@ -230,8 +230,7 @@ export function createControls({ state, services, parts }) {
if (
!state.error &&
!state.query &&
state.viewer?.camera?.positionCartographic?.height >
WILDLIFE_GRID_HEIGHT_METERS
state.engine?.camera.height() > WILDLIFE_GRID_HEIGHT_METERS
) {
// i18n-ignore: feed-state protocol value
stats.status = 'zoom-in';
+160 -67
View File
@@ -1,4 +1,11 @@
import * as geo from '../../geo/index.js';
import { wildlifeGridCellBounds } from './gridViewport.js';
import {
createWildlifeSpriteBank,
wildlifeCameraView,
wildlifeMetresToPixels,
wildlifeNearFarValue,
} from './display.js';
import {
createWildlifeWorkQueue,
drainWildlifeWork,
@@ -14,6 +21,10 @@ const METERS_PER_DEGREE = (2 * Math.PI * 6378137) / 360;
const FADE_STEPS = 3;
const FADE_STEP_MS = 60;
const SPRITE_SIZE = 128;
const WGS84_MINIMUM_RADIUS = geo.ellipsoid.WGS84.minimumRadius;
// Under every contact sprite set (src/data/spriteOrder.js orders start at 0).
export const WILDLIFE_DENSITY_ORDER = -1;
export const WILDLIFE_DENSITY_SET_ID = 'wildlife-sightings-density';
// Fade coarse splats before they read as haze across the whole Earth disc.
const ALTITUDE_FADE_START = 3_000_000;
const ALTITUDE_FADE_END = 8_000_000;
@@ -106,7 +117,7 @@ function intersects(bounds, boxes, padding = 0) {
}
// Fit the entire kernel inside the globe's tangent cone, plus 5% of its radius.
// A visible centre alone lets a depth-free billboard bleed across the limb.
// A visible centre alone lets a depth-free sprite bleed across the limb.
// The inscribed sphere is conservative for WGS84 and needs no projections.
function horizonTest(cameraPosition, radius) {
const { x, y, z } = cameraPosition || {};
@@ -130,16 +141,33 @@ function horizonTest(cameraPosition, radius) {
};
}
/** Own noninteractive, world-sized glow splats, arrival fades and style changes. */
/** A world-sized kernel fills the screen once the camera is inside it, e.g.
* while flying in before the closer view's tiles arrive. Cesium fades it on the
* GPU: gone within half its width, full beyond twice its width. */
* while flying in before the closer view's tiles arrive. Its alpha fades with
* the camera distance: gone within half its width, full beyond twice its
* width (Cesium's NearFarScalar interpolation). */
export const WILDLIFE_DENSITY_NEAR_FADE = Object.freeze({ near: 0.5, far: 2 });
const DENSITY_ATLAS = Object.freeze({
url: DENSITY_IMAGE,
cells: Object.freeze({ glow: Object.freeze([0, 0, 1, 1]) }),
});
/** The near fade of a kernel `width` metres wide, as [nearM, 0, farM, 1]. */
export function wildlifeDensityNearFade(width) {
return [
width * WILDLIFE_DENSITY_NEAR_FADE.near,
0,
width * WILDLIFE_DENSITY_NEAR_FADE.far,
1,
];
}
/** Own noninteractive, world-sized glow splats, arrival fades and style
* changes. Splats are sprites sized in metres: their pixel size and near fade
* follow the camera each frame it moves. */
export function createWildlifeDensity({
cesium: C,
viewer,
positionForCell = (cell) => C.Cartesian3.fromDegrees(cell.lon, cell.lat),
engine,
positionForCell = (cell) => geo.cartesian3.fromDegrees(cell.lon, cell.lat),
requestRender = () => {},
setTimer = (fn, delay) => setTimeout(fn, delay),
clearTimer = (timer) => clearTimeout(timer),
@@ -149,13 +177,8 @@ export function createWildlifeDensity({
documentRef = globalThis.document,
} = {}) {
let collection = null;
const nearFade = (width) =>
new C.NearFarScalar(
width * WILDLIFE_DENSITY_NEAR_FADE.near,
0,
width * WILDLIFE_DENSITY_NEAR_FADE.far,
1,
);
let removeFrame = null;
let lastView = null;
let metadata = new WeakMap();
const entries = new Map();
const fading = new Set();
@@ -168,30 +191,81 @@ export function createWildlifeDensity({
const fadeQueue = createWildlifeWorkQueue({ requestIdle, cancelIdle });
const styleQueue = createWildlifeWorkQueue({ requestIdle, cancelIdle });
// BillboardCollection does not implement Primitive's allowPicking option.
// Skipping its public pick update prevents its translucent quads from
// intercepting observations, including pixels with a faint glow alpha.
class DensityCollection extends C.BillboardCollection {
constructor() {
// Cesium's TRANSLUCENT-only option writes depth and occludes badges.
super();
this.allowPicking = false;
}
update(frameState) {
if (frameState.passes?.pick) return;
return super.update(frameState);
}
}
function color(entry) {
const mix = (i) => tint.low[i] + (tint.high[i] - tint.low[i]) * entry.rank;
return new C.Color(
return [
mix(0),
mix(1),
mix(2),
(wildlifeDensityIntensity(entry.rank) * entry.opacity * entry.fade) /
FADE_STEPS,
((wildlifeDensityIntensity(entry.rank) * entry.opacity * entry.fade) /
FADE_STEPS) *
entry.near,
];
}
// Pixel size and near fade of one splat for the camera `view`.
function layout({ position, width }, view) {
const pixels = wildlifeMetresToPixels(width, position, view);
const distance = Math.hypot(
position.x - view.position.x,
position.y - view.position.y,
position.z - view.position.z,
);
return {
scale: pixels === undefined ? 0 : pixels / SPRITE_SIZE,
show: pixels !== undefined,
near: wildlifeNearFarValue(wildlifeDensityNearFade(width), distance),
};
}
// Lays one splat out for `view`, writing only what changed; true when it
// wrote.
function place(entry, view) {
const { scale, show, near } = layout(entry.data, view);
let wrote = false;
if (entry.point.scale !== scale) {
entry.point.scale = scale;
wrote = true;
}
if (entry.point.show !== show) {
entry.point.show = show;
wrote = true;
}
if (entry.near !== near) {
entry.near = near;
entry.point.color = color(entry);
wrote = true;
}
return wrote;
}
const sameView = (a, b) =>
!!a &&
!!b &&
a.focalPx === b.focalPx &&
a.position.x === b.position.x &&
a.position.y === b.position.y &&
a.position.z === b.position.z &&
a.direction.x === b.direction.x &&
a.direction.y === b.direction.y &&
a.direction.z === b.direction.z;
// Lays every splat out for the current camera; commits what changed.
function follow() {
if (!collection || !entries.size) return false;
const view = wildlifeCameraView(engine);
if (sameView(view, lastView)) return false;
lastView = view;
let wrote = false;
for (const entry of entries.values()) wrote = place(entry, view) || wrote;
if (wrote) collection.commit();
return wrote;
}
const onFrame = () => follow();
// Commit what the data passes wrote before asking for a frame.
function render(reason) {
collection?.commit();
requestRender(reason);
}
function* styleWork() {
@@ -207,7 +281,7 @@ export function createWildlifeDensity({
if (next === tint) return;
tint = next;
if (entries.size)
styleQueue.start(styleWork(), () => requestRender('density-style'));
styleQueue.start(styleWork(), () => render('density-style'));
}
function onStyle(event) {
@@ -221,8 +295,18 @@ export function createWildlifeDensity({
function ensureCollection() {
if (collection) return;
collection = new DensityCollection();
viewer.scene.primitives.add(collection);
collection = createWildlifeSpriteBank(engine, {
id: WILDLIFE_DENSITY_SET_ID,
order: WILDLIFE_DENSITY_ORDER,
// Picks pass through the glow, including pixels with a faint alpha.
pickable: false,
// A depth-tested glow would hide under terrain and tiles; depth-free
// splats rely on the limb test below instead.
depthTest: 'always',
sprite: { atlas: DENSITY_ATLAS, sizePx: SPRITE_SIZE },
});
lastView = null;
removeFrame = engine.frames.preRender.addEventListener(onFrame);
if (!listening) {
tint = tintForStyle(documentRef?.documentElement?.dataset?.gevStyle);
eventTarget?.addEventListener?.('gev:style-change', onStyle);
@@ -247,7 +331,7 @@ export function createWildlifeDensity({
fadeTimer = setTimer(() => {
fadeTimer = null;
fadeQueue.start(fadeWork(), () => {
requestRender('density-fade');
render('density-fade');
scheduleFade();
});
}, FADE_STEP_MS);
@@ -314,7 +398,7 @@ export function createWildlifeDensity({
};
let sightingCount = 0;
let changed = false;
const height = viewer.camera?.positionCartographic?.height ?? 0;
const height = engine.camera.height();
const opacity = Math.max(
0,
Math.min(
@@ -323,9 +407,13 @@ export function createWildlifeDensity({
(ALTITUDE_FADE_END - ALTITUDE_FADE_START),
),
);
const camera = engine.camera.livePosition;
// Splats written in this pass are laid out for this camera.
const view = wildlifeCameraView(engine);
let created = false;
const insideHorizon = horizonTest(
viewer.camera?.positionWC,
C.Ellipsoid?.WGS84?.minimumRadius ?? 6_356_752.314245,
{ x: camera.x, y: camera.y, z: camera.z },
WGS84_MINIMUM_RADIUS,
);
const rankOf = wildlifeDensityRanks(cells);
for (const cell of cells) {
@@ -355,21 +443,24 @@ export function createWildlifeDensity({
const rank = rankOf(data.count);
let entry = entries.get(data.id);
if (!entry) {
created ||= !collection;
ensureCollection();
entry = { data, rank, opacity, fade: 1, seen: token, point: null };
const { scale, show, near } = layout(data, view);
entry = {
data,
rank,
opacity,
fade: 1,
near,
seen: token,
point: null,
};
entry.point = collection.add({
position: data.position,
image: DENSITY_IMAGE,
// Cesium packs width in 16 bits even for meter-sized billboards.
// Put the world diameter in its unrestricted scale attribute so
// continental kernels never clamp into thin vertical ellipses.
width: SPRITE_SIZE,
height: SPRITE_SIZE,
scale: data.width / SPRITE_SIZE,
sizeInMeters: true,
cell: 'glow',
scale,
show,
color: color(entry),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
translucencyByDistance: nearFade(data.width),
});
entries.set(data.id, entry);
fading.add(entry);
@@ -377,17 +468,11 @@ export function createWildlifeDensity({
changed = true;
} else {
entry.seen = token;
if (entry.data.position !== data.position) {
const moved =
entry.data.position !== data.position ||
entry.data.width !== data.width;
if (entry.data.position !== data.position)
entry.point.position = data.position;
operations.densityWrites++;
changed = true;
}
if (entry.data.width !== data.width) {
entry.point.scale = data.width / SPRITE_SIZE;
entry.point.translucencyByDistance = nearFade(data.width);
operations.densityWrites++;
changed = true;
}
if (entry.rank !== rank || entry.opacity !== opacity) {
entry.rank = rank;
entry.opacity = opacity;
@@ -396,6 +481,12 @@ export function createWildlifeDensity({
changed = true;
}
entry.data = data;
// A moved or resized splat is laid out again.
if (moved) {
place(entry, view);
operations.densityWrites++;
changed = true;
}
}
}
for (const [id, entry] of entries) {
@@ -413,12 +504,15 @@ export function createWildlifeDensity({
}
if (!entries.size && collection) clear(true);
else scheduleFade();
if (changed) requestRender('density');
// A new set is laid out for this camera; otherwise the next frame
// relays out whatever an earlier camera placed.
if (created && collection) lastView = view;
if (changed) render('density');
onOperations(operations);
return { sightingCount, changed, size: entries.size };
}
/** Release the texture atlas, billboards, cached coordinates and all work. */
/** Release the mover set, cached coordinates, frame listener and all work. */
function clear(keepMetadata = false) {
generation++;
fadeQueue.cancel();
@@ -428,12 +522,11 @@ export function createWildlifeDensity({
entries.clear();
fading.clear();
if (!keepMetadata) metadata = new WeakMap();
removeFrame?.();
removeFrame = null;
lastView = null;
if (collection) {
if (
viewer.scene.primitives.remove(collection) !== true &&
!collection.isDestroyed?.()
)
collection.destroy();
collection.destroy();
collection = null;
}
if (listening) {
+195 -114
View File
@@ -1,16 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { wildlifeGridCellBounds } from './gridViewport.js';
import {
createWildlifeDensity,
wildlifeDensityIntensity,
wildlifeDensityNearFade,
wildlifeDensityRanks,
wildlifeDensityKernel,
WILDLIFE_DENSITY_MAX_ALPHA,
WILDLIFE_DENSITY_NEAR_FADE,
WILDLIFE_DENSITY_ORDER,
WILDLIFE_DENSITY_OVERLAP,
WILDLIFE_DENSITY_SET_ID,
} from './density.js';
import {
wildlifeCameraView,
wildlifeMetresToPixels,
wildlifeNearFarValue,
} from './display.js';
import { WILDLIFE_WORK_UNIT } from './performance.js';
function cell(column, count = 1, options = {}) {
@@ -25,25 +34,37 @@ function cell(column, count = 1, options = {}) {
};
}
function harness({ documentStyle = 'normal', camera = {} } = {}) {
const counts = { positions: 0, writes: 0, created: 0, updates: 0 };
class BillboardCollection extends Cesium.BillboardCollection {
add(options) {
counts.created++;
const point = super.add(options);
return new Proxy(point, {
get: (target, key) => Reflect.get(target, key, target),
set(target, key, value) {
counts.writes++;
return Reflect.set(target, key, value, target);
},
});
}
update() {
counts.updates++;
}
}
const primitives = new Cesium.PrimitiveCollection();
const ID = WILDLIFE_DENSITY_SET_ID;
const degrees = (value) => (value * Math.PI) / 180;
/** A fake globe engine looking straight down at (lon, lat) from `height`. */
function engineAt({ lon = 20, lat = 40, height = 2_000_000 } = {}) {
const engine = createFakeEngine();
engine.fake.setPose({
longitude: degrees(lon),
latitude: degrees(lat),
height,
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
});
return engine;
}
function harness({ documentStyle = 'normal', engine = engineAt() } = {}) {
const counts = { positions: 0, writes: 0, sets: 0 };
// Every instance write the density makes, through the engine's mover set.
const createSet = engine.movers.createSet;
engine.movers.createSet = (options) => {
const set = createSet(options);
counts.sets++;
const write = set.set;
set.set = (index, fields) => {
counts.writes++;
return write(index, fields);
};
return set;
};
let handle = 0;
const timers = new Map();
const idle = new Map();
@@ -62,11 +83,10 @@ function harness({ documentStyle = 'normal', camera = {} } = {}) {
},
};
const density = createWildlifeDensity({
cesium: { ...Cesium, BillboardCollection },
viewer: { scene: { primitives }, camera },
engine,
positionForCell(row) {
counts.positions++;
return Cesium.Cartesian3.fromDegrees(row.lon, row.lat);
return geo.cartesian3.fromDegrees(row.lon, row.lat);
},
requestRender: (reason) => renders.push(reason),
setTimer(fn) {
@@ -97,8 +117,8 @@ function harness({ documentStyle = 'normal', camera = {} } = {}) {
flushIdle();
}
return {
engine,
density,
primitives,
counts,
renders,
timers,
@@ -107,6 +127,8 @@ function harness({ documentStyle = 'normal', camera = {} } = {}) {
eventTarget,
flushIdle,
fadeStep,
/** The density's mover set as the engine holds it. */
moverSet: () => engine.fake.moverSet(ID),
settle() {
while (timers.size || idle.size) {
fadeStep();
@@ -116,6 +138,8 @@ function harness({ documentStyle = 'normal', camera = {} } = {}) {
};
}
const alpha = (point) => point.color[3];
test('density intensity rises with view rank, is bounded, and leaves no background for zero data', () => {
for (const empty of [0, -1, null, undefined, NaN, Infinity])
assert.equal(wildlifeDensityIntensity(empty), 0);
@@ -156,7 +180,8 @@ test('an empty density snapshot creates no glow', () => {
cell(3, 1, { id: 'invalid' }),
]);
assert.equal(h.density.collection, null);
assert.equal(h.primitives.length, 0);
assert.equal(h.moverSet(), undefined);
assert.equal(h.engine.frames.preRender.numberOfListeners, 0);
assert.equal(h.timers.size, 0);
h.density.destroy();
});
@@ -179,77 +204,114 @@ test('uniform occupied cells make a continuous field with no visible lattice', (
assert.ok(modulation < 0.007, `${(100 * modulation).toFixed(3)}% modulation`);
});
test('each glow kernel fades on the GPU as the camera comes inside it, scaled to its own width', () => {
test('each glow kernel is sized in metres and fades as the camera comes inside it, scaled to its own width', () => {
const h = harness();
h.density.update([cell(0, 4), cell(1, 9)]);
const cells = [cell(0, 4), cell(1, 9)];
h.density.update(cells);
h.settle();
const collection = h.density.collection;
assert.equal(collection.length, 2);
for (let i = 0; i < collection.length; i++) {
const point = collection.get(i);
const width = point.scale * point.width;
const fade = point.translucencyByDistance;
assert.ok(fade, 'no screen-filling kernel when zoomed inside it');
const full = collection.handles.map(alpha);
const view = wildlifeCameraView(h.engine);
for (const [index, row] of cells.entries()) {
const point = collection.get(index);
const bounds = wildlifeGridCellBounds(row.id);
const metres =
WILDLIFE_DENSITY_OVERLAP *
((2 * Math.PI * 6378137) / 360) *
Math.max(
(bounds.east - bounds.west) * Math.cos((row.lat * Math.PI) / 180),
bounds.north - bounds.south,
);
const pixels = wildlifeMetresToPixels(metres, point.position, view);
assert.ok(Math.abs(point.scale * 128 - pixels) < 1e-6 * pixels);
const fade = wildlifeDensityNearFade(metres);
assert.ok(
Math.abs(fade.near - width * WILDLIFE_DENSITY_NEAR_FADE.near) < 1e-6,
Math.abs(fade[0] - metres * WILDLIFE_DENSITY_NEAR_FADE.near) < 1e-6,
);
assert.equal(fade.nearValue, 0);
assert.equal(fade[1], 0);
assert.ok(
Math.abs(fade.far - width * WILDLIFE_DENSITY_NEAR_FADE.far) < 1e-6,
Math.abs(fade[2] - metres * WILDLIFE_DENSITY_NEAR_FADE.far) < 1e-6,
);
assert.equal(fade.farValue, 1);
assert.equal(fade[3], 1);
assert.equal(wildlifeNearFarValue(fade, metres * 0.4), 0);
assert.equal(wildlifeNearFarValue(fade, metres * 3), 1);
}
// Inside the first kernel: it vanishes instead of filling the screen.
const commits = h.moverSet().commits;
h.engine.fake.setPose({
position: geo.cartesian3.fromDegrees(cells[0].lon, cells[0].lat, 50_000),
});
h.engine.fake.frame();
assert.equal(alpha(collection.get(0)), 0);
assert.ok(h.moverSet().commits > commits, 'a camera move relays out once');
assert.ok(
collection.get(0).scale > collection.get(1).scale,
'nearer kernels draw larger',
);
const settled = h.moverSet().commits;
h.engine.fake.frame();
assert.equal(h.moverSet().commits, settled, 'a still camera writes nothing');
h.engine.fake.setPose({
position: geo.cartesian3.fromDegrees(20, 40, 2_000_000),
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
});
h.engine.fake.frame();
assert.deepEqual(collection.handles.map(alpha), full);
h.density.destroy();
});
test('occupied grid counts produce unlabelled world-sized glow billboards that never enter Cesium picking', () => {
test('occupied grid counts produce unlabelled world-sized glow sprites that never enter picking', () => {
const h = harness();
const rows = [cell(0, 1), cell(1, 100), cell(2, 1000000)];
const result = h.density.update(rows);
assert.equal(result.sightingCount, 1000101);
assert.equal(h.primitives.length, 1);
const collection = h.density.collection;
assert.equal(collection.allowPicking, false);
assert.equal(collection.length, 3);
const first = collection.get(0);
const set = h.moverSet();
assert.equal(set.options.kind, 'sprite');
assert.equal(set.options.pickable, false);
assert.equal(set.options.depthTest, 'always');
assert.equal(set.options.order, WILDLIFE_DENSITY_ORDER);
assert.equal(set.store.count, 3);
assert.equal(
first.id,
set.store.pickIds[0],
undefined,
'density has no sighting identity or pick payload',
);
assert.equal(first.sizeInMeters, true);
assert.equal(first.scaleByDistance, undefined);
assert.match(decodeURIComponent(first.image), /radialGradient/);
assert.doesNotMatch(decodeURIComponent(first.image), /text|badge|raven/);
assert.equal(set.options.sprite.scaleByDistance, null);
const image = decodeURIComponent(set.options.sprite.atlas.url);
assert.match(image, /radialGradient/);
assert.doesNotMatch(image, /text|badge|raven/);
const first = h.density.collection.get(0);
const bounds = wildlifeGridCellBounds(rows[0].id);
const logicalHeight =
(((bounds.north - bounds.south) * Math.PI) / 180) * 6378137;
assert.ok(first.width * first.scale >= 1.5 * logicalHeight);
assert.equal(first.width, first.height);
assert.ok(
first.width <= 65536,
'encoded dimensions fit Cesium width packing',
first.scale * set.options.sprite.sizePx >=
wildlifeMetresToPixels(
1.5 * logicalHeight,
first.position,
wildlifeCameraView(h.engine),
),
);
assert.ok(
first.width * first.scale > 65536,
'continental splats retain their true footprint through scale',
);
collection.update({ passes: { pick: true } });
assert.equal(
h.counts.updates,
0,
'the real public pick update is suppressed',
);
collection.update({ passes: { render: true } });
assert.equal(h.counts.updates, 1);
const screen = h.engine.projector.worldToWindow(first.position);
assert.equal(h.engine.picker.pick(screen), undefined, 'picks pass through');
h.settle();
const rankOf = wildlifeDensityRanks(rows);
for (let i = 0; i < collection.length; i++)
for (let i = 0; i < h.density.collection.length; i++)
assert.ok(
Math.abs(
collection.get(i).color.alpha -
alpha(h.density.collection.get(i)) -
wildlifeDensityIntensity(rankOf(rows[i].cellCount)),
) < 1e-12,
);
for (let i = 0; i < set.store.count; i++)
assert.ok(
Math.abs(
set.store.colors[i * 4 + 3] - alpha(h.density.collection.get(i)),
) < 1e-6,
);
h.density.destroy();
});
@@ -259,25 +321,24 @@ test('progressive tile arrival fades only new cells and reuses existing glow wit
const two = cell(1, 10);
h.density.update([one]);
const first = h.density.collection.get(0);
const starting = first.color.alpha;
const starting = alpha(first);
h.fadeStep();
assert.ok(first.color.alpha > starting);
const middle = first.color.alpha;
assert.ok(alpha(first) > starting);
const middle = alpha(first);
h.density.update([one, two]);
// The arriving tile re-ranks the incumbent (alone it was the hotspot; now
// it is the median) but its fade progress is kept, not restarted.
const progress = middle / wildlifeDensityIntensity(1);
assert.ok(
Math.abs(first.color.alpha - wildlifeDensityIntensity(0.5) * progress) <
1e-12,
Math.abs(alpha(first) - wildlifeDensityIntensity(0.5) * progress) < 1e-12,
'a streamed tile re-ranks but does not restart incumbents',
);
assert.equal(h.density.collection.get(0), first);
assert.equal(h.density.size, 2);
h.settle();
const settled = first.color.alpha;
const settled = alpha(first);
h.density.update([one, two]);
assert.equal(first.color.alpha, settled);
assert.equal(alpha(first), settled);
h.density.update([two]);
assert.equal(h.density.size, 1);
assert.equal(h.density.collection.length, 1);
@@ -286,11 +347,11 @@ test('progressive tile arrival fades only new cells and reuses existing glow wit
assert.equal(
h.density.collection,
null,
'empty data releases the atlas and collection',
'empty data releases the atlas and mover set',
);
assert.equal(
h.primitives.length,
0,
h.moverSet(),
undefined,
'no full-view fill remains over empty data',
);
h.density.destroy();
@@ -321,32 +382,30 @@ test('visibility and antimeridian padding are applied on data/moveEnd work only'
west: 179,
east: -179,
};
// Over the antimeridian, so the wrapped kernel is in front of the limb.
h.engine.fake.setPose({
position: geo.cartesian3.fromDegrees(-179.3, wrapped.lat, 2_000_000),
});
assert.equal(h.density.update([wrapped], { boxes: [crossed] }).size, 1);
h.density.destroy();
});
test('density culls far-side cells and front-side kernels that would spill over the limb', () => {
const camera = {
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 5_000_000),
positionCartographic: { height: 5_000_000 },
};
const h = harness({ camera });
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
camera.positionWC,
);
const h = harness({
engine: engineAt({ lon: 0, lat: 0, height: 5_000_000 }),
});
const cells = [
cell(0, 1, { lat: 0, lon: 0 }),
cell(1, 2, { lat: 0, lon: 54 }),
cell(2, 4, { lat: 0, lon: 110 }),
];
assert.equal(
occluder.isPointVisible(Cesium.Cartesian3.fromDegrees(54, 0)),
h.engine.projector.horizonVisible(geo.cartesian3.fromDegrees(54, 0)),
true,
'the limb fixture has a visible centre but an overflowing footprint',
);
const result = h.density.update(cells, {
isPointVisible: (anchor) => occluder.isPointVisible(anchor),
isPointVisible: (anchor) => h.engine.projector.horizonVisible(anchor),
});
assert.equal(result.size, 1, 'only the fully front-side kernel can glow');
assert.equal(
@@ -357,27 +416,35 @@ test('density culls far-side cells and front-side kernels that would spill over
h.density.destroy();
});
test('density fades from regional detail to a clear whole-globe view without frame work or count loss', () => {
const camera = { positionCartographic: { height: 3_000_000 } };
const h = harness({ camera });
test('density fades from regional detail to a clear whole-globe view without idle frame work or count loss', () => {
const h = harness({ engine: engineAt({ height: 3_000_000 }) });
const at = (height) =>
h.engine.fake.setPose({
position: geo.cartesian3.fromDegrees(20, 40, height),
});
const cells = [cell(0, 12)];
h.density.update(cells);
h.settle();
const point = h.density.collection.get(0);
const fullAlpha = point.color.alpha;
camera.positionCartographic.height = 5_500_000;
const fullAlpha = alpha(point);
at(5_500_000);
h.density.update(cells);
assert.equal(h.density.collection.get(0), point);
assert.ok(Math.abs(point.color.alpha - fullAlpha / 2) < 1e-12);
assert.ok(Math.abs(alpha(point) - fullAlpha / 2) < 1e-12);
const writes = h.counts.writes;
h.density.update(cells);
assert.equal(h.counts.writes, writes, 'unchanged height writes nothing');
camera.positionCartographic.height = 8_000_000;
at(8_000_000);
assert.equal(h.density.update(cells).sightingCount, 12);
assert.equal(h.density.collection, null);
assert.equal(h.timers.size, 0, 'no invisible fade or frame loop');
assert.equal(h.timers.size, 0, 'no invisible fade');
assert.equal(
h.engine.frames.preRender.numberOfListeners,
0,
'no frame listener without glow',
);
const positions = h.counts.positions;
camera.positionCartographic.height = 3_000_000;
at(3_000_000);
h.density.update(cells);
h.settle();
assert.equal(
@@ -385,7 +452,7 @@ test('density fades from regional detail to a clear whole-globe view without fra
positions,
'altitude changes reuse decoded positions',
);
assert.equal(h.density.collection.get(0).color.alpha, fullAlpha);
assert.equal(alpha(h.density.collection.get(0)), fullAlpha);
h.density.destroy();
});
@@ -394,41 +461,46 @@ test('styles follow persisted mono/CRT and cockpit vision overrides, without add
h.density.update([cell(0)]);
h.settle();
const point = h.density.collection.get(0);
assert.equal(point.color.red, point.color.green);
assert.equal(point.color.green, point.color.blue);
const alpha = point.color.alpha;
const [red, green, blue] = [0, 1, 2];
assert.equal(point.color[red], point.color[green]);
assert.equal(point.color[green], point.color[blue]);
const before = alpha(point);
h.eventTarget.emit('gev:style-change', { style: 'retro' });
h.flushIdle();
assert.ok(point.color.green > point.color.red);
assert.ok(point.color[green] > point.color[red]);
h.eventTarget.emit('gev:vision-change', { cockpit: true, style: 'nvg' });
h.flushIdle();
assert.equal(point.color.red, point.color.green);
assert.equal(point.color[red], point.color[green]);
h.eventTarget.emit('gev:style-change', { style: 'normal' });
h.flushIdle();
assert.equal(
point.color.red,
point.color.green,
point.color[red],
point.color[green],
'cockpit vision wins while active',
);
h.eventTarget.emit('gev:vision-change', { cockpit: false, style: 'normal' });
h.flushIdle();
assert.notEqual(point.color.red, point.color.green);
assert.equal(point.color.alpha, alpha);
assert.notEqual(point.color[red], point.color[green]);
assert.equal(alpha(point), before);
const store = h.moverSet().store;
assert.ok(Math.abs(store.colors[1] - point.color[green]) < 1e-6);
h.density.destroy();
});
test('clear/disable and destroy release the atlas, primitives, coordinates, timers and listeners', () => {
test('clear/disable and destroy release the mover set, coordinates, timers and listeners', () => {
const h = harness();
const row = cell(0);
h.density.update([row]);
const old = h.density.collection;
const old = h.density.collection.set;
assert.equal(h.timers.size, 1);
assert.equal(h.engine.frames.preRender.numberOfListeners, 1);
h.fadeStep();
h.density.clear();
assert.equal(old.isDestroyed(), true);
assert.equal(h.density.collection, null);
assert.equal(h.density.size, 0);
assert.equal(h.primitives.length, 0);
assert.equal(h.moverSet(), undefined);
assert.equal(h.engine.frames.preRender.numberOfListeners, 0);
assert.equal(h.timers.size, 0);
assert.equal(h.idle.size, 0);
for (const values of h.listeners.values()) assert.equal(values.size, 0);
@@ -448,13 +520,16 @@ test('clear/disable and destroy release the atlas, primitives, coordinates, time
'obsolete data work cannot resurrect a destroyed layer',
);
assert.equal(h.density.collection, null);
assert.equal(h.primitives.length, 0);
assert.equal(h.moverSet(), undefined);
assert.equal(h.timers.size, 0);
assert.equal(h.idle.size, 0);
});
test('dense six-tile updates have a deterministic linear operation budget and no warmed writes/projections', (t) => {
const h = harness();
// High above the six tiles: most kernels are in front of the limb.
const h = harness({
engine: engineAt({ lon: -112.5, lat: 40, height: 7_900_000 }),
});
const cells = [];
for (let x = 1; x < 3; x++)
for (let y = 1; y < 4; y++)
@@ -479,7 +554,9 @@ test('dense six-tile updates have a deterministic linear operation budget and no
assert.equal(operations.densityVisits, 6144);
assert.equal(operations.densityMetadataBuilds, 6144);
assert.equal(operations.densityPositionBuilds, 6144);
assert.equal(operations.densityCreated, 6144);
const drawn = operations.densityCreated;
assert.ok(drawn > 5000, `${drawn} kernels drawn`);
assert.equal(h.moverSet().store.count, drawn);
const positions = h.counts.positions;
const writes = h.counts.writes;
let units = 0;
@@ -491,18 +568,22 @@ test('dense six-tile updates have a deterministic linear operation budget and no
while (!work.next().done) units++;
assert.deepEqual(operations, {
densityVisits: 6144,
densityRetirementVisits: 6144,
densityRetirementVisits: drawn,
densityMetadataBuilds: 0,
densityPositionBuilds: 0,
densityWrites: 0,
densityCreated: 0,
densityRemoved: 0,
});
assert.equal(units, Math.ceil(6144 / WILDLIFE_WORK_UNIT) * 2);
assert.equal(
units,
Math.ceil(6144 / WILDLIFE_WORK_UNIT) +
Math.ceil(drawn / WILDLIFE_WORK_UNIT),
);
assert.equal(h.counts.positions, positions);
assert.equal(h.counts.writes, writes);
t.diagnostic(
`6,144 occupied cells: ${units} bounded units, 12,288 linear visits, zero warmed metadata/Cartesian builds, projections or billboard writes`,
`6,144 occupied cells (${drawn} drawn): ${units} bounded units, ${6144 + drawn} linear visits, zero warmed metadata/Cartesian builds, projections or sprite writes`,
);
h.density.destroy();
});
+216
View File
@@ -0,0 +1,216 @@
// The wildlife layer's drawing on the globe contract (engine.movers and
// engine.lines), the same on every engine. A sprite bank keeps one sprite
// mover set and hands out handles whose index moves when another handle is
// removed; the set is rebuilt larger when it fills, or with a new atlas.
const FIRST_CAPACITY = 64;
const FIELDS = ['position', 'color', 'scale', 'cell', 'show'];
/** One sprite of a bank. Writes go to the bank's mover set at once. */
class WildlifeSprite {
constructor(bank, fields) {
this.bank = bank;
this.index = -1;
this.fields = fields;
}
get position() {
return this.fields.position;
}
set position(value) {
this.bank.write(this, { position: value });
}
get show() {
return this.fields.show;
}
set show(value) {
this.bank.write(this, { show: value === true });
}
get color() {
return this.fields.color;
}
set color(value) {
this.bank.write(this, { color: value });
}
get scale() {
return this.fields.scale;
}
set scale(value) {
this.bank.write(this, { scale: value });
}
get cell() {
return this.fields.cell;
}
set cell(value) {
this.bank.write(this, { cell: value });
}
get id() {
return this.fields.pickId;
}
}
/**
* A growing sprite mover set: `add(fields)` returns a handle with position,
* show, color, scale and cell properties; `id` becomes the instance pickId.
* Writes are stored at once and drawn after commit().
* @param {object} engine The globe engine.
* @param {{ id: string, order?: number, pickable?: boolean,
* depthTest?: 'normal'|'always'|{ disableWithinM: number },
* sprite: { atlas: object, sizePx: number, scaleByDistance?: number[] } }} options
*/
export function createWildlifeSpriteBank(engine, options) {
const handles = [];
let atlas = options.sprite.atlas;
let set = null;
let capacity = 0;
let dirty = false;
function full(handle) {
const fields = {};
for (const key of FIELDS)
if (handle.fields[key] !== undefined) fields[key] = handle.fields[key];
if ('pickId' in handle.fields) fields.pickId = handle.fields.pickId;
return fields;
}
function build(wanted) {
set?.destroy();
set = engine.movers.createSet({
id: options.id,
kind: 'sprite',
capacity: wanted,
order: options.order ?? 0,
depthTest: options.depthTest ?? 'always',
pickable: options.pickable === true,
sprite: { ...options.sprite, atlas },
});
capacity = wanted;
handles.forEach((handle, index) => set.set(index, full(handle)));
set.setCount(handles.length);
dirty = true;
}
function ensure(count) {
if (set && count <= capacity) return;
let next = Math.max(FIRST_CAPACITY, capacity);
while (next < count) next *= 2;
build(next);
}
return {
/** A new sprite: { position, cell?, color?, scale?, show?, id? }. */
add({ id, ...rest } = {}) {
const fields = { color: [1, 1, 1, 1], scale: 1, show: true, ...rest };
if (id !== undefined) fields.pickId = id;
const handle = new WildlifeSprite(this, fields);
ensure(handles.length + 1);
handle.index = handles.length;
handles.push(handle);
set.set(handle.index, full(handle));
set.setCount(handles.length);
dirty = true;
return handle;
},
/** Removes a sprite; the last one takes its index. */
remove(handle) {
if (handle?.bank !== this || handle.index < 0) return false;
const last = handles.pop();
if (last !== handle) {
handles[handle.index] = last;
last.index = handle.index;
set.set(last.index, full(last));
}
handle.index = -1;
set.setCount(handles.length);
dirty = true;
return true;
},
removeAll() {
for (const handle of handles) handle.index = -1;
handles.length = 0;
set?.setCount(0);
dirty = true;
},
write(handle, fields) {
Object.assign(handle.fields, fields);
if (handle.index < 0 || !set) return;
set.set(handle.index, fields);
dirty = true;
},
/** Rebuilds the set on another atlas (same cells), keeping every sprite. */
setAtlas(next) {
if (next === atlas) return false;
atlas = next;
if (set) build(capacity);
return true;
},
/** Uploads what changed since the last commit; false when nothing did. */
commit() {
if (!dirty || !set) return false;
dirty = false;
set.commit();
return true;
},
get length() {
return handles.length;
},
get(index) {
return handles[index];
},
get handles() {
return handles;
},
get set() {
return set;
},
destroy() {
for (const handle of handles) handle.index = -1;
handles.length = 0;
set?.destroy();
set = null;
capacity = 0;
dirty = false;
},
};
}
/**
* Cesium's NearFarScalar interpolation over [nearM, nearValue, farM,
* farValue]: over the squared camera distance, eased by ^0.2.
*/
export function wildlifeNearFarValue(scalar, distance) {
const [near, nearValue, far, farValue] = scalar;
const nearSq = near * near;
const farSq = far * far;
if (!(farSq > nearSq)) return distance <= near ? nearValue : farValue;
const t = Math.min(
1,
Math.max(0, (distance * distance - nearSq) / (farSq - nearSq)),
);
return nearValue + (farValue - nearValue) * t ** 0.2;
}
/**
* The CSS-pixel diameter of a camera-facing square `metres` wide at
* `position`, as Cesium draws a billboard sized in metres: its eye-space
* depth along the view direction sets the size. Undefined behind the camera.
*/
export function wildlifeMetresToPixels(metres, position, view) {
const dx = position.x - view.position.x;
const dy = position.y - view.position.y;
const dz = position.z - view.position.z;
const depth =
dx * view.direction.x + dy * view.direction.y + dz * view.direction.z;
if (!(depth > 0)) return undefined;
return (metres * view.focalPx) / depth;
}
/** The camera as wildlifeMetresToPixels reads it, from the engine. */
export function wildlifeCameraView(engine) {
const pose = engine.camera.pose();
const { height } = engine.host.size();
return {
position: pose.position,
direction: pose.direction,
focalPx: height / (2 * Math.tan(pose.fovy / 2)),
};
}
+2 -2
View File
@@ -80,7 +80,7 @@ test('Wildlife docs record queue failure isolation and the complete badge mappin
});
test('Wildlife docs separate density from real observation icons and define persistent selection', () => {
assert.match(section, /dedicated, nonpickable billboard/);
assert.match(section, /dedicated, nonpickable sprite/);
assert.match(section, /view-relative density ramp/);
assert.match(section, /four logical cell widths/);
assert.match(
@@ -107,7 +107,7 @@ test('Wildlife docs describe one source link, name declutter and globe glow limi
assert.match(section, /3,000 km/);
assert.match(section, /8,000 km/);
assert.match(section, /5% radius margin/);
assert.match(section, /add no per-frame work/);
assert.match(section, /only on frames where the camera moved/);
});
test('Wildlife docs cite the documented UTFGrid endpoint and the Acartia key request', () => {
+2 -4
View File
@@ -115,10 +115,8 @@ export function wildlifeGridTiles({ boxes, z } = {}) {
}
/** Unlike detail-row viewport queries, grids cover full globe views too. */
export function wildlifeGridViewportBoxes(viewer) {
const rectangle = viewer?.camera?.computeViewRectangle?.(
viewer?.scene?.globe?.ellipsoid,
);
export function wildlifeGridViewportBoxes(engine) {
const rectangle = engine?.camera?.viewRectangle();
if (!rectangle) return [];
return normalizedBoxes([
Object.fromEntries(
@@ -9,6 +9,7 @@ import {
WILDLIFE_GRID_LOGICAL_SIZE,
WILDLIFE_GRID_TILE_CAP,
} from './gridViewport.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
const globe = [
{ south: -85.0511287798066, west: -180, north: 85.0511287798066, east: 180 },
@@ -98,22 +99,21 @@ test('tile cap degrades zoom to preserve complete coverage and oversized direct
test('grid camera viewport supports globe and antimeridian without detail-query degree limits', () => {
const radians = (value) => (value * Math.PI) / 180;
const viewer = {
camera: {
computeViewRectangle: () => ({
south: radians(-80),
west: radians(120),
north: radians(80),
east: radians(-120),
}),
},
};
const boxes = wildlifeGridViewportBoxes(viewer);
const engine = createFakeEngine();
engine.fake.script('viewRectangle', {
south: radians(-80),
west: radians(120),
north: radians(80),
east: radians(-120),
});
const boxes = wildlifeGridViewportBoxes(engine);
assert.equal(boxes.length, 2);
assert.ok(Math.abs(boxes[0].west - 120) < 1e-8);
assert.equal(boxes[0].east, 180);
assert.equal(boxes[1].west, -180);
assert.deepEqual(wildlifeGridViewportBoxes({}), []);
engine.fake.script('viewRectangle', undefined);
assert.deepEqual(wildlifeGridViewportBoxes(engine), []);
assert.deepEqual(wildlifeGridViewportBoxes(null), []);
});
test('logical cell ids and bounds share geographic coverage and wrap antimeridian', () => {
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more