mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
refactor(flights): Cesium rendering behind a renderer interface
Task 10a. The flights logic no longer imports Cesium: every Cesium object it
built moved one for one into the shared aircraft renderer
(src/layers/aircraft/render/cesium.js; contract in render/contract.js), with
the flights' parameters in src/layers/flights/render/cesium.js, registered
for 'cesium' in the renderer registry and created at init(viewer, {engine}).
- Fleet: BillboardCollection + sprite-order slot; sprites stay Billboard
handles (field vocabulary documented for the Navara renderer).
- Models: fleet/tracked/preload glTF loads (asynchronous:false, MIX tint, IR
boost as `unlit` → the UNLIT CustomShader), the model collection, setPose
(same HPR frame with airplane.glb's 180° offset).
- Tracked: the ENU billboard Entity with its position/colour/rotation
callbacks, viewFrom, followed through viewer.trackedEntity + the one exact
tracked frame (kept as is; engine.camera.follow on Cesium would also end
the follow on any camera command, a behaviour change).
- Trail: the entity trail (data/trailRenderer, unchanged: its Primitive
predecessor failed below the mesh) and the live head segment.
- View: camera/scene reads, frame number, frustum and horizon tests,
projection, render requests, frame events, evidence render-loop hold.
- Clicks and Escape go through the engine's selection dispatcher (the
ScreenSpaceEventHandler and keydown listener are gone); the cross-layer
untrack watches the renderer's follow.
- Colours are engine-neutral Rgba (src/layers/shared/color.js), bit-equal to
the Cesium.Color constants they replace; NearFarScalar → nearFarScale.
- Cesium math in the logic → src/geo.
- check-import-directions: cesium only in render/cesium*.js and three/Navara
only in render/navara*.js for flights, aircraft and shared; a layer's
Navara renderer only through navaraMode's import().
Source-text tests updated with the moved code, same meaning:
- readLayerSource reads a layer's render/*.js and, for the aircraft layers,
aircraft/render/*.js (GLB-load, tracked-entity, CallbackProperty checks).
- modelScale.test: _normalBillboardScaleByDistance reads nearFarScale(...).
- spriteOrder.test: flights' enable calls fleet.restoreOrderOnEnable(); the
renderer wires it to restoreSpriteOrderOnEnable('flights', viewer).
- trackingClickGesture.test: the flights gesture rules are the dispatcher's
(selection gesture before select, click classifier before the empty-space
clear); flights answers the empty click with a user-origin clear.
- militaryAwareness.test: flights' hasContact guard reads fleet.visible().
- ownership.test: the services fixture names selection and renderer.
New tests: color, the Cesium aircraft renderer (contract, collections, pose,
loads, frustum/horizon, tracked entity, follow, trail head, adopted
collections), the flights logic over a plain-data renderer and a fake engine
(fleet, click-select, no-op on the tracked visual, empty-click release), the
boundary rule.
W1 replay vs 7c7422e2: counts, known objects, screen points (0 px), tracking
poses (0 m), requests and shots (same sha256) identical. Main chunk
2,890,982 B (+6.2 KB, +0.21 %: the renderer indirection).
This commit is contained in:
1 parent
8e702f0c3b
commit
7f13ef4a87
33 files changed
+1974
-748
No files matched your search
@@ -30,6 +30,18 @@ const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file);
|
|||||||
const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/;
|
const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/;
|
||||||
// Navara renders through three.js and its postprocessing peer.
|
// Navara renders through three.js and its postprocessing peer.
|
||||||
const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
|
const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
|
||||||
|
// Phase 3: the layers moved onto layer renderers (the *a tasks) keep each
|
||||||
|
// engine in their renderers: cesium only in render/cesium*.js, three and
|
||||||
|
// Navara only in render/navara*.js. The list grows with each moved layer.
|
||||||
|
const RENDERED_LAYER_DIRS = [
|
||||||
|
'src/layers/flights/',
|
||||||
|
'src/layers/aircraft/',
|
||||||
|
'src/layers/shared/',
|
||||||
|
];
|
||||||
|
const renderedLayer = (file) =>
|
||||||
|
RENDERED_LAYER_DIRS.some((dir) => file.startsWith(dir));
|
||||||
|
const cesiumLayerRenderer = (file) => /\/render\/cesium[^/]*\.js$/.test(file);
|
||||||
|
const navaraLayerRenderer = (file) => /\/render\/navara[^/]*\.js$/.test(file);
|
||||||
const renderer = (file) =>
|
const renderer = (file) =>
|
||||||
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
|
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
|
||||||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
|
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
|
||||||
@@ -123,6 +135,18 @@ export function checkImportDirections(root) {
|
|||||||
target = path.resolve(root, exported);
|
target = path.resolve(root, exported);
|
||||||
} else {
|
} else {
|
||||||
record.external.push(specifier);
|
record.external.push(specifier);
|
||||||
|
if (renderedLayer(file) && !tests(file)) {
|
||||||
|
if (cesiumPackage.test(specifier) && !cesiumLayerRenderer(file))
|
||||||
|
report(
|
||||||
|
file,
|
||||||
|
`cesium only in the layer's render/cesium*.js: ${specifier}`,
|
||||||
|
);
|
||||||
|
if (navaraPackage.test(specifier) && !navaraLayerRenderer(file))
|
||||||
|
report(
|
||||||
|
file,
|
||||||
|
`three and Navara only in the layer's render/navara*.js: ${specifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
file.startsWith('src/') &&
|
file.startsWith('src/') &&
|
||||||
(builtins.has(specifier) || specifier.startsWith('node:'))
|
(builtins.has(specifier) || specifier.startsWith('node:'))
|
||||||
@@ -157,6 +181,20 @@ export function checkImportDirections(root) {
|
|||||||
file,
|
file,
|
||||||
`The Navara engine loads only through a dynamic import(): ${to}`,
|
`The Navara engine loads only through a dynamic import(): ${to}`,
|
||||||
);
|
);
|
||||||
|
// Like the engine, a layer's Navara renderer reaches the app only
|
||||||
|
// through the Navara mode's import() (src/app/navaraMode.js).
|
||||||
|
if (
|
||||||
|
navaraLayerRenderer(to) &&
|
||||||
|
!navaraLayerRenderer(file) &&
|
||||||
|
!(
|
||||||
|
file === 'src/app/navaraMode.js' &&
|
||||||
|
analysis.dynamicImports.includes(specifier)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
report(
|
||||||
|
file,
|
||||||
|
`A Navara layer renderer loads only through the Navara mode's dynamic import(): ${to}`,
|
||||||
|
);
|
||||||
if (file.startsWith('src/') && to.startsWith('server/'))
|
if (file.startsWith('src/') && to.startsWith('server/'))
|
||||||
report(file, `Browser imports server: ${to}`);
|
report(file, `Browser imports server: ${to}`);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -321,6 +321,13 @@
|
|||||||
"src/layers/flights/testing.js",
|
"src/layers/flights/testing.js",
|
||||||
"src/layers/flights/evidence.js",
|
"src/layers/flights/evidence.js",
|
||||||
"src/layers/flights/ingestion.js",
|
"src/layers/flights/ingestion.js",
|
||||||
|
"src/layers/flights/render/cesium.js",
|
||||||
|
"src/layers/flights/renderer.test.mjs",
|
||||||
|
"src/layers/aircraft/render/cesium.test.mjs",
|
||||||
|
"src/layers/aircraft/render/cesium.js",
|
||||||
|
"src/layers/aircraft/render/contract.js",
|
||||||
|
"src/layers/shared/color.test.mjs",
|
||||||
|
"src/layers/shared/color.js",
|
||||||
"src/layers/flights/queries.js",
|
"src/layers/flights/queries.js",
|
||||||
"src/layers/flights/index.js",
|
"src/layers/flights/index.js",
|
||||||
"src/layers/flights/enrichment.js",
|
"src/layers/flights/enrichment.js",
|
||||||
|
|||||||
@@ -896,7 +896,8 @@
|
|||||||
"src/geo/developerError.js",
|
"src/geo/developerError.js",
|
||||||
"src/time/clock.js",
|
"src/time/clock.js",
|
||||||
"src/globe/contract.js",
|
"src/globe/contract.js",
|
||||||
"src/globe/cesium/engineFromViewer.js"
|
"src/globe/cesium/engineFromViewer.js",
|
||||||
|
"src/layers/shared/color.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -2587,7 +2588,11 @@
|
|||||||
"src/globe/surfaceDetail.js",
|
"src/globe/surfaceDetail.js",
|
||||||
"src/globe/navara/followGestures.js",
|
"src/globe/navara/followGestures.js",
|
||||||
"src/app/layers/directory.js",
|
"src/app/layers/directory.js",
|
||||||
"src/app/layers/renderers.js"
|
"src/app/layers/renderers.js",
|
||||||
|
"src/layers/aircraft/render/cesium.js",
|
||||||
|
"src/layers/flights/render/cesium.js",
|
||||||
|
"src/data/selectionDispatcher.js",
|
||||||
|
"src/layers/shared/color.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -3158,7 +3163,11 @@
|
|||||||
"src/globe/follow.js",
|
"src/globe/follow.js",
|
||||||
"src/globe/cesium/follow.js",
|
"src/globe/cesium/follow.js",
|
||||||
"src/app/layers/directory.js",
|
"src/app/layers/directory.js",
|
||||||
"src/app/layers/renderers.js"
|
"src/app/layers/renderers.js",
|
||||||
|
"src/layers/aircraft/render/cesium.js",
|
||||||
|
"src/data/selectionDispatcher.js",
|
||||||
|
"src/layers/shared/color.js",
|
||||||
|
"src/layers/flights/render/cesium.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { createCivilFlightLayer } from '../../layers/flights/index.js';
|
import { createCivilFlightLayer } from '../../layers/flights/index.js';
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as sprites from '../../data/spriteOrder.js';
|
import * as selection from '../../data/selectionDispatcher.js';
|
||||||
import * as trails from '../../data/trailRenderer.js';
|
|
||||||
import * as aircraftPresentation from '../../data/tr3bRegistry.js';
|
import * as aircraftPresentation from '../../data/tr3bRegistry.js';
|
||||||
import * as trackedCamera from '../../data/trackedCamera.js';
|
import * as trackedCamera from '../../data/trackedCamera.js';
|
||||||
import { applyTrackedCameraFrame } from '../../globe/cesium/follow.js';
|
import { rendererFor } from './renderers.js';
|
||||||
|
import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js';
|
||||||
import * as labels from '../../data/detectionDraw.js';
|
import * as labels from '../../data/detectionDraw.js';
|
||||||
import * as geoid from '../../data/geoid.js';
|
import * as geoid from '../../data/geoid.js';
|
||||||
import * as focus from '../../data/focusDeemphasis.js';
|
import * as focus from '../../data/focusDeemphasis.js';
|
||||||
@@ -20,6 +20,13 @@ export function createApplicationFlights({
|
|||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
resolveAsset = (url) =>
|
resolveAsset = (url) =>
|
||||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||||
|
// Test seam: the renderer factory (default: the engine's, from the registry).
|
||||||
|
renderer = {
|
||||||
|
create: ({ viewer, engine = null, adopt }) =>
|
||||||
|
engine
|
||||||
|
? rendererFor('flights', engine, { viewer, adopt })
|
||||||
|
: createFlightsCesiumRenderer(null, { viewer, adopt }),
|
||||||
|
},
|
||||||
}) {
|
}) {
|
||||||
const { groundFloor, meshFloor, groundSnap } = surface;
|
const { groundFloor, meshFloor, groundSnap } = surface;
|
||||||
return createCivilFlightLayer({
|
return createCivilFlightLayer({
|
||||||
@@ -27,10 +34,12 @@ export function createApplicationFlights({
|
|||||||
resolveAsset,
|
resolveAsset,
|
||||||
services: {
|
services: {
|
||||||
picking,
|
picking,
|
||||||
sprites,
|
selection,
|
||||||
trails,
|
// The engine's flights renderer from the registry (render/cesium.js on
|
||||||
|
// Cesium); a bare viewer without an engine (unit tests) draws on Cesium.
|
||||||
|
renderer,
|
||||||
aircraftPresentation,
|
aircraftPresentation,
|
||||||
camera: { ...trackedCamera, applyTrackedCameraFrame },
|
camera: trackedCamera,
|
||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
labels,
|
labels,
|
||||||
groundFloor,
|
groundFloor,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
// provider (`{ engine, unavailable() }` is the part it reads).
|
// provider (`{ engine, unavailable() }` is the part it reads).
|
||||||
|
|
||||||
import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
|
import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
|
||||||
|
import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js';
|
||||||
|
|
||||||
const ENGINE_KINDS = new Set(['cesium', 'navara']);
|
const ENGINE_KINDS = new Set(['cesium', 'navara']);
|
||||||
|
|
||||||
@@ -81,11 +82,19 @@ export const rendererFor = (layerId, engine, options) =>
|
|||||||
export const layerAvailableOn = (layerId, engineKind) =>
|
export const layerAvailableOn = (layerId, engineKind) =>
|
||||||
defaultRegistry.layerAvailableOn(layerId, engineKind);
|
defaultRegistry.layerAvailableOn(layerId, engineKind);
|
||||||
|
|
||||||
// Every application layer draws itself on Cesium today.
|
/** Cesium renderers extracted from their layers (render/cesium.js). */
|
||||||
|
export const CESIUM_LAYER_RENDERERS = Object.freeze({
|
||||||
|
flights: createFlightsCesiumRenderer,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The other application layers still draw themselves on Cesium.
|
||||||
registerLayerRenderers(
|
registerLayerRenderers(
|
||||||
'cesium',
|
'cesium',
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
APPLICATION_LAYER_DIRECTORY.map(({ id }) => [id, CESIUM_BUILTIN]),
|
APPLICATION_LAYER_DIRECTORY.map(({ id }) => [
|
||||||
|
id,
|
||||||
|
CESIUM_LAYER_RENDERERS[id] ?? CESIUM_BUILTIN,
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
CESIUM_BUILTIN,
|
CESIUM_BUILTIN,
|
||||||
|
CESIUM_LAYER_RENDERERS,
|
||||||
|
rendererFor,
|
||||||
createLayerAvailability,
|
createLayerAvailability,
|
||||||
createRendererRegistry,
|
createRendererRegistry,
|
||||||
layerAvailableOn,
|
layerAvailableOn,
|
||||||
@@ -54,6 +56,15 @@ test('the default registry: every layer on Cesium, none yet on Navara', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('flights has an extracted Cesium renderer; the others still draw themselves', () => {
|
||||||
|
assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), ['flights']);
|
||||||
|
const viewer = { scene: {}, camera: {} };
|
||||||
|
const renderer = rendererFor('flights', { kind: 'cesium' }, { viewer });
|
||||||
|
assert.equal(renderer.kind, 'cesium');
|
||||||
|
assert.equal(renderer.viewer, viewer);
|
||||||
|
assert.equal(rendererFor('satellites', { kind: 'cesium' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
test('the layer availability API: engine, isAvailable, available, unavailable', () => {
|
test('the layer availability API: engine, isAvailable, available, unavailable', () => {
|
||||||
const registry = createRendererRegistry();
|
const registry = createRendererRegistry();
|
||||||
registry.register('navara', { flights: () => ({}) });
|
registry.register('navara', { flights: () => ({}) });
|
||||||
|
|||||||
@@ -1366,8 +1366,9 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
|
|||||||
// disable() hides the collection but keeps the records, so a map lookup
|
// disable() hides the collection but keeps the records, so a map lookup
|
||||||
// alone would report a preserved subject as FRESH from hidden stale data.
|
// alone would report a preserved subject as FRESH from hidden stale data.
|
||||||
for (const [name, source, guard] of [
|
for (const [name, source, guard] of [
|
||||||
|
// Flights asks its renderer whether the fleet is shown (phase 3).
|
||||||
['flights', readLayerSource(new URL('./flights.js', import.meta.url)),
|
['flights', readLayerSource(new URL('./flights.js', import.meta.url)),
|
||||||
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
|
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_renderer\?\.fleet\.visible\(\)\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
|
||||||
['militaryFlights', readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
['militaryFlights', readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
||||||
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
|
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
|
||||||
['aisLiveVessels', readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
|
['aisLiveVessels', readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ function normalBillboardScaleByDistance(sourceFile) {
|
|||||||
const src = readLayerSource(path.join(ROOT, sourceFile));
|
const src = readLayerSource(path.join(ROOT, sourceFile));
|
||||||
const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/);
|
const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/);
|
||||||
assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`);
|
assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`);
|
||||||
const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/);
|
const scalar = fn[1].match(/(?:NearFarScalar|nearFarScale)\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/);
|
||||||
assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`);
|
assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`);
|
||||||
return scalar.slice(1).map(Number);
|
return scalar.slice(1).map(Number);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRendering as createFirmsRendering } from '../layers/firms/rendering.js';
|
import { createRendering as createFirmsRendering } from '../layers/firms/rendering.js';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import {
|
import {
|
||||||
registerSpriteCollection,
|
registerSpriteCollection,
|
||||||
restoreSpriteOrder,
|
restoreSpriteOrder,
|
||||||
@@ -110,7 +111,18 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r
|
|||||||
assert.deepEqual(calls, [viewer, viewer, viewer]);
|
assert.deepEqual(calls, [viewer, viewer, viewer]);
|
||||||
|
|
||||||
const firmsLayer = createFirmsHeatmapLayer({ id: 'firms', name: 'FIRMS' });
|
const firmsLayer = createFirmsHeatmapLayer({ id: 'firms', name: 'FIRMS' });
|
||||||
assert.match(flightsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('flights', viewer\)/);
|
// Flights draws through its renderer (phase 3): enable asks the fleet to
|
||||||
|
// restore its order, which the Cesium aircraft renderer wires to the shared
|
||||||
|
// restorer under the flights' sprite slot.
|
||||||
|
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\)/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
readFileSync(new URL('../layers/flights/render/cesium.js', import.meta.url), 'utf8'),
|
||||||
|
/spriteLayer: 'flights'/,
|
||||||
|
);
|
||||||
assert.match(aisLiveVesselsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('ais', activeViewer\)/);
|
assert.match(aisLiveVesselsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('ais', activeViewer\)/);
|
||||||
assert.match(firmsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('firms', viewer\)/);
|
assert.match(firmsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('firms', viewer\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -114,10 +114,18 @@ test('civilian and military click handlers apply duration only at the deselect b
|
|||||||
readLayerSource(new URL('./flights.js', import.meta.url)),
|
readLayerSource(new URL('./flights.js', import.meta.url)),
|
||||||
readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
|
||||||
];
|
];
|
||||||
for (const source of sources) {
|
// Military still owns its handler; flights moved to the selection
|
||||||
assert.match(source, /isTrackingSelectionGesture\(gesture\)[\s\S]+scene\.pick/);
|
// dispatcher (phase 3), whose source applies the same two rules: the
|
||||||
assert.match(source, /isTrackingClickGesture\(gesture\)[\s\S]+(?:parts\.\w+\.)?_clearTracking\([^)]*\{ origin: 'user' \}\)/);
|
// selection gesture before a select, the full click classifier before the
|
||||||
}
|
// empty-space deselect, which flights answers with a user-origin clear.
|
||||||
|
const military = sources[1];
|
||||||
|
assert.match(military, /isTrackingSelectionGesture\(gesture\)[\s\S]+scene\.pick/);
|
||||||
|
assert.match(military, /isTrackingClickGesture\(gesture\)[\s\S]+(?:parts\.\w+\.)?_clearTracking\([^)]*\{ origin: 'user' \}\)/);
|
||||||
|
const dispatcher = readFileSync(new URL('./selectionDispatcher.js', import.meta.url), 'utf8');
|
||||||
|
assert.match(dispatcher, /if \(!isTrackingSelectionGesture\(gesture\)\) return;[\s\S]+onSelect/);
|
||||||
|
assert.match(dispatcher, /if \(!isTrackingClickGesture\(gesture\)\) return;\s*for \(const owner of \[\.\.\.owners\.values\(\)\]\) owner\.onEmptyClick/);
|
||||||
|
assert.match(sources[0], /dispatcher\.register\('flights', \{[\s\S]+onEmptyClick: _onEmptyClick/);
|
||||||
|
assert.match(sources[0], /function _onEmptyClick\(\) \{[\s\S]+?_clearTracking\(false, \{ origin: 'user' \}\)/);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
sources[0],
|
sources[0],
|
||||||
/(?:flightState\.)?_trackedEntity = (?:flightState\.)?_viewer\.entities\.add\(\{\s*id:/,
|
/(?:flightState\.)?_trackedEntity = (?:flightState\.)?_viewer\.entities\.add\(\{\s*id:/,
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
// The aircraft renderer on Cesium (contract: ./contract.js): the Cesium
|
||||||
|
// objects the aircraft layers used to build themselves, moved here one for
|
||||||
|
// one — 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 (owner playtest 2026-08-16): 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]
|
||||||
|
* Test seam: drive existing (fake) collections instead of creating them.
|
||||||
|
* @returns {import('./contract.js').AircraftRenderer}
|
||||||
|
*/
|
||||||
|
export function createAircraftCesiumRenderer({
|
||||||
|
viewer,
|
||||||
|
engine = null,
|
||||||
|
spriteLayer,
|
||||||
|
headingOffsetDeg = 0,
|
||||||
|
adopt = null,
|
||||||
|
}) {
|
||||||
|
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,
|
||||||
|
}) =>
|
||||||
|
Cesium.Model.fromGltfAsync({
|
||||||
|
url,
|
||||||
|
asynchronous: false,
|
||||||
|
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,
|
||||||
|
}) =>
|
||||||
|
Cesium.Model.fromGltfAsync({
|
||||||
|
url,
|
||||||
|
asynchronous: false,
|
||||||
|
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) =>
|
||||||
|
Cesium.Model.fromGltfAsync({
|
||||||
|
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).
|
||||||
|
*/
|
||||||
|
setPose(model, position, headingDeg) {
|
||||||
|
scratchHpr.heading = Cesium.Math.toRadians(
|
||||||
|
(headingDeg || 0) + headingOffsetDeg,
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
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 { missingAircraftRendererMembers } from './contract.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 has every member of the contract', () => {
|
||||||
|
const { viewer } = stubViewer();
|
||||||
|
assert.deepEqual(missingAircraftRendererMembers(renderer(viewer)), []);
|
||||||
|
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('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.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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// 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 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:
|
||||||
|
//
|
||||||
|
// SpriteHandle (fleet.add) — one aircraft glyph:
|
||||||
|
// position (Cartesian3), show, rotation (screen angle, rad),
|
||||||
|
// scale, width, height (CSS px), color ({red, green, blue, alpha} with
|
||||||
|
// withAlpha(); an Rgba of src/layers/shared/color.js or what the engine
|
||||||
|
// stored), image (canvas or URL), scaleByDistance ({near, nearValue,
|
||||||
|
// far, farValue}), id. Reads return what was last written (color and
|
||||||
|
// 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).
|
||||||
|
// 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,
|
||||||
|
// gevDisplayPosition(), gevVisualPosition().
|
||||||
|
// Trail (trails.create): setPositions(Cartesian3[]), setVisible(bool),
|
||||||
|
// clear(), destroy(). TrailHead (trails.createHead): show (bool).
|
||||||
|
//
|
||||||
|
// The view: what the logic reads of the camera and the frame. `camera` is
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} AircraftView
|
||||||
|
* @property {object} camera
|
||||||
|
* @property {object} scene
|
||||||
|
* @property {() => number} frameNumber One value per rendered frame.
|
||||||
|
* @property {() => number} viewportHeightPx
|
||||||
|
* @property {() => ((position: object) => boolean)} frustumTester For this
|
||||||
|
* frame: is a point (1 m sphere) inside or on the view frustum.
|
||||||
|
* @property {() => { isPointVisible(position: object): boolean }} horizon
|
||||||
|
* For this frame: the ellipsoid horizon test.
|
||||||
|
* @property {(position: object, result?: object) => ({x: number, y: number} | undefined)} worldToWindow
|
||||||
|
* @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).
|
||||||
|
* @property {object} surfaceScene What sampleMeshFloorCells takes.
|
||||||
|
* @property {() => void} holdFrames Evidence capture: stop the engine's own
|
||||||
|
* frame loop (QA only).
|
||||||
|
* @property {() => void} closeFrameLatch Evidence capture, one task later:
|
||||||
|
* let manual renders start without waiting for the loop's next frame.
|
||||||
|
* @property {() => void} releaseFrames
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} AircraftRenderer
|
||||||
|
* @property {'cesium'|'navara'} kind
|
||||||
|
* @property {object|null} engine The globe engine (null in unit tests that
|
||||||
|
* drive a bare viewer).
|
||||||
|
* @property {AircraftView} view
|
||||||
|
* @property {object} fleet attach(), detach(), attached(), visible(),
|
||||||
|
* setVisible(bool), add(spec) → SpriteHandle, remove(sprite),
|
||||||
|
* restoreOrder(), restoreOrderOnEnable().
|
||||||
|
* @property {object} models live(), setVisible(bool), loadFleet(spec),
|
||||||
|
* loadTracked(spec), preload(url) → Promise<ModelHandle>, add(model),
|
||||||
|
* remove(model), destroy(model), setPose(model, position, headingDeg).
|
||||||
|
* spec: { url, minimumPixelSize, scale, 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.
|
||||||
|
* @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.
|
||||||
|
* @property {() => void} destroy
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The members every aircraft renderer has (shape checks, tests). */
|
||||||
|
export const AIRCRAFT_RENDERER_SHAPE = Object.freeze({
|
||||||
|
view: [
|
||||||
|
'frameNumber',
|
||||||
|
'viewportHeightPx',
|
||||||
|
'frustumTester',
|
||||||
|
'horizon',
|
||||||
|
'worldToWindow',
|
||||||
|
'requestRender',
|
||||||
|
'on',
|
||||||
|
'holdFrames',
|
||||||
|
'closeFrameLatch',
|
||||||
|
'releaseFrames',
|
||||||
|
],
|
||||||
|
fleet: [
|
||||||
|
'attach',
|
||||||
|
'detach',
|
||||||
|
'attached',
|
||||||
|
'visible',
|
||||||
|
'setVisible',
|
||||||
|
'add',
|
||||||
|
'remove',
|
||||||
|
'restoreOrder',
|
||||||
|
'restoreOrderOnEnable',
|
||||||
|
],
|
||||||
|
models: [
|
||||||
|
'live',
|
||||||
|
'setVisible',
|
||||||
|
'loadFleet',
|
||||||
|
'loadTracked',
|
||||||
|
'preload',
|
||||||
|
'add',
|
||||||
|
'remove',
|
||||||
|
'destroy',
|
||||||
|
'setPose',
|
||||||
|
],
|
||||||
|
tracked: [
|
||||||
|
'create',
|
||||||
|
'remove',
|
||||||
|
'isLive',
|
||||||
|
'setImage',
|
||||||
|
'setViewFrom',
|
||||||
|
'billboardSize',
|
||||||
|
],
|
||||||
|
follow: [
|
||||||
|
'start',
|
||||||
|
'refocus',
|
||||||
|
'release',
|
||||||
|
'isFollowing',
|
||||||
|
'current',
|
||||||
|
'onChanged',
|
||||||
|
],
|
||||||
|
trails: ['create', 'createHead', 'removeHead'],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The members of AIRCRAFT_RENDERER_SHAPE that `renderer` lacks
|
||||||
|
* (['group.member', …]); empty for a complete renderer.
|
||||||
|
*/
|
||||||
|
export function missingAircraftRendererMembers(renderer) {
|
||||||
|
const missing = [];
|
||||||
|
for (const key of ['kind', 'view', 'surfaceExclusions', 'destroy'])
|
||||||
|
if (renderer?.[key] === undefined) missing.push(key);
|
||||||
|
for (const [group, members] of Object.entries(AIRCRAFT_RENDERER_SHAPE))
|
||||||
|
for (const member of members)
|
||||||
|
if (typeof renderer?.[group]?.[member] !== 'function')
|
||||||
|
missing.push(`${group}.${member}`);
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
@@ -13,8 +13,9 @@ export function createController({
|
|||||||
flightState.feed._activeUpdateControllers.clear();
|
flightState.feed._activeUpdateControllers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _flightQuery(viewer) {
|
/** The feed query around the camera of `view` (a viewer or the renderer's view). */
|
||||||
const cartographic = viewer?.camera?.positionCartographic;
|
function _flightQuery(view) {
|
||||||
|
const cartographic = view?.camera?.positionCartographic;
|
||||||
return cartographic
|
return cartographic
|
||||||
? {
|
? {
|
||||||
latitude: geo.math.toDegrees(cartographic.latitude),
|
latitude: geo.math.toDegrees(cartographic.latitude),
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
|
||||||
import * as Cesium from 'cesium';
|
|
||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
import {
|
import {
|
||||||
ENRICH_MAX_INFLIGHT,
|
ENRICH_MAX_INFLIGHT,
|
||||||
@@ -181,32 +179,21 @@ export function createEnrichment({
|
|||||||
_refillAmbientBudget(Date.now());
|
_refillAmbientBudget(Date.now());
|
||||||
if (
|
if (
|
||||||
flightState._enrichAmbientBudget <= 0 ||
|
flightState._enrichAmbientBudget <= 0 ||
|
||||||
!flightState._viewer ||
|
!flightState._renderer?.fleet.visible()
|
||||||
!flightState._billboardCollection ||
|
|
||||||
!flightState._billboardCollection.show
|
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
try {
|
try {
|
||||||
const camera = flightState._viewer.camera;
|
const view = flightState._renderer.view;
|
||||||
const camPos = camera.positionWC;
|
const camPos = view.camera.positionWC;
|
||||||
const occluder = horizonOccluder(camera);
|
const occluder = view.horizon();
|
||||||
const cull = camera.frustum.computeCullingVolume(
|
const inFrustum = view.frustumTester();
|
||||||
camPos,
|
|
||||||
camera.directionWC,
|
|
||||||
camera.upWC,
|
|
||||||
);
|
|
||||||
const cand = [];
|
const cand = [];
|
||||||
for (const [icao24, bb] of flightState._billboards) {
|
for (const [icao24, bb] of flightState._billboards) {
|
||||||
if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session
|
if (flightState._enrichSeen.has(`t:${icao24}`)) continue; // answered / queued / negative this session
|
||||||
if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only
|
if (!/^[0-9a-f]{6}$/i.test(icao24)) continue; // adsbdb keys are 6-char hex only
|
||||||
if (flightState.records.data.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works)
|
if (flightState.records.data.get(icao24)?.onGround) continue; // ground traffic never spends ambient budget (click-to-enrich still works)
|
||||||
if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb
|
if (!bb.position || !occluder.isPointVisible(bb.position)) continue; // beyond the limb
|
||||||
geo.cartesian3.clone(bb.position, flightState._scratchModelBS.center);
|
if (!inFrustum(bb.position)) continue; // off-screen
|
||||||
if (
|
|
||||||
cull.computeVisibility(flightState._scratchModelBS) ===
|
|
||||||
Cesium.Intersect.OUTSIDE
|
|
||||||
)
|
|
||||||
continue; // off-screen
|
|
||||||
cand.push([
|
cand.push([
|
||||||
icao24,
|
icao24,
|
||||||
geo.cartesian3.distanceSquared(camPos, bb.position),
|
geo.cartesian3.distanceSquared(camPos, bb.position),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as geo from '../../geo/index.js';
|
||||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||||
import { FOCUS_EVIDENCE_DEV } from './policy.js';
|
import { FOCUS_EVIDENCE_DEV } from './policy.js';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export function createEvidence({
|
|||||||
cartesian.length >= 3 &&
|
cartesian.length >= 3 &&
|
||||||
cartesian.slice(0, 3).every(Number.isFinite)
|
cartesian.slice(0, 3).every(Number.isFinite)
|
||||||
) {
|
) {
|
||||||
return Cesium.Cartesian3.fromElements(
|
return geo.cartesian3.fromElements(
|
||||||
cartesian[0],
|
cartesian[0],
|
||||||
cartesian[1],
|
cartesian[1],
|
||||||
cartesian[2],
|
cartesian[2],
|
||||||
@@ -29,7 +29,7 @@ export function createEvidence({
|
|||||||
!Number.isFinite(record?.latitude)
|
!Number.isFinite(record?.latitude)
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
return Cesium.Cartesian3.fromDegrees(
|
return geo.cartesian3.fromDegrees(
|
||||||
record.longitude,
|
record.longitude,
|
||||||
record.latitude,
|
record.latitude,
|
||||||
Number.isFinite(record.altitudeM) ? record.altitudeM : 3_000,
|
Number.isFinite(record.altitudeM) ? record.altitudeM : 3_000,
|
||||||
@@ -39,16 +39,12 @@ export function createEvidence({
|
|||||||
/** Replace the real fleet with deterministic explicit-position contacts. */
|
/** Replace the real fleet with deterministic explicit-position contacts. */
|
||||||
|
|
||||||
function _setFocusEvidenceAircraft(records = []) {
|
function _setFocusEvidenceAircraft(records = []) {
|
||||||
if (
|
if (!FOCUS_EVIDENCE_DEV || !flightState._renderer?.fleet.attached())
|
||||||
!FOCUS_EVIDENCE_DEV ||
|
|
||||||
!flightState._billboardCollection ||
|
|
||||||
!flightState._viewer
|
|
||||||
)
|
|
||||||
return { ok: false, count: 0 };
|
return { ok: false, count: 0 };
|
||||||
if (flightState._trackedIcao) parts.tracking._clearTracking();
|
if (flightState._trackedIcao) parts.tracking._clearTracking();
|
||||||
parts.rendering._releaseModels();
|
parts.rendering._releaseModels();
|
||||||
for (const bb of flightState._billboards.values())
|
for (const bb of flightState._billboards.values())
|
||||||
flightState._billboardCollection.remove(bb);
|
flightState._renderer.fleet.remove(bb);
|
||||||
flightState._billboards.clear();
|
flightState._billboards.clear();
|
||||||
flightState.records.data.clear();
|
flightState.records.data.clear();
|
||||||
flightState._positionHistory.clear();
|
flightState._positionHistory.clear();
|
||||||
@@ -65,7 +61,7 @@ export function createEvidence({
|
|||||||
const klass = record.klass || 'airliner';
|
const klass = record.klass || 'airliner';
|
||||||
const altitudeM = Number.isFinite(record.altitudeM)
|
const altitudeM = Number.isFinite(record.altitudeM)
|
||||||
? record.altitudeM
|
? record.altitudeM
|
||||||
: Cesium.Cartographic.fromCartesian(position)?.height || 3_000;
|
: geo.cartographic.fromCartesian(position)?.height || 3_000;
|
||||||
const meta = {
|
const meta = {
|
||||||
callsign: String(record.callsign || id).toUpperCase(),
|
callsign: String(record.callsign || id).toUpperCase(),
|
||||||
altitude: altitudeM,
|
altitude: altitudeM,
|
||||||
@@ -83,14 +79,14 @@ export function createEvidence({
|
|||||||
flightState.records.data.set(id, meta);
|
flightState.records.data.set(id, meta);
|
||||||
flightState._cullPositions.delete(id);
|
flightState._cullPositions.delete(id);
|
||||||
flightState._focusEvidenceIds.add(id);
|
flightState._focusEvidenceIds.add(id);
|
||||||
const bb = flightState._billboardCollection.add({
|
const bb = flightState._renderer.fleet.add({
|
||||||
position,
|
position,
|
||||||
image: aircraftIcon(parts.rendering._iconKind(id, klass)),
|
image: aircraftIcon(parts.rendering._iconKind(id, klass)),
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
scale: parts.rendering._fleetBillboardScale(id, klass),
|
scale: parts.rendering._fleetBillboardScale(id, klass),
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
alignedAxis: geo.cartesian3.ZERO,
|
||||||
color: parts.rendering._fleetBillboardColor(id),
|
color: parts.rendering._fleetBillboardColor(id),
|
||||||
sizeInMeters: false,
|
sizeInMeters: false,
|
||||||
scaleByDistance: parts.rendering._normalBillboardScaleByDistance(),
|
scaleByDistance: parts.rendering._normalBillboardScaleByDistance(),
|
||||||
@@ -102,7 +98,7 @@ export function createEvidence({
|
|||||||
}
|
}
|
||||||
flightState.feed._count = flightState._billboards.size;
|
flightState.feed._count = flightState._billboards.size;
|
||||||
flightState._lastFleetTickMs = 0;
|
flightState._lastFleetTickMs = 0;
|
||||||
flightState._viewer.scene.requestRender?.();
|
flightState._renderer.view.requestRender();
|
||||||
return { ok: true, count: flightState.feed._count };
|
return { ok: true, count: flightState.feed._count };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,22 +125,18 @@ export function createEvidence({
|
|||||||
moved += 1;
|
moved += 1;
|
||||||
}
|
}
|
||||||
flightState._lastFleetTickMs = 0;
|
flightState._lastFleetTickMs = 0;
|
||||||
flightState._viewer?.scene?.requestRender?.();
|
flightState._renderer?.view.requestRender();
|
||||||
return { ok: true, moved };
|
return { ok: true, moved };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** JSON-safe visual snapshot for the evidence report. */
|
/** JSON-safe visual snapshot for the evidence report. */
|
||||||
|
|
||||||
function _focusEvidenceSnapshot() {
|
function _focusEvidenceSnapshot() {
|
||||||
if (!FOCUS_EVIDENCE_DEV || !flightState._viewer) return [];
|
const view = flightState._renderer?.view;
|
||||||
|
if (!FOCUS_EVIDENCE_DEV || !view) return [];
|
||||||
return [...flightState._focusEvidenceIds].map((id) => {
|
return [...flightState._focusEvidenceIds].map((id) => {
|
||||||
const bb = flightState._billboards.get(id);
|
const bb = flightState._billboards.get(id);
|
||||||
const screen = bb?.position
|
const screen = bb?.position ? view.worldToWindow(bb.position) : null;
|
||||||
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
|
||||||
flightState._viewer.scene,
|
|
||||||
bb.position,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
show: bb?.show === true,
|
show: bb?.show === true,
|
||||||
@@ -153,10 +145,7 @@ export function createEvidence({
|
|||||||
x: screen?.x ?? null,
|
x: screen?.x ?? null,
|
||||||
y: screen?.y ?? null,
|
y: screen?.y ?? null,
|
||||||
cameraDistanceM: bb?.position
|
cameraDistanceM: bb?.position
|
||||||
? Cesium.Cartesian3.distance(
|
? geo.cartesian3.distance(view.camera.positionWC, bb.position)
|
||||||
flightState._viewer.camera.positionWC,
|
|
||||||
bb.position,
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function createCivilFlightLayer({
|
|||||||
parts.ingestion = createIngestion({
|
parts.ingestion = createIngestion({
|
||||||
feed: flightState.feed,
|
feed: flightState.feed,
|
||||||
getQuery: (viewer) =>
|
getQuery: (viewer) =>
|
||||||
parts.controller._flightQuery(viewer || flightState._viewer),
|
parts.controller._flightQuery(viewer || flightState._renderer?.view),
|
||||||
applySnapshot,
|
applySnapshot,
|
||||||
setSourceLabel: (source) => {
|
setSourceLabel: (source) => {
|
||||||
layer.source = source;
|
layer.source = source;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { PLANE_MODEL_URL } from './policy.js';
|
import { PLANE_MODEL_URL } from './policy.js';
|
||||||
|
|
||||||
export function createLifecycle({
|
export function createLifecycle({
|
||||||
@@ -9,11 +8,6 @@ export function createLifecycle({
|
|||||||
resolveAsset,
|
resolveAsset,
|
||||||
}) {
|
}) {
|
||||||
const { clearFocusTarget } = services.focus;
|
const { clearFocusTarget } = services.focus;
|
||||||
const {
|
|
||||||
registerSpriteCollection,
|
|
||||||
restoreSpriteOrder,
|
|
||||||
restoreSpriteOrderOnEnable,
|
|
||||||
} = services.sprites;
|
|
||||||
const { onMilitaryLayerActiveChange } = services.militaryRegistry;
|
const { onMilitaryLayerActiveChange } = services.militaryRegistry;
|
||||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||||
const { ensureGeoidReady } = services.geoid;
|
const { ensureGeoidReady } = services.geoid;
|
||||||
@@ -22,7 +16,7 @@ export function createLifecycle({
|
|||||||
const methods = {
|
const methods = {
|
||||||
/** Configure the source before initialization; an active layer keeps its owner. */
|
/** Configure the source before initialization; an active layer keeps its owner. */
|
||||||
setSource(source) {
|
setSource(source) {
|
||||||
if (flightState._viewer)
|
if (flightState._renderer)
|
||||||
throw new Error('Configure the source before layer initialization');
|
throw new Error('Configure the source before layer initialization');
|
||||||
if (typeof source?.getSnapshot !== 'function')
|
if (typeof source?.getSnapshot !== 'function')
|
||||||
throw new TypeError('A snapshot source is required');
|
throw new TypeError('A snapshot source is required');
|
||||||
@@ -34,12 +28,14 @@ export function createLifecycle({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the flights layer.
|
* Initialize the flights layer.
|
||||||
* Creates the BillboardCollection, resets all state, and installs the
|
* Creates the engine's aircraft renderer (fleet sprites, models), resets
|
||||||
* click-to-track handler on the scene canvas.
|
* all state, and joins the engine's selection dispatcher.
|
||||||
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
|
* @param {object|null} viewer - The Cesium viewer (null on an engine
|
||||||
|
* without one).
|
||||||
|
* @param {{ engine?: object }} [context] - LayerLifecycle's context.
|
||||||
*/
|
*/
|
||||||
init(viewer) {
|
init(viewer, { engine = null } = {}) {
|
||||||
if (flightState._viewer)
|
if (flightState._renderer)
|
||||||
throw new Error('Flight layer is already initialized');
|
throw new Error('Flight layer is already initialized');
|
||||||
if (typeof flightState.feed._source?.getSnapshot !== 'function')
|
if (typeof flightState.feed._source?.getSnapshot !== 'function')
|
||||||
throw new TypeError('A snapshot source is required');
|
throw new TypeError('A snapshot source is required');
|
||||||
@@ -47,31 +43,22 @@ export function createLifecycle({
|
|||||||
flightState.lifetime = new AbortController();
|
flightState.lifetime = new AbortController();
|
||||||
clearFocusTarget('flights');
|
clearFocusTarget('flights');
|
||||||
flightState._focusEvidenceIds.clear();
|
flightState._focusEvidenceIds.clear();
|
||||||
flightState._viewer = viewer;
|
const renderer = services.renderer.create({ viewer, engine });
|
||||||
flightState._billboardCollection = new Cesium.BillboardCollection();
|
flightState._renderer = renderer;
|
||||||
viewer.scene.primitives.add(flightState._billboardCollection);
|
renderer.fleet.attach();
|
||||||
registerSpriteCollection('flights', flightState._billboardCollection);
|
|
||||||
flightState._modelCollection = new Cesium.PrimitiveCollection();
|
|
||||||
viewer.scene.primitives.add(flightState._modelCollection);
|
|
||||||
// Warm the glTF cache so the tracked plane's model instantiates instantly when first needed
|
// Warm the glTF cache so the tracked plane's model instantiates instantly when first needed
|
||||||
// (keeps the retained instance referenced; never rendered). Captured against this epoch so a
|
// (keeps the retained instance referenced; never rendered). Captured against this epoch so a
|
||||||
// destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle.
|
// destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle.
|
||||||
if (!flightState._preloadModel) {
|
if (!flightState._preloadModel) {
|
||||||
const epoch = flightState._modelEpoch;
|
const epoch = flightState._modelEpoch;
|
||||||
Cesium.Model.fromGltfAsync({
|
renderer.models
|
||||||
url: resolveAsset(PLANE_MODEL_URL),
|
.preload(resolveAsset(PLANE_MODEL_URL))
|
||||||
asynchronous: false,
|
|
||||||
})
|
|
||||||
.then((m) => {
|
.then((m) => {
|
||||||
if (epoch === flightState._modelEpoch) {
|
if (epoch === flightState._modelEpoch) {
|
||||||
flightState._preloadModel = m;
|
flightState._preloadModel = m;
|
||||||
flightState._planeModelLoaded = true;
|
flightState._planeModelLoaded = true;
|
||||||
} else {
|
} else {
|
||||||
try {
|
renderer.models.destroy(m);
|
||||||
m.destroy();
|
|
||||||
} catch {
|
|
||||||
/* gone */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -114,7 +101,7 @@ export function createLifecycle({
|
|||||||
parts.enrichment._ambientBudgetKnobs().ceil;
|
parts.enrichment._ambientBudgetKnobs().ceil;
|
||||||
flightState._enrichAmbientRefillAnchorMs = 0;
|
flightState._enrichAmbientRefillAnchorMs = 0;
|
||||||
|
|
||||||
parts.tracking._installClickHandler(viewer);
|
parts.tracking._installClickHandler();
|
||||||
|
|
||||||
// React to Military-layer toggles IMMEDIATELY (suppress/restore sweep)
|
// React to Military-layer toggles IMMEDIATELY (suppress/restore sweep)
|
||||||
// instead of waiting out the 30 s poll (M2).
|
// instead of waiting out the 30 s poll (M2).
|
||||||
@@ -124,21 +111,19 @@ export function createLifecycle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreSpriteOrder(viewer);
|
renderer.fleet.restoreOrder();
|
||||||
|
|
||||||
console.log('[Data:Flights] Initialized with billboard icons');
|
console.log('[Data:Flights] Initialized with billboard icons');
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the billboard collection and re-install the click handler.
|
* Show the fleet and re-join the selection dispatcher.
|
||||||
* @param {Cesium.Viewer} viewer
|
|
||||||
*/
|
*/
|
||||||
enable(viewer) {
|
enable() {
|
||||||
if (flightState._billboardCollection)
|
const renderer = flightState._renderer;
|
||||||
flightState._billboardCollection.show = true;
|
renderer?.fleet.setVisible(true);
|
||||||
holdContinuousRender('flights'); // per-frame animator (perf wave 2)
|
holdContinuousRender('flights'); // per-frame animator (perf wave 2)
|
||||||
if (flightState._modelCollection)
|
renderer?.models.setVisible(true);
|
||||||
flightState._modelCollection.show = true;
|
|
||||||
parts.tracking._setCockpitContactMode(
|
parts.tracking._setCockpitContactMode(
|
||||||
document.body.classList.contains('cockpit-mode'),
|
document.body.classList.contains('cockpit-mode'),
|
||||||
);
|
);
|
||||||
@@ -155,24 +140,25 @@ export function createLifecycle({
|
|||||||
/* geoid grid failed to load — baro path stays un-geoid-corrected until retried */
|
/* geoid grid failed to load — baro path stays un-geoid-corrected until retried */
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
parts.tracking._installClickHandler(viewer);
|
parts.tracking._installClickHandler();
|
||||||
registerPickOwner('flights', (pickedId) =>
|
registerPickOwner('flights', (pickedId) =>
|
||||||
flightState._billboards.has(pickedId),
|
flightState._billboards.has(pickedId),
|
||||||
);
|
);
|
||||||
// Force a fresh rotation pass on the first tick after re-enable
|
// Force a fresh rotation pass on the first tick after re-enable
|
||||||
flightState._lastCamPoseSig = '';
|
flightState._lastCamPoseSig = '';
|
||||||
if (!flightState._preRenderRemove && viewer?.scene) {
|
if (!flightState._preRenderRemove && renderer) {
|
||||||
flightState._preRenderRemove = viewer.scene.preRender.addEventListener(
|
flightState._preRenderRemove = renderer.view.on(
|
||||||
|
'preRender',
|
||||||
parts.rendering._fleetTick,
|
parts.rendering._fleetTick,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!flightState._trackedModelPreUpdateRemove && viewer?.scene) {
|
if (!flightState._trackedModelPreUpdateRemove && renderer) {
|
||||||
flightState._trackedModelPreUpdateRemove =
|
flightState._trackedModelPreUpdateRemove = renderer.view.on(
|
||||||
viewer.scene.preUpdate.addEventListener(
|
'preUpdate',
|
||||||
parts.rendering._updateTrackedModel,
|
parts.rendering._updateTrackedModel,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!flightState._moveEndRemove && viewer?.camera) {
|
if (!flightState._moveEndRemove && renderer) {
|
||||||
// Arrival polish (field test 2026-07-03: "planes look weird when you first
|
// Arrival polish (field test 2026-07-03: "planes look weird when you first
|
||||||
// come to them"): when a camera move SETTLES (voice fly-to, fast pan
|
// come to them"): when a camera move SETTLES (voice fly-to, fast pan
|
||||||
// release), force a full rotation pass on the very next frame. The
|
// release), force a full rotation pass on the very next frame. The
|
||||||
@@ -182,43 +168,30 @@ export function createLifecycle({
|
|||||||
// ROTATION_REFRESH_MS. Zeroing the tick throttle too means the pass runs
|
// ROTATION_REFRESH_MS. Zeroing the tick throttle too means the pass runs
|
||||||
// on the next preRender, not up to FLEET_DR_INTERVAL_MS later. Cost: one
|
// on the next preRender, not up to FLEET_DR_INTERVAL_MS later. Cost: one
|
||||||
// extra rotation pass per completed camera gesture — nothing per-frame.
|
// extra rotation pass per completed camera gesture — nothing per-frame.
|
||||||
flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener(
|
flightState._moveEndRemove = renderer.view.on('moveEnd', () => {
|
||||||
() => {
|
flightState._lastCamPoseSig = '';
|
||||||
flightState._lastCamPoseSig = '';
|
flightState._lastFleetTickMs = 0;
|
||||||
flightState._lastFleetTickMs = 0;
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
restoreSpriteOrderOnEnable('flights', viewer);
|
renderer?.fleet.restoreOrderOnEnable();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hide all flight billboards and tear down click/keyboard handlers.
|
* Hide all flight billboards and leave the selection dispatcher.
|
||||||
* Also clears any active flight tracking so the camera is released.
|
* Also clears any active flight tracking so the camera is released.
|
||||||
* @param {Cesium.Viewer} viewer
|
|
||||||
*/
|
*/
|
||||||
disable(viewer) {
|
disable() {
|
||||||
parts.controller._abortActiveUpdates();
|
parts.controller._abortActiveUpdates();
|
||||||
parts.tracking._cancelPendingTrackingRestore();
|
parts.tracking._cancelPendingTrackingRestore();
|
||||||
if (flightState._billboardCollection)
|
flightState._renderer?.fleet.setVisible(false);
|
||||||
flightState._billboardCollection.show = false;
|
|
||||||
releaseContinuousRender('flights');
|
releaseContinuousRender('flights');
|
||||||
parts.rendering._releaseModels();
|
parts.rendering._releaseModels();
|
||||||
if (flightState._modelCollection)
|
flightState._renderer?.models.setVisible(false);
|
||||||
flightState._modelCollection.show = false;
|
|
||||||
parts.tracking._clearTracking();
|
parts.tracking._clearTracking();
|
||||||
parts.tracking._destroyTrail();
|
parts.tracking._destroyTrail();
|
||||||
// Remove click handler + keydown listener while disabled to avoid
|
// Leave the selection dispatcher (clicks, Escape) while disabled to
|
||||||
// intercepting input when the layer is off
|
// avoid intercepting input when the layer is off
|
||||||
if (flightState._clickHandler) {
|
parts.tracking._removeClickHandler();
|
||||||
flightState._clickHandler.destroy();
|
|
||||||
flightState._clickHandler = null;
|
|
||||||
}
|
|
||||||
if (flightState._trackedEntityChangedRemove) {
|
|
||||||
flightState._trackedEntityChangedRemove();
|
|
||||||
flightState._trackedEntityChangedRemove = null;
|
|
||||||
}
|
|
||||||
document.removeEventListener('keydown', parts.tracking._onKeyDown);
|
|
||||||
unregisterPickOwner('flights');
|
unregisterPickOwner('flights');
|
||||||
if (flightState._preRenderRemove) {
|
if (flightState._preRenderRemove) {
|
||||||
flightState._preRenderRemove();
|
flightState._preRenderRemove();
|
||||||
@@ -235,9 +208,9 @@ export function createLifecycle({
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fully tear down the flights layer — remove primitives, handlers,
|
* Fully tear down the flights layer — remove its drawings, handlers,
|
||||||
* tracked entities, and clear all internal state maps.
|
* the tracked visual, and clear all internal state maps.
|
||||||
* @param {Cesium.Viewer} viewer
|
* @param {object|null} viewer - The viewer the layer was drawn on.
|
||||||
*/
|
*/
|
||||||
destroy(viewer) {
|
destroy(viewer) {
|
||||||
flightState.lifetime.abort();
|
flightState.lifetime.abort();
|
||||||
@@ -248,19 +221,11 @@ export function createLifecycle({
|
|||||||
parts.tracking._clearTracking();
|
parts.tracking._clearTracking();
|
||||||
parts.tracking._destroyTrail();
|
parts.tracking._destroyTrail();
|
||||||
parts.tracking._cancelPendingTrackingRestore();
|
parts.tracking._cancelPendingTrackingRestore();
|
||||||
if (flightState._clickHandler) {
|
parts.tracking._removeClickHandler();
|
||||||
flightState._clickHandler.destroy();
|
|
||||||
flightState._clickHandler = null;
|
|
||||||
}
|
|
||||||
if (flightState._trackedEntityChangedRemove) {
|
|
||||||
flightState._trackedEntityChangedRemove();
|
|
||||||
flightState._trackedEntityChangedRemove = null;
|
|
||||||
}
|
|
||||||
if (flightState._milActiveChangeUnsub) {
|
if (flightState._milActiveChangeUnsub) {
|
||||||
flightState._milActiveChangeUnsub();
|
flightState._milActiveChangeUnsub();
|
||||||
flightState._milActiveChangeUnsub = null;
|
flightState._milActiveChangeUnsub = null;
|
||||||
}
|
}
|
||||||
document.removeEventListener('keydown', parts.tracking._onKeyDown);
|
|
||||||
if (flightState._cockpitModeListener) {
|
if (flightState._cockpitModeListener) {
|
||||||
window.removeEventListener(
|
window.removeEventListener(
|
||||||
'gev:cockpit-mode-changed',
|
'gev:cockpit-mode-changed',
|
||||||
@@ -282,23 +247,13 @@ export function createLifecycle({
|
|||||||
flightState._moveEndRemove = null;
|
flightState._moveEndRemove = null;
|
||||||
}
|
}
|
||||||
parts.rendering._releaseModels();
|
parts.rendering._releaseModels();
|
||||||
if (flightState._billboardCollection) {
|
// Removing the model collection destroys it and its models.
|
||||||
viewer.scene.primitives.remove(flightState._billboardCollection);
|
flightState._renderer?.fleet.detach(viewer);
|
||||||
flightState._billboardCollection = null;
|
|
||||||
}
|
|
||||||
if (flightState._modelCollection) {
|
|
||||||
viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models
|
|
||||||
flightState._modelCollection = null;
|
|
||||||
}
|
|
||||||
flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy)
|
flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy)
|
||||||
flightState._modelPending.clear();
|
flightState._modelPending.clear();
|
||||||
flightState._modelGen.clear();
|
flightState._modelGen.clear();
|
||||||
if (flightState._preloadModel) {
|
if (flightState._preloadModel) {
|
||||||
try {
|
flightState._renderer?.models.destroy(flightState._preloadModel);
|
||||||
flightState._preloadModel.destroy();
|
|
||||||
} catch {
|
|
||||||
/* gone */
|
|
||||||
}
|
|
||||||
flightState._preloadModel = null;
|
flightState._preloadModel = null;
|
||||||
}
|
}
|
||||||
flightState._planeModelLoaded = false;
|
flightState._planeModelLoaded = false;
|
||||||
@@ -332,7 +287,8 @@ export function createLifecycle({
|
|||||||
coverage: flightState.feed._lastCoverage,
|
coverage: flightState.feed._lastCoverage,
|
||||||
};
|
};
|
||||||
parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling
|
parts.tracking._resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling
|
||||||
flightState._viewer = null;
|
flightState._renderer?.destroy();
|
||||||
|
flightState._renderer = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -297,8 +297,9 @@ export function createMotion({
|
|||||||
/** Resolve the selected aircraft's actual rendered square extent this frame. */
|
/** Resolve the selected aircraft's actual rendered square extent this frame. */
|
||||||
|
|
||||||
function _trackedFocusSizePx(icao24, position) {
|
function _trackedFocusSizePx(icao24, position) {
|
||||||
const camera = flightState._viewer?.camera;
|
const view = flightState._renderer?.view;
|
||||||
const scene = flightState._viewer?.scene;
|
const camera = view?.camera;
|
||||||
|
const scene = view?.scene;
|
||||||
if (!camera?.positionWC || !position || !scene) return 28;
|
if (!camera?.positionWC || !position || !scene) return 28;
|
||||||
const rangeM = geo.cartesian3.distance(camera.positionWC, position);
|
const rangeM = geo.cartesian3.distance(camera.positionWC, position);
|
||||||
if (parts.rendering._modelOwnsVisual(icao24)) {
|
if (parts.rendering._modelOwnsVisual(icao24)) {
|
||||||
@@ -309,12 +310,12 @@ export function createMotion({
|
|||||||
baseScale: spec.scale,
|
baseScale: spec.scale,
|
||||||
nativeRadiusM: spec.nativeRadiusM,
|
nativeRadiusM: spec.nativeRadiusM,
|
||||||
rangeM,
|
rangeM,
|
||||||
viewportHeightPx: scene.canvas.clientHeight,
|
viewportHeightPx: view.viewportHeightPx(),
|
||||||
fovyRad: camera.frustum.fovy,
|
fovyRad: camera.frustum.fovy,
|
||||||
maximumPixelSize: TRACKED_MODEL_MAX_PX,
|
maximumPixelSize: TRACKED_MODEL_MAX_PX,
|
||||||
});
|
});
|
||||||
const focalLengthPx =
|
const focalLengthPx =
|
||||||
scene.canvas.clientHeight / (2 * Math.tan(camera.frustum.fovy / 2));
|
view.viewportHeightPx() / (2 * Math.tan(camera.frustum.fovy / 2));
|
||||||
const projectedDiameterPx =
|
const projectedDiameterPx =
|
||||||
(2 * spec.nativeRadiusM * scale * focalLengthPx) / rangeM;
|
(2 * spec.nativeRadiusM * scale * focalLengthPx) / rangeM;
|
||||||
return Math.max(
|
return Math.max(
|
||||||
@@ -323,16 +324,16 @@ export function createMotion({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const billboard = flightState._trackedEntity?.billboard;
|
const billboard = flightState._renderer.tracked.billboardSize(
|
||||||
const time = flightState._viewer.clock.currentTime;
|
flightState._trackedEntity,
|
||||||
const width = billboard?.width?.getValue(time) ?? 28;
|
);
|
||||||
const height = billboard?.height?.getValue(time) ?? 28;
|
const width = billboard?.width ?? 28;
|
||||||
|
const height = billboard?.height ?? 28;
|
||||||
const scale =
|
const scale =
|
||||||
billboard?.scale?.getValue(time) ??
|
billboard?.scale ??
|
||||||
(CLASS_SCALE_2D[flightState.records.data.get(icao24)?.klass] || 1);
|
(CLASS_SCALE_2D[flightState.records.data.get(icao24)?.klass] || 1);
|
||||||
const scaleByDistance =
|
const scaleByDistance =
|
||||||
billboard?.scaleByDistance?.getValue(time) ??
|
billboard?.scaleByDistance ?? TRACKED_BILLBOARD_SCALE_BY_DISTANCE;
|
||||||
TRACKED_BILLBOARD_SCALE_BY_DISTANCE;
|
|
||||||
const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM);
|
const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM);
|
||||||
return Math.max(width, height) * scale * distanceScale;
|
return Math.max(width, height) * scale * distanceScale;
|
||||||
}
|
}
|
||||||
@@ -347,7 +348,7 @@ export function createMotion({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function _trackedDisplayPosition(icao24) {
|
function _trackedDisplayPosition(icao24) {
|
||||||
const frame = flightState._viewer?.scene?.frameState?.frameNumber ?? -1;
|
const frame = flightState._renderer?.view.frameNumber() ?? -1;
|
||||||
if (
|
if (
|
||||||
frame === flightState._cachedDRFrame &&
|
frame === flightState._cachedDRFrame &&
|
||||||
icao24 === flightState._drReconcileIcao
|
icao24 === flightState._drReconcileIcao
|
||||||
@@ -434,8 +435,8 @@ export function createMotion({
|
|||||||
publishFocusTargetFromCachedPosition({
|
publishFocusTargetFromCachedPosition({
|
||||||
ownerLayer: 'flights',
|
ownerLayer: 'flights',
|
||||||
id: icao24,
|
id: icao24,
|
||||||
scene: flightState._viewer?.scene,
|
scene: flightState._renderer?.view.scene,
|
||||||
camera: flightState._viewer?.camera,
|
camera: flightState._renderer?.view.camera,
|
||||||
displayPosition: flightState._cachedDRPosition,
|
displayPosition: flightState._cachedDRPosition,
|
||||||
widthPx: focusSizePx,
|
widthPx: focusSizePx,
|
||||||
heightPx: focusSizePx,
|
heightPx: focusSizePx,
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { createEnrichment } from './enrichment.js';
|
|||||||
function services() {
|
function services() {
|
||||||
const names = [
|
const names = [
|
||||||
'picking',
|
'picking',
|
||||||
'sprites',
|
'selection',
|
||||||
'trails',
|
'renderer',
|
||||||
'aircraftPresentation',
|
'aircraftPresentation',
|
||||||
'camera',
|
'camera',
|
||||||
'militaryRegistry',
|
'militaryRegistry',
|
||||||
|
|||||||
@@ -6,13 +6,18 @@ export {
|
|||||||
MISSING_POLL_LIMIT,
|
MISSING_POLL_LIMIT,
|
||||||
ERROR_BACKOFF_INTERVAL,
|
ERROR_BACKOFF_INTERVAL,
|
||||||
} from './recordPolicy.js';
|
} from './recordPolicy.js';
|
||||||
import * as Cesium from 'cesium';
|
import {
|
||||||
|
CYAN,
|
||||||
|
constantRgba,
|
||||||
|
nearFarScale,
|
||||||
|
rgbaFromCss,
|
||||||
|
} from '../shared/color.js';
|
||||||
|
|
||||||
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
||||||
|
|
||||||
/** Amber tint for known-military aircraft rendered by this layer (matches the military layer's icon color). */
|
/** Amber tint for known-military aircraft rendered by this layer (matches the military layer's icon color). */
|
||||||
|
|
||||||
export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800');
|
export const MIL_TINT = Object.freeze(rgbaFromCss('#FFB800'));
|
||||||
|
|
||||||
// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes
|
// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes
|
||||||
// taxiing and landing") -----------------------------------------------------------
|
// taxiing and landing") -----------------------------------------------------------
|
||||||
@@ -107,18 +112,19 @@ export const MODEL_COLOR_BLEND_AMOUNT = 0.94;
|
|||||||
|
|
||||||
export const MODEL_BELLY_OFFSET_NATIVE = 6.719;
|
export const MODEL_BELLY_OFFSET_NATIVE = 6.719;
|
||||||
|
|
||||||
export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0);
|
export const CYAN_TRANSPARENT = constantRgba(
|
||||||
|
CYAN.red,
|
||||||
|
CYAN.green,
|
||||||
|
CYAN.blue,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
export const COCKPIT_CONTACT_SIZE_PX = 6;
|
export const COCKPIT_CONTACT_SIZE_PX = 6;
|
||||||
|
|
||||||
export const COCKPIT_CIVILIAN_COLOR =
|
export const COCKPIT_CIVILIAN_COLOR = Object.freeze(rgbaFromCss('#DCEEFF'));
|
||||||
Cesium.Color.fromCssColorString('#DCEEFF');
|
|
||||||
|
|
||||||
export const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
|
export const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = Object.freeze(
|
||||||
1000,
|
nearFarScale(1000, 3.0, 8000000, 0.5),
|
||||||
3.0,
|
|
||||||
8000000,
|
|
||||||
0.5,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export function createQueries({
|
|||||||
const { tr3bTypeLabel, tr3bAircraftClass } = services.aircraftPresentation;
|
const { tr3bTypeLabel, tr3bAircraftClass } = services.aircraftPresentation;
|
||||||
const { formatFlightLevel } = services.labels;
|
const { formatFlightLevel } = services.labels;
|
||||||
const { isMilitaryIcao } = services.militaryRegistry;
|
const { isMilitaryIcao } = services.militaryRegistry;
|
||||||
const { applyTrackedCameraFrame } = services.camera;
|
|
||||||
const {
|
const {
|
||||||
setFocusDeemphasisParams,
|
setFocusDeemphasisParams,
|
||||||
getFocusDeemphasisParams,
|
getFocusDeemphasisParams,
|
||||||
@@ -303,7 +302,7 @@ export function createQueries({
|
|||||||
// Restore fleet billboards (the horizon-cull pass re-asserts next tick), but NEVER the
|
// Restore fleet billboards (the horizon-cull pass re-asserts next tick), but NEVER the
|
||||||
// tracked plane's own fleet billboard — its tracked entity is the visual, so re-showing it
|
// tracked plane's own fleet billboard — its tracked entity is the visual, so re-showing it
|
||||||
// here would double-image the tracked plane when 3D is turned off mid-track.
|
// here would double-image the tracked plane when 3D is turned off mid-track.
|
||||||
if (flightState._billboardCollection)
|
if (flightState._renderer?.fleet.attached())
|
||||||
for (const [icao, bb] of flightState._billboards) {
|
for (const [icao, bb] of flightState._billboards) {
|
||||||
if (icao !== flightState._trackedIcao) bb.show = true;
|
if (icao !== flightState._trackedIcao) bb.show = true;
|
||||||
}
|
}
|
||||||
@@ -389,11 +388,7 @@ export function createQueries({
|
|||||||
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
|
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
|
||||||
*/
|
*/
|
||||||
getDetectableObjects(options = {}) {
|
getDetectableObjects(options = {}) {
|
||||||
if (
|
if (!flightState._renderer?.fleet.visible()) return [];
|
||||||
!flightState._billboardCollection ||
|
|
||||||
!flightState._billboardCollection.show
|
|
||||||
)
|
|
||||||
return [];
|
|
||||||
// Compute a stride that evenly samples the billboard map.
|
// Compute a stride that evenly samples the billboard map.
|
||||||
// seed shifts the starting offset so successive calls can sample
|
// seed shifts the starting offset so successive calls can sample
|
||||||
// different aircraft without shuffling the underlying Map order.
|
// different aircraft without shuffling the underlying Map order.
|
||||||
@@ -536,12 +531,7 @@ export function createQueries({
|
|||||||
* @returns {Array<{id: string, icao24: string, callsign: string|null, position: Cesium.Cartesian3, distance: number, aircraftClass: string|null, altitudeM: number|null, velocityMps: number|null, track: number|null}>}
|
* @returns {Array<{id: string, icao24: string, callsign: string|null, position: Cesium.Cartesian3, distance: number, aircraftClass: string|null, altitudeM: number|null, velocityMps: number|null, track: number|null}>}
|
||||||
*/
|
*/
|
||||||
getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) {
|
getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) {
|
||||||
if (
|
if (!center || !flightState._renderer?.fleet.visible()) return [];
|
||||||
!center ||
|
|
||||||
!flightState._billboardCollection ||
|
|
||||||
!flightState._billboardCollection.show
|
|
||||||
)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
const limit = Number.isFinite(maxCount)
|
const limit = Number.isFinite(maxCount)
|
||||||
? Math.max(1, Math.floor(maxCount))
|
? Math.max(1, Math.floor(maxCount))
|
||||||
@@ -625,8 +615,7 @@ export function createQueries({
|
|||||||
*/
|
*/
|
||||||
hasContact(icao24) {
|
hasContact(icao24) {
|
||||||
if (
|
if (
|
||||||
!flightState._billboardCollection ||
|
!flightState._renderer?.fleet.visible() ||
|
||||||
!flightState._billboardCollection.show ||
|
|
||||||
flightState._billboards.size === 0
|
flightState._billboards.size === 0
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
@@ -640,7 +629,7 @@ export function createQueries({
|
|||||||
|
|
||||||
getAllPositions(maxCount = 500) {
|
getAllPositions(maxCount = 500) {
|
||||||
if (
|
if (
|
||||||
!flightState._billboardCollection ||
|
!flightState._renderer?.fleet.attached() ||
|
||||||
flightState._billboards.size === 0
|
flightState._billboards.size === 0
|
||||||
)
|
)
|
||||||
return [];
|
return [];
|
||||||
@@ -695,8 +684,7 @@ export function createQueries({
|
|||||||
*/
|
*/
|
||||||
getAnalystRecords(maxCount = 2000) {
|
getAnalystRecords(maxCount = 2000) {
|
||||||
if (
|
if (
|
||||||
!flightState._billboardCollection ||
|
!flightState._renderer?.fleet.visible() ||
|
||||||
!flightState._billboardCollection.show ||
|
|
||||||
flightState.records.data.size === 0
|
flightState.records.data.size === 0
|
||||||
)
|
)
|
||||||
return [];
|
return [];
|
||||||
@@ -792,7 +780,7 @@ export function createQueries({
|
|||||||
if (
|
if (
|
||||||
!icao24 ||
|
!icao24 ||
|
||||||
flightState._cockpitContactMode ||
|
flightState._cockpitContactMode ||
|
||||||
!flightState._viewer ||
|
!flightState._renderer ||
|
||||||
!flightState._trackedEntity
|
!flightState._trackedEntity
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
@@ -800,19 +788,13 @@ export function createQueries({
|
|||||||
if (!flightState._billboards.has(id)) id = id.toLowerCase();
|
if (!flightState._billboards.has(id)) id = id.toLowerCase();
|
||||||
if (
|
if (
|
||||||
id !== flightState._trackedIcao ||
|
id !== flightState._trackedIcao ||
|
||||||
!flightState._viewer.entities?.contains?.(flightState._trackedEntity) ||
|
!flightState._renderer.tracked.isLive(flightState._trackedEntity) ||
|
||||||
flightState._viewer.trackedEntity !== flightState._trackedEntity
|
!flightState._renderer.follow.isFollowing(flightState._trackedEntity)
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
flightState._trackedCameraFrameStop?.();
|
flightState._trackedCameraFrameStop?.();
|
||||||
flightState._viewer.camera.cancelFlight();
|
|
||||||
flightState._viewer.trackedEntity = flightState._trackedEntity;
|
|
||||||
flightState._trackedCameraFrameStop =
|
flightState._trackedCameraFrameStop =
|
||||||
applyTrackedCameraFrame(
|
flightState._renderer.follow.refocus(flightState._trackedEntity);
|
||||||
flightState._viewer,
|
|
||||||
flightState._trackedEntity,
|
|
||||||
flightState._trackedEntity.viewFrom,
|
|
||||||
) || null;
|
|
||||||
parts.tracking._publishTrackedSelection(id, origin);
|
parts.tracking._publishTrackedSelection(id, origin);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -886,9 +868,10 @@ export function createQueries({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
takeFrameClock(startMs = 1_000_000_000) {
|
takeFrameClock(startMs = 1_000_000_000) {
|
||||||
if (!flightState._viewer || !Number.isFinite(startMs))
|
const view = flightState._renderer?.view;
|
||||||
|
if (!view || !Number.isFinite(startMs))
|
||||||
return { ok: false, nowMs: null };
|
return { ok: false, nowMs: null };
|
||||||
flightState._viewer.useDefaultRenderLoop = false;
|
view.holdFrames();
|
||||||
setFocusEvidenceNowMs(startMs);
|
setFocusEvidenceNowMs(startMs);
|
||||||
flightState._lastFleetTickMs = startMs - FLEET_DR_INTERVAL_MS;
|
flightState._lastFleetTickMs = startMs - FLEET_DR_INTERVAL_MS;
|
||||||
// Cross a browser task boundary, then close Cesium's pending-loop
|
// Cross a browser task boundary, then close Cesium's pending-loop
|
||||||
@@ -896,8 +879,7 @@ export function createQueries({
|
|||||||
// while manual evidence renders can begin without waiting on VSYNC.
|
// while manual evidence renders can begin without waiting on VSYNC.
|
||||||
return new Promise((resolve) =>
|
return new Promise((resolve) =>
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (flightState._viewer?._cesiumWidget)
|
flightState._renderer?.view.closeFrameLatch();
|
||||||
flightState._viewer._cesiumWidget._renderLoopRunning = false;
|
|
||||||
resolve({ ok: true, nowMs: startMs });
|
resolve({ ok: true, nowMs: startMs });
|
||||||
}, 0),
|
}, 0),
|
||||||
);
|
);
|
||||||
@@ -907,8 +889,7 @@ export function createQueries({
|
|||||||
},
|
},
|
||||||
releaseFrameClock() {
|
releaseFrameClock() {
|
||||||
setFocusEvidenceNowMs(null);
|
setFocusEvidenceNowMs(null);
|
||||||
if (flightState._viewer)
|
flightState._renderer?.view.releaseFrames();
|
||||||
flightState._viewer.useDefaultRenderLoop = true;
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
// The flights logic draws only through its renderer (phase 3 Task 10a): 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. This is the seam
|
||||||
|
// the Navara renderer (Task 10b) plugs into.
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as geo from '../../geo/index.js';
|
||||||
|
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||||
|
import { createApplicationFlights } from '../../app/layers/flights.js';
|
||||||
|
import { defaultSurface } from '../../data/surfaceServices.js';
|
||||||
|
import * as militaryRegistry from '../../data/militaryRegistry.js';
|
||||||
|
import { openSkySnapshot } from '../../sources/live/aircraft.js';
|
||||||
|
import { missingAircraftRendererMembers } from '../aircraft/render/contract.js';
|
||||||
|
import { Rgba } from '../shared/color.js';
|
||||||
|
|
||||||
|
globalThis.window ||= {};
|
||||||
|
globalThis.window.dispatchEvent ||= () => true;
|
||||||
|
globalThis.window.addEventListener ||= () => {};
|
||||||
|
globalThis.window.removeEventListener ||= () => {};
|
||||||
|
globalThis.CustomEvent ||= class CustomEvent extends Event {
|
||||||
|
constructor(type, init) {
|
||||||
|
super(type);
|
||||||
|
this.detail = init?.detail;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
globalThis.document ||= {
|
||||||
|
body: { classList: { contains: () => false } },
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A renderer of plain records, for the logic alone. */
|
||||||
|
function recordingRenderer(engine) {
|
||||||
|
const calls = [];
|
||||||
|
const sprites = new Set();
|
||||||
|
const camera = {
|
||||||
|
positionWC: geo.cartesian3.fromDegrees(-97.7, 30.2, 400_000),
|
||||||
|
positionCartographic: geo.cartographic.fromDegrees(-97.7, 30.2, 400_000),
|
||||||
|
rightWC: geo.cartesian3.create(1, 0, 0),
|
||||||
|
upWC: geo.cartesian3.create(0, 0, 1),
|
||||||
|
directionWC: geo.cartesian3.create(0, -1, 0),
|
||||||
|
heading: 0,
|
||||||
|
pitch: -Math.PI / 2,
|
||||||
|
roll: 0,
|
||||||
|
frustum: { fovy: 1 },
|
||||||
|
};
|
||||||
|
const scene = {
|
||||||
|
camera,
|
||||||
|
canvas: { clientHeight: 768 },
|
||||||
|
frameState: { frameNumber: 1 },
|
||||||
|
};
|
||||||
|
let trackedSeq = 0;
|
||||||
|
let following;
|
||||||
|
const renderer = {
|
||||||
|
kind: 'record',
|
||||||
|
engine,
|
||||||
|
calls,
|
||||||
|
sprites,
|
||||||
|
view: {
|
||||||
|
camera,
|
||||||
|
scene,
|
||||||
|
surfaceOwner: engine,
|
||||||
|
surfaceScene: engine,
|
||||||
|
frameNumber: () => 1,
|
||||||
|
viewportHeightPx: () => 768,
|
||||||
|
frustumTester: () => () => true,
|
||||||
|
horizon: () => ({ isPointVisible: () => true }),
|
||||||
|
worldToWindow: () => ({ x: 0, y: 0 }),
|
||||||
|
requestRender: () => calls.push(['requestRender']),
|
||||||
|
on: () => () => {},
|
||||||
|
holdFrames() {},
|
||||||
|
closeFrameLatch() {},
|
||||||
|
releaseFrames() {},
|
||||||
|
},
|
||||||
|
fleet: {
|
||||||
|
shown: false,
|
||||||
|
attach: () => calls.push(['attach']),
|
||||||
|
detach: () => calls.push(['detach']),
|
||||||
|
attached: () => true,
|
||||||
|
visible: () => renderer.fleet.shown,
|
||||||
|
setVisible(visible) {
|
||||||
|
renderer.fleet.shown = visible;
|
||||||
|
},
|
||||||
|
add(spec) {
|
||||||
|
const sprite = { ...spec };
|
||||||
|
sprites.add(sprite);
|
||||||
|
return sprite;
|
||||||
|
},
|
||||||
|
remove: (sprite) => sprites.delete(sprite),
|
||||||
|
restoreOrder() {},
|
||||||
|
restoreOrderOnEnable() {},
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
live: () => true,
|
||||||
|
setVisible() {},
|
||||||
|
loadFleet: () => Promise.reject(new Error('no models here')),
|
||||||
|
loadTracked: () => Promise.reject(new Error('no models here')),
|
||||||
|
preload: () => Promise.reject(new Error('no models here')),
|
||||||
|
add() {},
|
||||||
|
remove() {},
|
||||||
|
destroy() {},
|
||||||
|
setPose() {},
|
||||||
|
},
|
||||||
|
tracked: {
|
||||||
|
create(spec) {
|
||||||
|
const handle = { id: `tracked-${++trackedSeq}`, spec };
|
||||||
|
calls.push(['tracked.create', handle.id]);
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
remove: (handle) => calls.push(['tracked.remove', handle.id]),
|
||||||
|
isLive: () => true,
|
||||||
|
setImage() {},
|
||||||
|
setViewFrom(handle, offset) {
|
||||||
|
handle.viewFrom = offset;
|
||||||
|
},
|
||||||
|
billboardSize: () => null,
|
||||||
|
},
|
||||||
|
follow: {
|
||||||
|
start(handle) {
|
||||||
|
following = handle;
|
||||||
|
calls.push(['follow.start', handle.id]);
|
||||||
|
return () => calls.push(['follow.frameStop']);
|
||||||
|
},
|
||||||
|
refocus: (handle) => renderer.follow.start(handle),
|
||||||
|
release() {
|
||||||
|
following = undefined;
|
||||||
|
calls.push(['follow.release']);
|
||||||
|
},
|
||||||
|
isFollowing: (handle) => following === handle,
|
||||||
|
current: () => following,
|
||||||
|
onChanged: () => () => {},
|
||||||
|
},
|
||||||
|
trails: {
|
||||||
|
create: () => ({
|
||||||
|
setPositions() {},
|
||||||
|
setVisible() {},
|
||||||
|
clear() {},
|
||||||
|
destroy() {},
|
||||||
|
}),
|
||||||
|
createHead: ({ id }) => ({ id, show: true }),
|
||||||
|
removeHead() {},
|
||||||
|
},
|
||||||
|
surfaceExclusions: () => [],
|
||||||
|
destroy: () => calls.push(['destroy']),
|
||||||
|
};
|
||||||
|
return renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotOf(ids) {
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
return openSkySnapshot({
|
||||||
|
time: nowSec,
|
||||||
|
states: ids.map((id, index) => [
|
||||||
|
id,
|
||||||
|
`TST${index} `,
|
||||||
|
'United States',
|
||||||
|
nowSec,
|
||||||
|
nowSec,
|
||||||
|
-97.6 + index * 0.1,
|
||||||
|
30.3,
|
||||||
|
10_668,
|
||||||
|
false,
|
||||||
|
250,
|
||||||
|
95,
|
||||||
|
5,
|
||||||
|
null,
|
||||||
|
10_700,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
5,
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the flights logic draws, selects and follows only through its renderer', async () => {
|
||||||
|
const engine = createFakeEngine();
|
||||||
|
let renderer = null;
|
||||||
|
const layer = createApplicationFlights({
|
||||||
|
surface: defaultSurface,
|
||||||
|
source: {
|
||||||
|
label: 'Fixture aircraft',
|
||||||
|
getSnapshot: async () => snapshotOf(['abc123', 'def456']),
|
||||||
|
},
|
||||||
|
militaryRegistry,
|
||||||
|
renderer: {
|
||||||
|
create: ({ viewer, engine: given }) => {
|
||||||
|
assert.equal(viewer, null, 'no viewer on an engine without one');
|
||||||
|
renderer = recordingRenderer(given);
|
||||||
|
return renderer;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
layer.init(null, { engine });
|
||||||
|
assert.deepEqual(missingAircraftRendererMembers(renderer), []);
|
||||||
|
layer.enable();
|
||||||
|
try {
|
||||||
|
await layer.update(null);
|
||||||
|
assert.equal(renderer.sprites.size, 2, 'one sprite per contact');
|
||||||
|
const [sprite] = renderer.sprites;
|
||||||
|
assert.ok(sprite.color instanceof Rgba, 'colours are engine-neutral');
|
||||||
|
assert.equal(sprite.id, 'abc123');
|
||||||
|
assert.equal(layer.getAllPositions().length, 2);
|
||||||
|
|
||||||
|
// A click on a contact: the engine's pick, through the dispatcher.
|
||||||
|
engine.fake.script('pick', () => ({ id: 'def456' }));
|
||||||
|
engine.fake.script('drillPick', () => [{ id: 'def456' }]);
|
||||||
|
const click = () => {
|
||||||
|
engine.fake.input('down', { position: { x: 10, y: 10 } });
|
||||||
|
engine.fake.input('up', { position: { x: 10, y: 10 } });
|
||||||
|
engine.fake.input('click', { position: { x: 10, y: 10 } });
|
||||||
|
};
|
||||||
|
click();
|
||||||
|
assert.equal(layer.getTrackedInfo()?.icao24, 'def456');
|
||||||
|
assert.deepEqual(
|
||||||
|
renderer.calls.filter(
|
||||||
|
([name]) => name.startsWith('tracked') || name.startsWith('follow'),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
['tracked.create', 'tracked-1'],
|
||||||
|
['follow.start', 'tracked-1'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clicking the tracked visual itself is a no-op.
|
||||||
|
engine.fake.script('pick', () => ({ id: 'tracked-1' }));
|
||||||
|
engine.fake.script('drillPick', () => [{ id: 'tracked-1' }]);
|
||||||
|
click();
|
||||||
|
assert.equal(layer.getTrackedInfo()?.icao24, 'def456');
|
||||||
|
|
||||||
|
// Empty space releases the follow and removes the tracked visual.
|
||||||
|
engine.fake.script('pick', () => undefined);
|
||||||
|
engine.fake.script('drillPick', () => []);
|
||||||
|
click();
|
||||||
|
assert.equal(layer.getTrackedInfo(), null);
|
||||||
|
assert.ok(
|
||||||
|
renderer.calls.some(([name]) => name === 'follow.release') &&
|
||||||
|
renderer.calls.some(
|
||||||
|
([name, id]) => name === 'tracked.remove' && id === 'tracked-1',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
layer.disable();
|
||||||
|
layer.destroy(null);
|
||||||
|
}
|
||||||
|
assert.ok(renderer.calls.some(([name]) => name === 'destroy'));
|
||||||
|
});
|
||||||
+74
-166
@@ -1,4 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as geo from '../../geo/index.js';
|
||||||
|
import { WHITE, CYAN, nearFarScale } from '../shared/color.js';
|
||||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||||
import { civilAircraftModelSpec } from './modelSpec.js';
|
import { civilAircraftModelSpec } from './modelSpec.js';
|
||||||
@@ -7,7 +8,6 @@ import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
|||||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||||
import {
|
import {
|
||||||
cameraPoseSignature,
|
cameraPoseSignature,
|
||||||
horizonOccluder,
|
|
||||||
screenProjectedRotation,
|
screenProjectedRotation,
|
||||||
} from '../../data/iconOrientation.js';
|
} from '../../data/iconOrientation.js';
|
||||||
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
MODEL_PROX_ADD_M,
|
MODEL_PROX_ADD_M,
|
||||||
MODEL_ALL_KEEP_M,
|
MODEL_ALL_KEEP_M,
|
||||||
MODEL_PROX_KEEP_M,
|
MODEL_PROX_KEEP_M,
|
||||||
MODEL_HEADING_OFFSET_DEG,
|
|
||||||
IR_RELOAD_BATCH,
|
IR_RELOAD_BATCH,
|
||||||
MODEL_MIN_PX,
|
MODEL_MIN_PX,
|
||||||
TRACKED_MODEL_MIN_PX,
|
TRACKED_MODEL_MIN_PX,
|
||||||
@@ -58,7 +57,7 @@ export function createRendering({
|
|||||||
* Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */
|
* Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */
|
||||||
|
|
||||||
function _fleetBillboardColor(icao24) {
|
function _fleetBillboardColor(icao24) {
|
||||||
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
|
return isMilitaryIcao(icao24) ? MIL_TINT : WHITE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */
|
/** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */
|
||||||
@@ -97,11 +96,11 @@ export function createRendering({
|
|||||||
function _normalBillboardScaleByDistance() {
|
function _normalBillboardScaleByDistance() {
|
||||||
// Preserve the established close-range 3× scale. Any smaller owner-visible
|
// Preserve the established close-range 3× scale. Any smaller owner-visible
|
||||||
// default belongs in a separate evidence-backed proposal.
|
// default belongs in a separate evidence-backed proposal.
|
||||||
return new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5);
|
return nearFarScale(1000, 3.0, 8000000, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _cockpitBillboardScaleByDistance() {
|
function _cockpitBillboardScaleByDistance() {
|
||||||
return new Cesium.NearFarScalar(1000, 1.15, 8000000, 0.65);
|
return nearFarScale(1000, 1.15, 8000000, 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sprite kind for one contact's billboard. Identity for every aircraft except
|
/** Sprite kind for one contact's billboard. Identity for every aircraft except
|
||||||
@@ -161,8 +160,8 @@ export function createRendering({
|
|||||||
/** Model tint, mirroring the billboard color rules. */
|
/** Model tint, mirroring the billboard color rules. */
|
||||||
|
|
||||||
function _modelColor(icao24) {
|
function _modelColor(icao24) {
|
||||||
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
|
if (icao24 === flightState._trackedIcao) return CYAN;
|
||||||
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
|
return isMilitaryIcao(icao24) ? MIL_TINT : WHITE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
||||||
@@ -177,7 +176,8 @@ export function createRendering({
|
|||||||
function _modelRegimeActive() {
|
function _modelRegimeActive() {
|
||||||
if (!flightState._models3dEnabled) return false;
|
if (!flightState._models3dEnabled) return false;
|
||||||
const h =
|
const h =
|
||||||
flightState._viewer?.camera?.positionCartographic?.height ?? Infinity;
|
flightState._renderer?.view.camera?.positionCartographic?.height ??
|
||||||
|
Infinity;
|
||||||
return h < MODEL_ALT_CEIL_M;
|
return h < MODEL_ALT_CEIL_M;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,30 +212,13 @@ export function createRendering({
|
|||||||
: MODEL_PROX_KEEP_M;
|
: MODEL_PROX_KEEP_M;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into
|
/** World model transform from a position + course heading (pitch/roll 0;
|
||||||
* `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one
|
* ENU frame), written by the renderer into the model's OWN transform —
|
||||||
* mutable matrix object. `Model.modelMatrix` is a plain field (not a cloning setter); Cesium
|
* never a shared scratch (see the renderer's setPose: one shared matrix
|
||||||
* clones it per frame in updateModelMatrix(). Sharing a single scratch made every model render at
|
* stacked every model on the last-written plane, "flickering like mad"). */
|
||||||
* the LAST-written transform — all stacked on one plane — and, once the tracked model wrote the
|
|
||||||
* scratch every frame, the stack point oscillated frame-to-frame: the "flickering like mad" bug. */
|
|
||||||
|
|
||||||
function _modelMatrix(
|
function _modelMatrix(pos, headingDeg, model) {
|
||||||
pos,
|
return flightState._renderer.models.setPose(model, pos, headingDeg);
|
||||||
headingDeg,
|
|
||||||
result = flightState._scratchModelMtx,
|
|
||||||
) {
|
|
||||||
flightState._scratchModelHpr.heading = Cesium.Math.toRadians(
|
|
||||||
(headingDeg || 0) + MODEL_HEADING_OFFSET_DEG,
|
|
||||||
);
|
|
||||||
flightState._scratchModelHpr.pitch = 0;
|
|
||||||
flightState._scratchModelHpr.roll = 0;
|
|
||||||
return Cesium.Transforms.headingPitchRollToFixedFrame(
|
|
||||||
pos,
|
|
||||||
flightState._scratchModelHpr,
|
|
||||||
Cesium.Ellipsoid.WGS84,
|
|
||||||
undefined,
|
|
||||||
result,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the
|
/** Everything scene.sampleHeight must NOT hit when snapping a grounded model: the
|
||||||
@@ -271,23 +254,23 @@ export function createRendering({
|
|||||||
const meta = flightState.records.data.get(icao24);
|
const meta = flightState.records.data.get(icao24);
|
||||||
if (!meta || !meta.onGround) return pos;
|
if (!meta || !meta.onGround) return pos;
|
||||||
const h = flightState._groundSnap.heightFor(
|
const h = flightState._groundSnap.heightFor(
|
||||||
flightState._viewer,
|
flightState._renderer?.view.surfaceOwner,
|
||||||
icao24,
|
icao24,
|
||||||
pos,
|
pos,
|
||||||
_groundSampleExclusions,
|
_groundSampleExclusions,
|
||||||
);
|
);
|
||||||
if (h == null) return null;
|
if (h == null) return null;
|
||||||
const carto = Cesium.Cartographic.fromCartesian(
|
const carto = geo.cartographic.fromCartesian(
|
||||||
pos,
|
pos,
|
||||||
Cesium.Ellipsoid.WGS84,
|
geo.ellipsoid.WGS84,
|
||||||
flightState._scratchGroundCarto,
|
flightState._scratchGroundCarto,
|
||||||
);
|
);
|
||||||
carto.height = h + _modelSpec(meta.klass).bellyM;
|
carto.height = h + _modelSpec(meta.klass).bellyM;
|
||||||
return Cesium.Cartesian3.fromRadians(
|
return geo.cartesian3.fromRadians(
|
||||||
carto.longitude,
|
carto.longitude,
|
||||||
carto.latitude,
|
carto.latitude,
|
||||||
carto.height,
|
carto.height,
|
||||||
Cesium.Ellipsoid.WGS84,
|
geo.ellipsoid.WGS84,
|
||||||
result,
|
result,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -316,7 +299,7 @@ export function createRendering({
|
|||||||
// The matrix is written BEFORE the readiness test on purpose: a model can flip
|
// The matrix is written BEFORE the readiness test on purpose: a model can flip
|
||||||
// `ready` during scene update after this tick, and a first rendered frame on a
|
// `ready` during scene update after this tick, and a first rendered frame on a
|
||||||
// stale load-start matrix is the one-frame jump this ordering prevents.
|
// stale load-start matrix is the one-frame jump this ordering prevents.
|
||||||
_modelMatrix(displayPos, course, model.modelMatrix);
|
_modelMatrix(displayPos, course, model);
|
||||||
if (!model.ready) {
|
if (!model.ready) {
|
||||||
model.show = false; // not loaded yet → keep the 2D icon, no half-model flash
|
model.show = false; // not loaded yet → keep the 2D icon, no half-model flash
|
||||||
bb.show = true;
|
bb.show = true;
|
||||||
@@ -477,22 +460,19 @@ export function createRendering({
|
|||||||
// toggle must reject too (the reload queue only covers ADMITTED models).
|
// toggle must reject too (the reload queue only covers ADMITTED models).
|
||||||
const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass);
|
const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass);
|
||||||
const loadIrBoost = flightState._irBoost;
|
const loadIrBoost = flightState._irBoost;
|
||||||
|
const renderer = flightState._renderer;
|
||||||
try {
|
try {
|
||||||
const spec = _modelSpec(flightState.records.data.get(icao24)?.klass);
|
const spec = _modelSpec(flightState.records.data.get(icao24)?.klass);
|
||||||
model = await Cesium.Model.fromGltfAsync({
|
model = await renderer.models.loadFleet({
|
||||||
url: resolveAsset(spec.url),
|
url: resolveAsset(spec.url),
|
||||||
asynchronous: false,
|
|
||||||
minimumPixelSize: MODEL_MIN_PX,
|
minimumPixelSize: MODEL_MIN_PX,
|
||||||
scale: spec.scale,
|
scale: spec.scale,
|
||||||
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
|
color: flightState._irBoost ? WHITE : _modelColor(icao24),
|
||||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
|
||||||
// Launch presentation keeps the code-side tint dominant for every approved
|
// Launch presentation keeps the code-side tint dominant for every approved
|
||||||
// model; IR boost removes the remaining diffuse hint with flat UNLIT white.
|
// model; IR boost removes the remaining diffuse hint with flat UNLIT white.
|
||||||
colorBlendAmount: flightState._irBoost ? 1.0 : spec.blendAmount,
|
blendAmount: flightState._irBoost ? 1.0 : spec.blendAmount,
|
||||||
customShader: flightState._irBoost
|
unlit: flightState._irBoost,
|
||||||
? flightState._IR_UNLIT_SHADER
|
id: icao24, // so a pick returns the icao for click-to-track
|
||||||
: undefined,
|
|
||||||
id: icao24, // so scene.pick returns the icao for click-to-track
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// asset/decode fail — stay billboard. Only touch this lifecycle's state if still current
|
// asset/decode fail — stay billboard. Only touch this lifecycle's state if still current
|
||||||
@@ -506,11 +486,7 @@ export function createRendering({
|
|||||||
// A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new
|
// A load from a PREVIOUS lifecycle (destroy→init happened mid-load) must NOT mutate the new
|
||||||
// epoch's _modelPending/_modelGen or add to the new collection — just drop its model.
|
// epoch's _modelPending/_modelGen or add to the new collection — just drop its model.
|
||||||
if (epoch !== flightState._modelEpoch) {
|
if (epoch !== flightState._modelEpoch) {
|
||||||
try {
|
renderer.models.destroy(model);
|
||||||
model.destroy();
|
|
||||||
} catch {
|
|
||||||
/* gone */
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
flightState._modelPending.delete(icao24);
|
flightState._modelPending.delete(icao24);
|
||||||
@@ -522,8 +498,7 @@ export function createRendering({
|
|||||||
const stale =
|
const stale =
|
||||||
(flightState._modelGen.get(icao24) || 0) !== gen ||
|
(flightState._modelGen.get(icao24) || 0) !== gen ||
|
||||||
!_modelRegimeActive() ||
|
!_modelRegimeActive() ||
|
||||||
!flightState._modelCollection ||
|
!flightState._renderer?.models.live() ||
|
||||||
flightState._modelCollection.isDestroyed() ||
|
|
||||||
!flightState.records.data.has(icao24) ||
|
!flightState.records.data.has(icao24) ||
|
||||||
icao24 === flightState._trackedIcao ||
|
icao24 === flightState._trackedIcao ||
|
||||||
flightState._models.has(icao24) ||
|
flightState._models.has(icao24) ||
|
||||||
@@ -533,11 +508,7 @@ export function createRendering({
|
|||||||
// IR boost flipped mid-load → this model baked the wrong shader/tint.
|
// IR boost flipped mid-load → this model baked the wrong shader/tint.
|
||||||
flightState._irBoost !== loadIrBoost;
|
flightState._irBoost !== loadIrBoost;
|
||||||
if (stale) {
|
if (stale) {
|
||||||
try {
|
renderer.models.destroy(model);
|
||||||
model.destroy();
|
|
||||||
} catch {
|
|
||||||
/* already gone */
|
|
||||||
}
|
|
||||||
_cleanupModelGen(icao24); // bound the map
|
_cleanupModelGen(icao24); // bound the map
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -551,7 +522,7 @@ export function createRendering({
|
|||||||
// between admission and the next fleet tick (and draw at the identity matrix,
|
// between admission and the next fleet tick (and draw at the identity matrix,
|
||||||
// i.e. the Earth's centre). The handoff turns it on once it has a matrix.
|
// i.e. the Earth's centre). The handoff turns it on once it has a matrix.
|
||||||
model.show = false;
|
model.show = false;
|
||||||
flightState._modelCollection.add(model);
|
flightState._renderer.models.add(model);
|
||||||
flightState._models.set(icao24, model);
|
flightState._models.set(icao24, model);
|
||||||
flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard
|
flightState._planeModelLoaded = true; // GLB is cached now — the tracked entity's model can fade in its billboard
|
||||||
}
|
}
|
||||||
@@ -571,16 +542,7 @@ export function createRendering({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (m) {
|
if (m) {
|
||||||
if (
|
flightState._renderer?.models.remove(m);
|
||||||
flightState._modelCollection &&
|
|
||||||
!flightState._modelCollection.isDestroyed()
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
flightState._modelCollection.remove(m);
|
|
||||||
} catch {
|
|
||||||
/* gone */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flightState._models.delete(icao24);
|
flightState._models.delete(icao24);
|
||||||
}
|
}
|
||||||
// Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping
|
// Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping
|
||||||
@@ -610,17 +572,9 @@ export function createRendering({
|
|||||||
icao,
|
icao,
|
||||||
(flightState._modelGen.get(icao) || 0) + 1,
|
(flightState._modelGen.get(icao) || 0) + 1,
|
||||||
);
|
);
|
||||||
if (
|
if (flightState._renderer?.models.live()) {
|
||||||
flightState._modelCollection &&
|
for (const m of flightState._models.values())
|
||||||
!flightState._modelCollection.isDestroyed()
|
flightState._renderer.models.remove(m);
|
||||||
) {
|
|
||||||
for (const m of flightState._models.values()) {
|
|
||||||
try {
|
|
||||||
flightState._modelCollection.remove(m);
|
|
||||||
} catch {
|
|
||||||
/* gone */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
flightState._models.clear();
|
flightState._models.clear();
|
||||||
}
|
}
|
||||||
@@ -631,16 +585,7 @@ export function createRendering({
|
|||||||
flightState._trackedModelGen++;
|
flightState._trackedModelGen++;
|
||||||
flightState._trackedModelLoading = false;
|
flightState._trackedModelLoading = false;
|
||||||
if (flightState._trackedModel) {
|
if (flightState._trackedModel) {
|
||||||
if (
|
flightState._renderer?.models.remove(flightState._trackedModel);
|
||||||
flightState._modelCollection &&
|
|
||||||
!flightState._modelCollection.isDestroyed()
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
flightState._modelCollection.remove(flightState._trackedModel);
|
|
||||||
} catch {
|
|
||||||
/* gone */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flightState._trackedModel = null;
|
flightState._trackedModel = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -654,8 +599,7 @@ export function createRendering({
|
|||||||
const active =
|
const active =
|
||||||
flightState._trackedIcao &&
|
flightState._trackedIcao &&
|
||||||
parts.tracking._trackedModelRegimeActive() &&
|
parts.tracking._trackedModelRegimeActive() &&
|
||||||
flightState._modelCollection &&
|
flightState._renderer?.models.live();
|
||||||
!flightState._modelCollection.isDestroyed();
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
if (flightState._trackedModel) flightState._trackedModel.show = false;
|
if (flightState._trackedModel) flightState._trackedModel.show = false;
|
||||||
return;
|
return;
|
||||||
@@ -685,36 +629,29 @@ export function createRendering({
|
|||||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||||
);
|
);
|
||||||
const trackedIrBoost = flightState._irBoost;
|
const trackedIrBoost = flightState._irBoost;
|
||||||
Cesium.Model.fromGltfAsync({
|
const renderer = flightState._renderer;
|
||||||
url: resolveAsset(trackedSpec.url),
|
renderer.models
|
||||||
asynchronous: false,
|
.loadTracked({
|
||||||
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
url: resolveAsset(trackedSpec.url),
|
||||||
scale: trackedSpec.scale,
|
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
||||||
color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN,
|
scale: trackedSpec.scale,
|
||||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
color: flightState._irBoost ? WHITE : CYAN,
|
||||||
// The tracked aircraft uses the same dominant light tint as the fleet;
|
// The tracked aircraft uses the same dominant light tint as the fleet;
|
||||||
// IR boost removes the remaining diffuse hint with flat UNLIT white.
|
// IR boost removes the remaining diffuse hint with flat UNLIT white.
|
||||||
colorBlendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount,
|
blendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount,
|
||||||
customShader: flightState._irBoost
|
unlit: flightState._irBoost,
|
||||||
? flightState._IR_UNLIT_SHADER
|
// Pick id (H1): without it, clicking the very plane being tracked read as
|
||||||
: undefined,
|
// EMPTY SPACE (a pick with no id) → an unintended deselect. With the
|
||||||
// Pick id (H1): without it, clicking the very plane being tracked read as
|
// icao, the click handler recognizes it as ours.
|
||||||
// EMPTY SPACE (scene.pick → primitive with no id) → an unintended
|
id: flightState._trackedIcao,
|
||||||
// deselect. With the icao, the click handler recognizes it as ours.
|
})
|
||||||
id: flightState._trackedIcao,
|
|
||||||
})
|
|
||||||
.then((m) => {
|
.then((m) => {
|
||||||
// Untracked / re-tracked / torn down during the load → drop it.
|
// Untracked / re-tracked / torn down during the load → drop it.
|
||||||
if (
|
if (
|
||||||
gen !== flightState._trackedModelGen ||
|
gen !== flightState._trackedModelGen ||
|
||||||
!flightState._modelCollection ||
|
!flightState._renderer?.models.live()
|
||||||
flightState._modelCollection.isDestroyed()
|
|
||||||
) {
|
) {
|
||||||
try {
|
renderer.models.destroy(m);
|
||||||
m.destroy();
|
|
||||||
} catch {
|
|
||||||
/* gone */
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Class reclassified OR boost flipped mid-load (no release ran —
|
// Class reclassified OR boost flipped mid-load (no release ran —
|
||||||
@@ -725,11 +662,7 @@ export function createRendering({
|
|||||||
) !== trackedKey ||
|
) !== trackedKey ||
|
||||||
flightState._irBoost !== trackedIrBoost
|
flightState._irBoost !== trackedIrBoost
|
||||||
) {
|
) {
|
||||||
try {
|
renderer.models.destroy(m);
|
||||||
m.destroy();
|
|
||||||
} catch {
|
|
||||||
/* gone */
|
|
||||||
}
|
|
||||||
flightState._trackedModelLoading = false;
|
flightState._trackedModelLoading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -751,11 +684,7 @@ export function createRendering({
|
|||||||
flightState._scratchGroundPos,
|
flightState._scratchGroundPos,
|
||||||
);
|
);
|
||||||
if (displayPos)
|
if (displayPos)
|
||||||
_modelMatrix(
|
_modelMatrix(displayPos, parts.motion._trackedDisplayCourse(), m);
|
||||||
displayPos,
|
|
||||||
parts.motion._trackedDisplayCourse(),
|
|
||||||
m.modelMatrix,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
flightState._trackedModel = m;
|
flightState._trackedModel = m;
|
||||||
flightState._trackedModelLoading = false;
|
flightState._trackedModelLoading = false;
|
||||||
@@ -764,7 +693,7 @@ export function createRendering({
|
|||||||
flightState._trackedModelFailIcao = null;
|
flightState._trackedModelFailIcao = null;
|
||||||
flightState._trackedModelFailCount = 0;
|
flightState._trackedModelFailCount = 0;
|
||||||
flightState._trackedModelRetryAtMs = 0;
|
flightState._trackedModelRetryAtMs = 0;
|
||||||
flightState._modelCollection.add(m);
|
flightState._renderer.models.add(m);
|
||||||
flightState._planeModelLoaded = true; // GLB cached — the tracked billboard can fade out once the model is up
|
flightState._planeModelLoaded = true; // GLB cached — the tracked billboard can fade out once the model is up
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -790,21 +719,19 @@ export function createRendering({
|
|||||||
_modelMatrix(
|
_modelMatrix(
|
||||||
displayPos,
|
displayPos,
|
||||||
parts.motion._trackedDisplayCourse(),
|
parts.motion._trackedDisplayCourse(),
|
||||||
flightState._trackedModel.modelMatrix,
|
flightState._trackedModel,
|
||||||
);
|
);
|
||||||
if (!flightState._trackedModel.ready) return;
|
if (!flightState._trackedModel.ready) return;
|
||||||
const spec = _modelSpec(
|
const spec = _modelSpec(
|
||||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||||
);
|
);
|
||||||
|
const view = flightState._renderer.view;
|
||||||
flightState._trackedModel.scale = trackedModelScaleForPixelCap({
|
flightState._trackedModel.scale = trackedModelScaleForPixelCap({
|
||||||
baseScale: spec.scale,
|
baseScale: spec.scale,
|
||||||
nativeRadiusM: spec.nativeRadiusM,
|
nativeRadiusM: spec.nativeRadiusM,
|
||||||
rangeM: Cesium.Cartesian3.distance(
|
rangeM: geo.cartesian3.distance(view.camera.positionWC, displayPos),
|
||||||
flightState._viewer.camera.positionWC,
|
viewportHeightPx: view.viewportHeightPx(),
|
||||||
displayPos,
|
fovyRad: view.camera.frustum.fovy,
|
||||||
),
|
|
||||||
viewportHeightPx: flightState._viewer.scene.canvas.clientHeight,
|
|
||||||
fovyRad: flightState._viewer.camera.frustum.fovy,
|
|
||||||
maximumPixelSize: TRACKED_MODEL_MAX_PX,
|
maximumPixelSize: TRACKED_MODEL_MAX_PX,
|
||||||
});
|
});
|
||||||
flightState._trackedModel.show = true;
|
flightState._trackedModel.show = true;
|
||||||
@@ -812,14 +739,10 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _fleetTick() {
|
function _fleetTick() {
|
||||||
if (
|
if (!flightState._renderer?.fleet.visible()) return;
|
||||||
!flightState._viewer ||
|
const view = flightState._renderer.view;
|
||||||
!flightState._billboardCollection ||
|
const scene = view.scene;
|
||||||
!flightState._billboardCollection.show
|
const camera = view.camera;
|
||||||
)
|
|
||||||
return;
|
|
||||||
const scene = flightState._viewer.scene;
|
|
||||||
const camera = flightState._viewer.camera;
|
|
||||||
const nowMs = focusNowMs(Date.now());
|
const nowMs = focusNowMs(Date.now());
|
||||||
|
|
||||||
// (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz
|
// (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz
|
||||||
@@ -848,7 +771,7 @@ export function createRendering({
|
|||||||
flightState._lastRotPassMs = nowMs;
|
flightState._lastRotPassMs = nowMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
const occluder = horizonOccluder(camera);
|
const occluder = view.horizon();
|
||||||
const focusTarget = getFocusTarget();
|
const focusTarget = getFocusTarget();
|
||||||
|
|
||||||
// 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling.
|
// 3D model regime: only when enabled AND the camera is zoomed in past the altitude ceiling.
|
||||||
@@ -879,11 +802,7 @@ export function createRendering({
|
|||||||
const addDistSq = addM * addM;
|
const addDistSq = addM * addM;
|
||||||
const keepM = _modelKeepDistM();
|
const keepM = _modelKeepDistM();
|
||||||
const keepDistSq = keepM * keepM;
|
const keepDistSq = keepM * keepM;
|
||||||
const cull = camera.frustum.computeCullingVolume(
|
const inFrustum = view.frustumTester();
|
||||||
camPos,
|
|
||||||
camera.directionWC,
|
|
||||||
camera.upWC,
|
|
||||||
);
|
|
||||||
// Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness.
|
// Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness.
|
||||||
const cand = [];
|
const cand = [];
|
||||||
for (const [icao, bb] of flightState._billboards) {
|
for (const [icao, bb] of flightState._billboards) {
|
||||||
@@ -898,18 +817,9 @@ export function createRendering({
|
|||||||
// ground or in the air — no distinction"). The cap + nearest-first ordering
|
// ground or in the air — no distinction"). The cap + nearest-first ordering
|
||||||
// below already bound airport clusters; grounded placement is handled by the
|
// below already bound airport clusters; grounded placement is handled by the
|
||||||
// one-shot ground snap in _modelDisplayPosition.
|
// one-shot ground snap in _modelDisplayPosition.
|
||||||
const d2 = Cesium.Cartesian3.distanceSquared(camPos, bb.position);
|
const d2 = geo.cartesian3.distanceSquared(camPos, bb.position);
|
||||||
if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible
|
if (d2 > keepDistSq) continue; // beyond the keep radius → never eligible
|
||||||
Cesium.Cartesian3.clone(
|
cand.push([icao, d2, inFrustum(bb.position)]);
|
||||||
bb.position,
|
|
||||||
flightState._scratchModelBS.center,
|
|
||||||
);
|
|
||||||
cand.push([
|
|
||||||
icao,
|
|
||||||
d2,
|
|
||||||
cull.computeVisibility(flightState._scratchModelBS) !==
|
|
||||||
Cesium.Intersect.OUTSIDE,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
cand.sort((a, b) => a[1] - b[1]); // nearest first
|
cand.sort((a, b) => a[1] - b[1]); // nearest first
|
||||||
if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) {
|
if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) {
|
||||||
@@ -952,7 +862,7 @@ export function createRendering({
|
|||||||
// collection's vertex buffer, so skip sub-meter moves.
|
// collection's vertex buffer, so skip sub-meter moves.
|
||||||
if (
|
if (
|
||||||
display &&
|
display &&
|
||||||
Cesium.Cartesian3.distanceSquared(display, bb.position) > 1.0
|
geo.cartesian3.distanceSquared(display, bb.position) > 1.0
|
||||||
) {
|
) {
|
||||||
bb.position = display;
|
bb.position = display;
|
||||||
}
|
}
|
||||||
@@ -985,7 +895,7 @@ export function createRendering({
|
|||||||
// locked NearFarScalar remains a separate multiplicative stage. This
|
// locked NearFarScalar remains a separate multiplicative stage. This
|
||||||
// narrowly amends always-visible rendering without count culling or zero
|
// narrowly amends always-visible rendering without count culling or zero
|
||||||
// alpha; nearer focus behavior remains tunable rather than universal.
|
// alpha; nearer focus behavior remains tunable rather than universal.
|
||||||
const cameraDistanceM = Cesium.Cartesian3.distance(
|
const cameraDistanceM = geo.cartesian3.distance(
|
||||||
camera.positionWC,
|
camera.positionWC,
|
||||||
bb.position,
|
bb.position,
|
||||||
);
|
);
|
||||||
@@ -1101,9 +1011,7 @@ export function createRendering({
|
|||||||
// white. Boosted models also skip the recession fade: hot targets
|
// white. Boosted models also skip the recession fade: hot targets
|
||||||
// stay full-strength at any range (billboards keep their normal
|
// stay full-strength at any range (billboards keep their normal
|
||||||
// fade — full-opacity glyph walls read as overwhelming).
|
// fade — full-opacity glyph walls read as overwhelming).
|
||||||
baseColor: flightState._irBoost
|
baseColor: flightState._irBoost ? WHITE : _modelColor(icao24),
|
||||||
? Cesium.Color.WHITE
|
|
||||||
: _modelColor(icao24),
|
|
||||||
alpha: flightState._irBoost ? 1 : treatment.alpha,
|
alpha: flightState._irBoost ? 1 : treatment.alpha,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||||
import {
|
import {
|
||||||
@@ -8,8 +7,9 @@ import {
|
|||||||
} from '../../data/motionModel.js';
|
} from '../../data/motionModel.js';
|
||||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||||
|
import { CYAN } from '../shared/color.js';
|
||||||
|
|
||||||
/** Apply reconciled aircraft records to Cesium resources and follow state. */
|
/** Apply reconciled aircraft records to the renderer's fleet and follow state. */
|
||||||
export function createFlightSnapshotRenderer({
|
export function createFlightSnapshotRenderer({
|
||||||
flightState,
|
flightState,
|
||||||
records,
|
records,
|
||||||
@@ -32,7 +32,8 @@ export function createFlightSnapshotRenderer({
|
|||||||
// subpoint + collected floor cells for the viewer-proximate low-contact
|
// subpoint + collected floor cells for the viewer-proximate low-contact
|
||||||
// clamp below — one carto read per poll, one batch warm after the loop.
|
// clamp below — one carto read per poll, one batch warm after the loop.
|
||||||
const viewerCarto =
|
const viewerCarto =
|
||||||
(viewer || flightState._viewer)?.camera?.positionCartographic || null;
|
(viewer || flightState._renderer?.view)?.camera?.positionCartographic ||
|
||||||
|
null;
|
||||||
const viewerLatDeg = viewerCarto
|
const viewerLatDeg = viewerCarto
|
||||||
? geo.math.toDegrees(viewerCarto.latitude)
|
? geo.math.toDegrees(viewerCarto.latitude)
|
||||||
: null;
|
: null;
|
||||||
@@ -55,7 +56,7 @@ export function createFlightSnapshotRenderer({
|
|||||||
if (isMil && tracking._militaryLayerSuppresses(icao24)) {
|
if (isMil && tracking._militaryLayerSuppresses(icao24)) {
|
||||||
const dupe = flightState._billboards.get(icao24);
|
const dupe = flightState._billboards.get(icao24);
|
||||||
if (dupe) {
|
if (dupe) {
|
||||||
flightState._billboardCollection.remove(dupe);
|
flightState._renderer.fleet.remove(dupe);
|
||||||
flightState._billboards.delete(icao24);
|
flightState._billboards.delete(icao24);
|
||||||
rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
||||||
records.forget(icao24);
|
records.forget(icao24);
|
||||||
@@ -200,7 +201,7 @@ export function createFlightSnapshotRenderer({
|
|||||||
rendering._modelOwnsVisual(icao24),
|
rendering._modelOwnsVisual(icao24),
|
||||||
)) ||
|
)) ||
|
||||||
position;
|
position;
|
||||||
const bb = flightState._billboardCollection.add({
|
const bb = flightState._renderer.fleet.add({
|
||||||
position: displayed,
|
position: displayed,
|
||||||
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
|
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
|
||||||
width: isTracked ? 24 : 20,
|
width: isTracked ? 24 : 20,
|
||||||
@@ -209,9 +210,7 @@ export function createFlightSnapshotRenderer({
|
|||||||
// Screen-projected rotation lands on the next fleet tick.
|
// Screen-projected rotation lands on the next fleet tick.
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
alignedAxis: geo.cartesian3.ZERO,
|
alignedAxis: geo.cartesian3.ZERO,
|
||||||
color: isTracked
|
color: isTracked ? CYAN : rendering._fleetBillboardColor(icao24),
|
||||||
? Cesium.Color.CYAN
|
|
||||||
: rendering._fleetBillboardColor(icao24),
|
|
||||||
sizeInMeters: false,
|
sizeInMeters: false,
|
||||||
scaleByDistance: rendering._normalBillboardScaleByDistance(),
|
scaleByDistance: rendering._normalBillboardScaleByDistance(),
|
||||||
// Grounded/near-surface planes sit at/below the photoreal tile
|
// Grounded/near-surface planes sit at/below the photoreal tile
|
||||||
@@ -272,7 +271,7 @@ export function createFlightSnapshotRenderer({
|
|||||||
tracking._clearTracking(false, { evicted: true });
|
tracking._clearTracking(false, { evicted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
flightState._billboardCollection.remove(bb);
|
flightState._renderer.fleet.remove(bb);
|
||||||
flightState._billboards.delete(icao24);
|
flightState._billboards.delete(icao24);
|
||||||
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
|
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
|
||||||
records.forget(icao24);
|
records.forget(icao24);
|
||||||
@@ -308,15 +307,22 @@ export function createFlightSnapshotRenderer({
|
|||||||
// cell, budget-capped, viewer-proximate, google-3d regime only). Own
|
// cell, budget-capped, viewer-proximate, google-3d regime only). Own
|
||||||
// billboards/models are excluded so a vertical probe can't land on an
|
// billboards/models are excluded so a vertical probe can't land on an
|
||||||
// aircraft instead of the pavement.
|
// aircraft instead of the pavement.
|
||||||
sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, {
|
sampleMeshFloorCells(
|
||||||
excludeObjects: [
|
flightState._renderer?.view.surfaceScene,
|
||||||
...flightState._billboards.values(),
|
floorWarmPoints,
|
||||||
...flightState._models.values(),
|
{
|
||||||
flightState._trackedModel,
|
excludeObjects:
|
||||||
].filter(Boolean),
|
flightState._renderer?.surfaceExclusions({
|
||||||
viewerLat: viewerLatDeg,
|
sprites: flightState._billboards.values(),
|
||||||
viewerLon: viewerLonDeg,
|
models: [
|
||||||
});
|
...flightState._models.values(),
|
||||||
|
flightState._trackedModel,
|
||||||
|
],
|
||||||
|
}) ?? [],
|
||||||
|
viewerLat: viewerLatDeg,
|
||||||
|
viewerLon: viewerLonDeg,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Round 5: the grounded exact-key warm that used to live here is gone —
|
// Round 5: the grounded exact-key warm that used to live here is gone —
|
||||||
// see the note where _warmGroundedAircraftSurfaceCache was removed. The
|
// see the note where _warmGroundedAircraftSurfaceCache was removed. The
|
||||||
|
|||||||
+21
-46
@@ -1,6 +1,5 @@
|
|||||||
import { FlightRecords } from './records.js';
|
import { FlightRecords } from './records.js';
|
||||||
import { createFlightFeed } from './ingestion.js';
|
import { createFlightFeed } from './ingestion.js';
|
||||||
import * as Cesium from 'cesium';
|
|
||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
|
import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
|
||||||
|
|
||||||
@@ -36,11 +35,14 @@ export function createFlightState({ source, services }) {
|
|||||||
|
|
||||||
flightState._scratchGroundPos = geo.cartesian3.create();
|
flightState._scratchGroundPos = geo.cartesian3.create();
|
||||||
|
|
||||||
/** @type {Cesium.PrimitiveCollection|null} */
|
/** @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. */
|
||||||
|
|
||||||
flightState._modelCollection = null;
|
flightState._renderer = null;
|
||||||
|
|
||||||
/** @type {Map<string, Cesium.Model>} icao24 → model */
|
/** @type {Map<string, object>} icao24 → model handle */
|
||||||
|
|
||||||
flightState._models = new Map();
|
flightState._models = new Map();
|
||||||
|
|
||||||
@@ -79,33 +81,20 @@ export function createFlightState({ source, services }) {
|
|||||||
|
|
||||||
flightState._planeModelLoaded = false;
|
flightState._planeModelLoaded = false;
|
||||||
|
|
||||||
/** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */
|
/** @type {object|null} retained preload that keeps the glTF cache warm */
|
||||||
|
|
||||||
flightState._preloadModel = null;
|
flightState._preloadModel = null;
|
||||||
|
|
||||||
flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0);
|
|
||||||
|
|
||||||
flightState._scratchModelMtx = geo.matrix4.create();
|
|
||||||
|
|
||||||
flightState._scratchModelBS = new Cesium.BoundingSphere(
|
|
||||||
geo.cartesian3.create(),
|
|
||||||
1.0,
|
|
||||||
);
|
|
||||||
// frustum-visibility test
|
|
||||||
/** Last limb taper per billboard, retained across class/ground/cockpit repaints. */
|
/** Last limb taper per billboard, retained across class/ground/cockpit repaints. */
|
||||||
|
|
||||||
flightState._billboardLimbScale = new WeakMap();
|
flightState._billboardLimbScale = new WeakMap();
|
||||||
// keep last N positions per aircraft
|
// keep last N positions per aircraft
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Module-level state: billboard collection and per-aircraft lookup maps
|
// Per-aircraft lookup maps (the fleet sprites live in the renderer)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** @type {Cesium.BillboardCollection|null} */
|
/** @type {Map<string, object>} icao24 -> fleet sprite handle */
|
||||||
|
|
||||||
flightState._billboardCollection = null;
|
|
||||||
|
|
||||||
/** @type {Map<string, Cesium.Billboard>} icao24 -> billboard primitive */
|
|
||||||
|
|
||||||
flightState._billboards = new Map();
|
flightState._billboards = new Map();
|
||||||
|
|
||||||
@@ -133,7 +122,7 @@ export function createFlightState({ source, services }) {
|
|||||||
|
|
||||||
flightState._trackingIntentGeneration = 0;
|
flightState._trackingIntentGeneration = 0;
|
||||||
|
|
||||||
/** @type {Cesium.Entity|null} Entity used for camera tracking */
|
/** @type {object|null} The renderer's tracked handle (the follow target) */
|
||||||
|
|
||||||
flightState._trackedEntity = null;
|
flightState._trackedEntity = null;
|
||||||
|
|
||||||
@@ -141,7 +130,7 @@ export function createFlightState({ source, services }) {
|
|||||||
|
|
||||||
flightState._trackedCameraFrameStop = null;
|
flightState._trackedCameraFrameStop = null;
|
||||||
|
|
||||||
/** @type {Cesium.Model|null} Standalone 3D model for the tracked aircraft. Deliberately NOT a
|
/** @type {object|null} Standalone 3D model for the tracked aircraft. Deliberately NOT a
|
||||||
* graphic on _trackedEntity: viewer.trackedEntity derives the follow-camera from the entity's
|
* graphic on _trackedEntity: viewer.trackedEntity derives the follow-camera from the entity's
|
||||||
* bounding sphere, and a model graphic reports PENDING until its glTF loads — which stalls (or, on
|
* bounding sphere, and a model graphic reports PENDING until its glTF loads — which stalls (or, on
|
||||||
* 3D-toggle, freezes) the centering. A pure-billboard entity always supplies a ready sphere; the
|
* 3D-toggle, freezes) the centering. A pure-billboard entity always supplies a ready sphere; the
|
||||||
@@ -155,13 +144,10 @@ export function createFlightState({ source, services }) {
|
|||||||
|
|
||||||
flightState._trackedModelLoading = false;
|
flightState._trackedModelLoading = false;
|
||||||
|
|
||||||
/** @type {Cesium.ScreenSpaceEventHandler|null} Click handler on the scene canvas */
|
/** @type {(() => void)|null} Unregisters this layer from the engine's
|
||||||
|
* selection dispatcher (clicks and Escape). */
|
||||||
|
|
||||||
flightState._clickHandler = null;
|
flightState._selectionRemove = null;
|
||||||
|
|
||||||
/** @type {Cesium.Viewer|null} Cached viewer reference */
|
|
||||||
|
|
||||||
flightState._viewer = null;
|
|
||||||
|
|
||||||
/** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */
|
/** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */
|
||||||
|
|
||||||
@@ -183,7 +169,7 @@ export function createFlightState({ source, services }) {
|
|||||||
|
|
||||||
flightState._trail = null;
|
flightState._trail = null;
|
||||||
|
|
||||||
/** @type {Cesium.Entity|null} Cheap 2-point head segment bridging the last fix to the LIVE
|
/** @type {object|null} Cheap 2-point head segment bridging the last fix to the LIVE
|
||||||
* dead-reckoned icon, updated per frame via a CallbackProperty — so the trail head stays
|
* dead-reckoned icon, updated per frame via a CallbackProperty — so the trail head stays
|
||||||
* glued to the 12 Hz icon without rebuilding the 400-point trail primitive every frame. */
|
* glued to the 12 Hz icon without rebuilding the 400-point trail primitive every frame. */
|
||||||
|
|
||||||
@@ -421,26 +407,15 @@ export function createFlightState({ source, services }) {
|
|||||||
/** IR hot-target mode (owner playtest 2026-08-16): the NVG/FLIR post-styles
|
/** IR hot-target mode (owner playtest 2026-08-16): the NVG/FLIR post-styles
|
||||||
* map LUMINANCE, so mid-gray textured models read cold and vanish into
|
* map LUMINANCE, so mid-gray textured models read cold and vanish into
|
||||||
* terrain. While a boost style is active every model renders flat white
|
* terrain. While a boost style is active every model renders flat white
|
||||||
* (hottest); per-spec color/tint restores on style exit. Driven by ui.js
|
* (hottest) and UNLIT (`unlit` in the model spec: a side-lit airframe under
|
||||||
* setStyle via the `irBoost` layer param. */
|
* a high sun otherwise rendered near-black in FLIR/NVG); per-spec
|
||||||
|
* color/tint restores on style exit. Driven by ui.js setStyle via the
|
||||||
|
* `irBoost` layer param. The shader is baked at creation (assigning it to
|
||||||
|
* a ready Cesium model is a silent no-op), so the boost flips by
|
||||||
|
* RELEASE-AND-RELOAD (see setParams). */
|
||||||
|
|
||||||
flightState._irBoost = false;
|
flightState._irBoost = false;
|
||||||
|
|
||||||
/** Boosted models render UNLIT (owner cockpit-FLIR field rounds, 2026-08-16):
|
|
||||||
* the white tint alone is applied to the MATERIAL, so Cesium still
|
|
||||||
* sun-shades it — near-horizon viewing shows a plane's SIDE, ~90° to a high
|
|
||||||
* sun, so it rendered near-BLACK in FLIR/NVG while sun-lit neighbors glowed.
|
|
||||||
* LightingModel.UNLIT emits the flat white directly, orientation be damned.
|
|
||||||
* CRITICAL (field-verified via scene.pick): assigning customShader to an
|
|
||||||
* already-READY model is a silent no-op — the property sets but the shader
|
|
||||||
* program never rebuilds. The boost therefore flips by RELEASE-AND-RELOAD
|
|
||||||
* (see setParams), so every boosted model gets the shader AT CREATION.
|
|
||||||
* One shared shader instance — stateless, safe across models. */
|
|
||||||
|
|
||||||
flightState._IR_UNLIT_SHADER = new Cesium.CustomShader({
|
|
||||||
lightingModel: Cesium.LightingModel.UNLIT,
|
|
||||||
});
|
|
||||||
|
|
||||||
flightState._irReloadQueue = null;
|
flightState._irReloadQueue = null;
|
||||||
return flightState;
|
return flightState;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { CYAN_TRANSPARENT } from './policy.js';
|
import { CYAN_TRANSPARENT } from './policy.js';
|
||||||
|
import { CYAN } from '../shared/color.js';
|
||||||
|
|
||||||
export function createTesting({
|
export function createTesting({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -65,9 +65,11 @@ export function createTesting({
|
|||||||
models = [],
|
models = [],
|
||||||
modelCollection = null,
|
modelCollection = null,
|
||||||
}) {
|
}) {
|
||||||
flightState._viewer = viewer;
|
// The production renderer over the test's viewer and (fake) collections.
|
||||||
flightState._modelCollection = modelCollection;
|
flightState._renderer = services.renderer.create({
|
||||||
flightState._billboardCollection = billboardCollection;
|
viewer,
|
||||||
|
adopt: { billboardCollection, modelCollection },
|
||||||
|
});
|
||||||
flightState._billboards = new Map([[icao24, billboard]]);
|
flightState._billboards = new Map([[icao24, billboard]]);
|
||||||
flightState._cullPositions.clear();
|
flightState._cullPositions.clear();
|
||||||
flightState._models.clear();
|
flightState._models.clear();
|
||||||
@@ -175,7 +177,7 @@ export function createTesting({
|
|||||||
function _trackedBillboardColorForTest() {
|
function _trackedBillboardColorForTest() {
|
||||||
return parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
return parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
||||||
? CYAN_TRANSPARENT
|
? CYAN_TRANSPARENT
|
||||||
: Cesium.Color.CYAN;
|
: CYAN;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */
|
/** Drive the exact fleet billboard-to-model handoff used by `_fleetTick`. */
|
||||||
|
|||||||
+227
-287
@@ -1,4 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as geo from '../../geo/index.js';
|
||||||
|
import { CYAN, nearFarScale } from '../shared/color.js';
|
||||||
import { appClock } from '../../time/clock.js';
|
import { appClock } from '../../time/clock.js';
|
||||||
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
||||||
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
|
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
|
||||||
@@ -10,11 +11,6 @@ import { trailHeadStart } from '../../data/modelVisualAnchor.js';
|
|||||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||||
import { routePlausible } from '../../data/routePlausible.js';
|
import { routePlausible } from '../../data/routePlausible.js';
|
||||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||||
import {
|
|
||||||
bindTrackingClickGesture,
|
|
||||||
isTrackingSelectionGesture,
|
|
||||||
isTrackingClickGesture,
|
|
||||||
} from '../../data/trackingClickGesture.js';
|
|
||||||
import {
|
import {
|
||||||
TRACKED_MODEL_MAX_LOAD_FAILS,
|
TRACKED_MODEL_MAX_LOAD_FAILS,
|
||||||
TRACKED_MODEL_RETRY_BACKOFF_MS,
|
TRACKED_MODEL_RETRY_BACKOFF_MS,
|
||||||
@@ -38,15 +34,13 @@ export function createTracking({
|
|||||||
} = services.context;
|
} = services.context;
|
||||||
const { isTr3b, tr3bTypeLabel, tr3bConvertedIds } =
|
const { isTr3b, tr3bTypeLabel, tr3bConvertedIds } =
|
||||||
services.aircraftPresentation;
|
services.aircraftPresentation;
|
||||||
const { createTrail } = services.trails;
|
|
||||||
const { ensureGeoidReady, geoidHeight } = services.geoid;
|
const { ensureGeoidReady, geoidHeight } = services.geoid;
|
||||||
const { resolveGroundFloorCellsBounded, floorAltitudeM, cachedGroundFloor } =
|
const { resolveGroundFloorCellsBounded, floorAltitudeM, cachedGroundFloor } =
|
||||||
services.groundFloor;
|
services.groundFloor;
|
||||||
const { clearFocusTarget } = services.focus;
|
const { clearFocusTarget } = services.focus;
|
||||||
const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry;
|
const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry;
|
||||||
const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout;
|
const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout;
|
||||||
const { applyTrackedCameraFrame } = services.camera;
|
const { selectionDispatcherFor } = services.selection;
|
||||||
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
|
||||||
const flightsLayer = layer;
|
const flightsLayer = layer;
|
||||||
|
|
||||||
function _emitAwarenessEvent(type, detail) {
|
function _emitAwarenessEvent(type, detail) {
|
||||||
@@ -73,7 +67,7 @@ export function createTracking({
|
|||||||
// a callsign-less contact reached Context as its raw hex even once adsbdb
|
// a callsign-less contact reached Context as its raw hex even once adsbdb
|
||||||
// had supplied a registration. Identity below stays `icao24`.
|
// had supplied a registration. Identity below stays `icao24`.
|
||||||
label: parts.queries._contactLabel(icao24, info),
|
label: parts.queries._contactLabel(icao24, info),
|
||||||
position: Cesium.Cartesian3.clone(bb.position),
|
position: geo.cartesian3.clone(bb.position),
|
||||||
origin,
|
origin,
|
||||||
});
|
});
|
||||||
selectTrackedSubjectContext(_contextSubjectMetadata(icao24));
|
selectTrackedSubjectContext(_contextSubjectMetadata(icao24));
|
||||||
@@ -147,10 +141,8 @@ export function createTracking({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function _refreshCockpitNearContacts() {
|
function _refreshCockpitNearContacts() {
|
||||||
if (
|
const camera = flightState._renderer?.view.camera;
|
||||||
!flightState._cockpitContactMode ||
|
if (!flightState._cockpitContactMode || !camera?.positionWC) {
|
||||||
!flightState._viewer?.camera?.positionWC
|
|
||||||
) {
|
|
||||||
if (flightState._cockpitNearContacts.size)
|
if (flightState._cockpitNearContacts.size)
|
||||||
flightState._cockpitNearContacts = new Set();
|
flightState._cockpitNearContacts = new Set();
|
||||||
return;
|
return;
|
||||||
@@ -161,10 +153,7 @@ export function createTracking({
|
|||||||
if (icao24 === flightState._trackedIcao || !bb?.position) continue;
|
if (icao24 === flightState._trackedIcao || !bb?.position) continue;
|
||||||
distancesSquared.push([
|
distancesSquared.push([
|
||||||
icao24,
|
icao24,
|
||||||
Cesium.Cartesian3.distanceSquared(
|
geo.cartesian3.distanceSquared(camera.positionWC, bb.position),
|
||||||
flightState._viewer.camera.positionWC,
|
|
||||||
bb.position,
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
const next = nextCockpitNearContacts(
|
const next = nextCockpitNearContacts(
|
||||||
@@ -195,7 +184,7 @@ export function createTracking({
|
|||||||
// aircraft silhouette until a ready model takes over; far contacts are pips.
|
// aircraft silhouette until a ready model takes over; far contacts are pips.
|
||||||
// Never destroy on entry: tearing down hundreds of live glTF instances
|
// Never destroy on entry: tearing down hundreds of live glTF instances
|
||||||
// synchronously blocked Chrome's renderer into Page Unresponsive.
|
// synchronously blocked Chrome's renderer into Page Unresponsive.
|
||||||
if (flightState._modelCollection) flightState._modelCollection.show = true;
|
flightState._renderer?.models.setVisible(true);
|
||||||
flightState._trail?.setVisible(!next);
|
flightState._trail?.setVisible(!next);
|
||||||
if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next;
|
if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next;
|
||||||
for (const [icao24, bb] of flightState._billboards)
|
for (const [icao24, bb] of flightState._billboards)
|
||||||
@@ -300,7 +289,7 @@ export function createTracking({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
flightState._trackedZoomLatched = trackedModelZoomActive(
|
flightState._trackedZoomLatched = trackedModelZoomActive(
|
||||||
flightState._viewer?.camera?.positionCartographic?.height,
|
flightState._renderer?.view.camera?.positionCartographic?.height,
|
||||||
flightState._trackedZoomLatched,
|
flightState._trackedZoomLatched,
|
||||||
);
|
);
|
||||||
return flightState._trackedZoomLatched;
|
return flightState._trackedZoomLatched;
|
||||||
@@ -309,13 +298,13 @@ export function createTracking({
|
|||||||
/** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */
|
/** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */
|
||||||
|
|
||||||
function _syncTracked2dRotation() {
|
function _syncTracked2dRotation() {
|
||||||
if (!flightState._trackedIcao || !flightState._viewer) return;
|
if (!flightState._trackedIcao || !flightState._renderer) return;
|
||||||
const pos =
|
const pos =
|
||||||
parts.motion._trackedDisplayCached() ||
|
parts.motion._trackedDisplayCached() ||
|
||||||
flightState._billboards.get(flightState._trackedIcao)?.position;
|
flightState._billboards.get(flightState._trackedIcao)?.position;
|
||||||
if (!pos) return;
|
if (!pos) return;
|
||||||
const projected = screenProjectedRotation(
|
const projected = screenProjectedRotation(
|
||||||
flightState._viewer.scene,
|
flightState._renderer.view.scene,
|
||||||
pos,
|
pos,
|
||||||
parts.motion._trackedDisplayCourse(),
|
parts.motion._trackedDisplayCourse(),
|
||||||
flightState._lastTrackedRotation,
|
flightState._lastTrackedRotation,
|
||||||
@@ -331,7 +320,7 @@ export function createTracking({
|
|||||||
/**
|
/**
|
||||||
* Append one fix to the tracked aircraft's trail accumulation and refresh
|
* Append one fix to the tracked aircraft's trail accumulation and refresh
|
||||||
* the rendered trail. Caller passes an owned (cloned) Cartesian3.
|
* the rendered trail. Caller passes an owned (cloned) Cartesian3.
|
||||||
* @param {Cesium.Cartesian3} position - New fix position, appended at the head.
|
* @param {object} position - New fix position (Cartesian3), appended at the head.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function _appendTrailFix(position) {
|
function _appendTrailFix(position) {
|
||||||
@@ -382,11 +371,11 @@ export function createTracking({
|
|||||||
const seedRenderTimeMs = appClock.now() - RENDER_DELAY_SEC * 1000;
|
const seedRenderTimeMs = appClock.now() - RENDER_DELAY_SEC * 1000;
|
||||||
for (const fix of history) {
|
for (const fix of history) {
|
||||||
if (fix.timeMs <= seedRenderTimeMs) {
|
if (fix.timeMs <= seedRenderTimeMs) {
|
||||||
flightState._trailPositions.push(Cesium.Cartesian3.clone(fix.position));
|
flightState._trailPositions.push(geo.cartesian3.clone(fix.position));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!flightState._trail && flightState._viewer) {
|
if (!flightState._trail && flightState._renderer) {
|
||||||
flightState._trail = createTrail(flightState._viewer, {
|
flightState._trail = flightState._renderer.trails.create({
|
||||||
color: TRAIL_COLOR,
|
color: TRAIL_COLOR,
|
||||||
width: 2.5,
|
width: 2.5,
|
||||||
});
|
});
|
||||||
@@ -396,60 +385,54 @@ export function createTracking({
|
|||||||
// a CallbackProperty (Cesium updates entity-polyline positions cheaply, unlike the
|
// a CallbackProperty (Cesium updates entity-polyline positions cheaply, unlike the
|
||||||
// trail primitive which fully rebuilds on setPositions). Keeps the head glued to the
|
// trail primitive which fully rebuilds on setPositions). Keeps the head glued to the
|
||||||
// 12 Hz icon instead of lagging ~1 s behind it.
|
// 12 Hz icon instead of lagging ~1 s behind it.
|
||||||
if (!flightState._trailHeadEntity && flightState._viewer) {
|
if (!flightState._trailHeadEntity && flightState._renderer) {
|
||||||
flightState._trailHeadEntity = flightState._viewer.entities.add({
|
flightState._trailHeadEntity = flightState._renderer.trails.createHead({
|
||||||
// 'gev-trail' namespace (round 6): claimed by trailRenderer's pick
|
// 'gev-trail' namespace (round 6): claimed by trailRenderer's pick
|
||||||
// owner so a click on the head segment never reads as empty space.
|
// owner so a click on the head segment never reads as empty space.
|
||||||
id: `gev-trail:fl-head-${++flightState._trailHeadSeq}`,
|
id: `gev-trail:fl-head-${++flightState._trailHeadSeq}`,
|
||||||
show: !flightState._cockpitContactMode,
|
show: !flightState._cockpitContactMode,
|
||||||
polyline: {
|
positions: () => {
|
||||||
positions: new Cesium.CallbackProperty(() => {
|
// Need ≥2 accumulated points: the body draws all-but-newest, so the head must
|
||||||
// Need ≥2 accumulated points: the body draws all-but-newest, so the head must
|
// start at the last DISPLAYED body point (index n−2). With a single fix that
|
||||||
// start at the last DISPLAYED body point (index n−2). With a single fix that
|
// point would be the sole raw fix — which is ~now, AHEAD of the delayed icon —
|
||||||
// point would be the sole raw fix — which is ~now, AHEAD of the delayed icon —
|
// so the segment would draw IN FRONT of the plane. Likewise during warm-up the
|
||||||
// so the segment would draw IN FRONT of the plane. Likewise during warm-up the
|
// icon predates all real history, so there is no valid body point behind it.
|
||||||
// icon predates all real history, so there is no valid body point behind it.
|
if (
|
||||||
if (
|
!flightState._trackedIcao ||
|
||||||
!flightState._trackedIcao ||
|
flightState._trailPositions.length < 2 ||
|
||||||
flightState._trailPositions.length < 2 ||
|
parts.motion._isTrackWarmingUp()
|
||||||
parts.motion._isTrackWarmingUp()
|
)
|
||||||
)
|
return [];
|
||||||
return [];
|
const head =
|
||||||
const head =
|
parts.motion._trackedTrailCached() ||
|
||||||
parts.motion._trackedTrailCached() ||
|
parts.motion._trackedDisplayPosition(flightState._trackedIcao);
|
||||||
parts.motion._trackedDisplayPosition(flightState._trackedIcao);
|
if (!head) return [];
|
||||||
if (!head) return [];
|
// body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a
|
||||||
// body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a
|
// backward/reversing segment.
|
||||||
// backward/reversing segment.
|
const start =
|
||||||
const start =
|
flightState._trailPositions[flightState._trailPositions.length - 2];
|
||||||
flightState._trailPositions[
|
// On a contact that has not moved this segment runs from inside the
|
||||||
flightState._trailPositions.length - 2
|
// model out to its own anchor — a line through the fuselage. The END
|
||||||
];
|
// never gives, so a moving trail still terminates on the tail; the
|
||||||
// On a contact that has not moved this segment runs from inside the
|
// START is what slides, from nothing on a parked contact out to the
|
||||||
// model out to its own anchor — a line through the fuselage. The END
|
// whole segment once it has cleared its own envelope.
|
||||||
// never gives, so a moving trail still terminates on the tail; the
|
// See trailHeadStart. Read `head` in place and clone only on the draw
|
||||||
// START is what slides, from nothing on a parked contact out to the
|
// path — a suppressed parked contact runs this every frame.
|
||||||
// whole segment once it has cleared its own envelope.
|
const from = trailHeadStart(
|
||||||
// See trailHeadStart. Read `head` in place and clone only on the draw
|
start,
|
||||||
// path — a suppressed parked contact runs this every frame.
|
head,
|
||||||
const from = trailHeadStart(
|
parts.motion._trackedModelCenterWorld(),
|
||||||
start,
|
parts.motion._trackedModelEnvelopeM(),
|
||||||
head,
|
flightState._scratchTrailHead,
|
||||||
parts.motion._trackedModelCenterWorld(),
|
);
|
||||||
parts.motion._trackedModelEnvelopeM(),
|
if (!from) return [];
|
||||||
flightState._scratchTrailHead,
|
return [from, geo.cartesian3.clone(head)];
|
||||||
);
|
|
||||||
if (!from) return [];
|
|
||||||
return [from, Cesium.Cartesian3.clone(head)];
|
|
||||||
}, false),
|
|
||||||
width: 2.5,
|
|
||||||
material: Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.9),
|
|
||||||
// Round 4: the head must never vanish into the mesh either (dimmed
|
|
||||||
// when occluded so depth still reads).
|
|
||||||
depthFailMaterial:
|
|
||||||
Cesium.Color.fromCssColorString(TRAIL_COLOR).withAlpha(0.45),
|
|
||||||
arcType: Cesium.ArcType.GEODESIC, // round 8: consistent with the trail body (no chords)
|
|
||||||
},
|
},
|
||||||
|
width: 2.5,
|
||||||
|
// Round 4: the head must never vanish into the mesh either (dimmed
|
||||||
|
// when occluded so depth still reads); round 8: geodesic, consistent
|
||||||
|
// with the trail body (no chords).
|
||||||
|
color: TRAIL_COLOR,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_refreshTrailDisplay();
|
_refreshTrailDisplay();
|
||||||
@@ -547,7 +530,7 @@ export function createTracking({
|
|||||||
// with nothing to carry keep the old 10 km airborne default.
|
// with nothing to carry keep the old 10 km airborne default.
|
||||||
if (altM == null) altM = lastAltM != null ? lastAltM : 10000;
|
if (altM == null) altM = lastAltM != null ? lastAltM : 10000;
|
||||||
lastAltM = altM;
|
lastAltM = altM;
|
||||||
older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM));
|
older.push(geo.cartesian3.fromDegrees(lon, lat, altM));
|
||||||
}
|
}
|
||||||
|
|
||||||
flightState._trailPositions = older.concat(flightState._trailPositions);
|
flightState._trailPositions = older.concat(flightState._trailPositions);
|
||||||
@@ -567,17 +550,8 @@ export function createTracking({
|
|||||||
flightState._trailBackfillToken += 1;
|
flightState._trailBackfillToken += 1;
|
||||||
flightState._trailPositions = [];
|
flightState._trailPositions = [];
|
||||||
if (flightState._trail) flightState._trail.clear();
|
if (flightState._trail) flightState._trail.clear();
|
||||||
if (
|
if (flightState._trailHeadEntity)
|
||||||
flightState._trailHeadEntity &&
|
flightState._renderer?.trails.removeHead(flightState._trailHeadEntity);
|
||||||
flightState._viewer &&
|
|
||||||
!flightState._viewer.isDestroyed()
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
flightState._viewer.entities.remove(flightState._trailHeadEntity);
|
|
||||||
} catch {
|
|
||||||
/* already gone */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flightState._trailHeadEntity = null;
|
flightState._trailHeadEntity = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,11 +627,11 @@ export function createTracking({
|
|||||||
// controls it now — clearing it would yank the camera off their plane). Releasing trackedEntity
|
// controls it now — clearing it would yank the camera off their plane). Releasing trackedEntity
|
||||||
// does NOT move the camera: Cesium resets the lookAt transform in place, so the view stays where
|
// does NOT move the camera: Cesium resets the lookAt transform in place, so the view stays where
|
||||||
// the follow left it and the user can immediately orbit/zoom.
|
// the follow left it and the user can immediately orbit/zoom.
|
||||||
if (flightState._viewer && !skipViewerUntrack) {
|
if (flightState._renderer && !skipViewerUntrack) {
|
||||||
flightState._viewer.trackedEntity = undefined;
|
flightState._renderer.follow.release();
|
||||||
}
|
}
|
||||||
if (flightState._trackedEntity) {
|
if (flightState._trackedEntity) {
|
||||||
flightState._viewer.entities.remove(flightState._trackedEntity);
|
flightState._renderer.tracked.remove(flightState._trackedEntity);
|
||||||
flightState._trackedEntity = null;
|
flightState._trackedEntity = null;
|
||||||
}
|
}
|
||||||
parts.rendering._releaseTrackedModel();
|
parts.rendering._releaseTrackedModel();
|
||||||
@@ -715,7 +689,7 @@ export function createTracking({
|
|||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
if (
|
if (
|
||||||
!flightState._billboardCollection?.show ||
|
!flightState._renderer?.fleet.visible() ||
|
||||||
!flightState._billboards.has(pending.id)
|
!flightState._billboards.has(pending.id)
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
@@ -787,14 +761,16 @@ export function createTracking({
|
|||||||
/** Re-image the tracked entity's billboard from the current class/conversion. */
|
/** Re-image the tracked entity's billboard from the current class/conversion. */
|
||||||
|
|
||||||
function _syncTrackedBillboardImage() {
|
function _syncTrackedBillboardImage() {
|
||||||
if (!flightState._trackedIcao || !flightState._trackedEntity?.billboard)
|
if (!flightState._trackedIcao || !flightState._trackedEntity) return;
|
||||||
return;
|
flightState._renderer.tracked.setImage(
|
||||||
flightState._trackedEntity.billboard.image = aircraftIcon(
|
flightState._trackedEntity,
|
||||||
parts.rendering._iconKind(
|
aircraftIcon(
|
||||||
flightState._trackedIcao,
|
parts.rendering._iconKind(
|
||||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
flightState._trackedIcao,
|
||||||
|
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||||
|
),
|
||||||
|
TRACKED_ICON_PX,
|
||||||
),
|
),
|
||||||
TRACKED_ICON_PX,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,7 +805,7 @@ export function createTracking({
|
|||||||
_syncTrackedBillboardImage();
|
_syncTrackedBillboardImage();
|
||||||
_updateTrackedLabelModel(id);
|
_updateTrackedLabelModel(id);
|
||||||
}
|
}
|
||||||
flightState._viewer?.scene?.requestRender?.();
|
flightState._renderer?.view.requestRender();
|
||||||
return flightState._billboards.has(id) || id === flightState._trackedIcao;
|
return flightState._billboards.has(id) || id === flightState._trackedIcao;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -850,15 +826,15 @@ export function createTracking({
|
|||||||
const info = flightState.records.data.get(icao24);
|
const info = flightState.records.data.get(icao24);
|
||||||
const bb = flightState._billboards.get(icao24);
|
const bb = flightState._billboards.get(icao24);
|
||||||
if (!info || !bb || !bb.position) return true;
|
if (!info || !bb || !bb.position) return true;
|
||||||
const carto = Cesium.Cartographic.fromCartesian(
|
const carto = geo.cartographic.fromCartesian(
|
||||||
bb.position,
|
bb.position,
|
||||||
Cesium.Ellipsoid.WGS84,
|
geo.ellipsoid.WGS84,
|
||||||
flightState._scratchCarto,
|
flightState._scratchCarto,
|
||||||
);
|
);
|
||||||
if (!carto) return true;
|
if (!carto) return true;
|
||||||
return routePlausible({
|
return routePlausible({
|
||||||
latDeg: Cesium.Math.toDegrees(carto.latitude),
|
latDeg: geo.math.toDegrees(carto.latitude),
|
||||||
lonDeg: Cesium.Math.toDegrees(carto.longitude),
|
lonDeg: geo.math.toDegrees(carto.longitude),
|
||||||
altitudeM: info.altitude ?? null,
|
altitudeM: info.altitude ?? null,
|
||||||
verticalRateMps: info.verticalRate ?? null,
|
verticalRateMps: info.verticalRate ?? null,
|
||||||
origin: route.origin,
|
origin: route.origin,
|
||||||
@@ -909,11 +885,6 @@ export function createTracking({
|
|||||||
const getTrackedPosition = () =>
|
const getTrackedPosition = () =>
|
||||||
parts.motion._trackedDisplayPosition(icao24) || bb.position;
|
parts.motion._trackedDisplayPosition(icao24) || bb.position;
|
||||||
|
|
||||||
// Dead-reckoning position property — smooth continuous motion between API updates.
|
|
||||||
const positionProperty = new Cesium.CallbackProperty(() => {
|
|
||||||
return getTrackedPosition();
|
|
||||||
}, false);
|
|
||||||
|
|
||||||
// Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the
|
// Create tracked entity: a 2D billboard when zoomed out, a 3D model when zoomed in past the
|
||||||
// TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the
|
// TRACKED altitude ceiling. That handoff is DEFAULT behaviour — it does NOT wait on the
|
||||||
// DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always
|
// DISPLAY-rail 3D toggle, which arms the FLEET. The plane the user zooms into always
|
||||||
@@ -929,68 +900,53 @@ export function createTracking({
|
|||||||
// The tracked entity is a PURE BILLBOARD — no label or model graphic. The 3D model for the
|
// The tracked entity is a PURE BILLBOARD — no label or model graphic. The 3D model for the
|
||||||
// tracked plane is a standalone primitive driven by _updateTrackedModel(); keeping it off the
|
// tracked plane is a standalone primitive driven by _updateTrackedModel(); keeping it off the
|
||||||
// entity is what makes the follow-camera's bounding sphere always ready (see _trackedModel).
|
// entity is what makes the follow-camera's bounding sphere always ready (see _trackedModel).
|
||||||
// Keep Cesium's generated entity ID: re-init without destroy can temporarily
|
flightState._trackedEntity = flightState._renderer.tracked.create({
|
||||||
// overlap collections, and an explicit ICAO ID would throw on that duplicate.
|
// Dead-reckoning position — smooth continuous motion between API updates.
|
||||||
flightState._trackedEntity = flightState._viewer.entities.add({
|
position: getTrackedPosition,
|
||||||
position: positionProperty,
|
image: aircraftIcon(
|
||||||
// Force Cesium's built-in EntityView and our close-range camera guard to
|
parts.rendering._iconKind(
|
||||||
// use the same local frame. AUTO can select a velocity frame while the
|
flightState._trackedIcao,
|
||||||
// model matrix uses aircraft orientation; alternating between those
|
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||||
// frames makes the target oscillate forward/back on screen.
|
|
||||||
trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU,
|
|
||||||
billboard: {
|
|
||||||
image: aircraftIcon(
|
|
||||||
parts.rendering._iconKind(
|
|
||||||
flightState._trackedIcao,
|
|
||||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
|
||||||
),
|
|
||||||
TRACKED_ICON_PX,
|
|
||||||
),
|
),
|
||||||
width: 28,
|
TRACKED_ICON_PX,
|
||||||
height: 28,
|
),
|
||||||
scale:
|
width: 28,
|
||||||
CLASS_SCALE_2D[
|
height: 28,
|
||||||
flightState.records.data.get(flightState._trackedIcao)?.klass
|
scale:
|
||||||
] || 1,
|
CLASS_SCALE_2D[
|
||||||
// Solid cyan when the billboard is the visual (zoomed out, 3D off, or model still loading);
|
flightState.records.data.get(flightState._trackedIcao)?.klass
|
||||||
// transparent once the STANDALONE tracked model is actually up (ready + shown).
|
] || 1,
|
||||||
color: new Cesium.CallbackProperty(
|
// Solid cyan when the billboard is the visual (zoomed out, 3D off, or model still loading);
|
||||||
() =>
|
// transparent once the STANDALONE tracked model is actually up (ready + shown).
|
||||||
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
color: () =>
|
||||||
? CYAN_TRANSPARENT
|
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
||||||
: Cesium.Color.CYAN,
|
? CYAN_TRANSPARENT
|
||||||
false,
|
: CYAN,
|
||||||
),
|
scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5),
|
||||||
sizeInMeters: false,
|
// The tracked target must never vanish into tile geometry — tracking a
|
||||||
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5),
|
// taxiing plane at street level would otherwise bury the cyan icon inside
|
||||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
// the runway skin exactly like the fleet ground icons (_groundDepthDistance);
|
||||||
// The tracked target must never vanish into tile geometry — tracking a
|
// its shared-host tracked card is top-composited separately.
|
||||||
// taxiing plane at street level would otherwise bury the cyan icon inside
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
// the runway skin exactly like the fleet ground icons (_groundDepthDistance);
|
// Screen-projected rotation, evaluated per frame: exact in tracked-orbit
|
||||||
// its shared-host tracked card is top-composited separately.
|
// mode where camera.heading lives in the entity's reference frame.
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
rotation: () => {
|
||||||
// Screen-projected rotation, evaluated per frame: exact in tracked-orbit
|
const tracked = flightState.records.data.get(flightState._trackedIcao);
|
||||||
// mode where camera.heading lives in the entity's reference frame.
|
const pos = getTrackedPosition();
|
||||||
rotation: new Cesium.CallbackProperty(() => {
|
if (!tracked || !pos || !flightState._renderer)
|
||||||
const tracked = flightState.records.data.get(
|
|
||||||
flightState._trackedIcao,
|
|
||||||
);
|
|
||||||
const pos = getTrackedPosition();
|
|
||||||
if (!tracked || !pos || !flightState._viewer)
|
|
||||||
return flightState._lastTrackedRotation;
|
|
||||||
const projected = screenProjectedRotation(
|
|
||||||
flightState._viewer.scene,
|
|
||||||
pos,
|
|
||||||
parts.motion._trackedDisplayCourse(),
|
|
||||||
flightState._lastTrackedRotation,
|
|
||||||
);
|
|
||||||
const rot = stabilizeScreenRotation(
|
|
||||||
flightState._lastTrackedRotation,
|
|
||||||
projected,
|
|
||||||
);
|
|
||||||
if (rot !== null) flightState._lastTrackedRotation = rot;
|
|
||||||
return flightState._lastTrackedRotation;
|
return flightState._lastTrackedRotation;
|
||||||
}, false),
|
const projected = screenProjectedRotation(
|
||||||
|
flightState._renderer.view.scene,
|
||||||
|
pos,
|
||||||
|
parts.motion._trackedDisplayCourse(),
|
||||||
|
flightState._lastTrackedRotation,
|
||||||
|
);
|
||||||
|
const rot = stabilizeScreenRotation(
|
||||||
|
flightState._lastTrackedRotation,
|
||||||
|
projected,
|
||||||
|
);
|
||||||
|
if (rot !== null) flightState._lastTrackedRotation = rot;
|
||||||
|
return flightState._lastTrackedRotation;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
flightState._trackedEntity.gevSelectionOrigin = origin;
|
flightState._trackedEntity.gevSelectionOrigin = origin;
|
||||||
@@ -1008,11 +964,11 @@ export function createTracking({
|
|||||||
Math.max((info.altitude || 1500) * 1.1 + 2500, 3000),
|
Math.max((info.altitude || 1500) * 1.1 + 2500, 3000),
|
||||||
30000,
|
30000,
|
||||||
);
|
);
|
||||||
flightState._trackedEntity.viewFrom = new Cesium.Cartesian3(
|
flightState._renderer.tracked.setViewFrom(flightState._trackedEntity, {
|
||||||
0,
|
x: 0,
|
||||||
-followRange * 0.8,
|
y: -followRange * 0.8,
|
||||||
followRange * 0.55,
|
z: followRange * 0.55,
|
||||||
);
|
});
|
||||||
|
|
||||||
// Cancel any in-progress camera flight first — otherwise Cesium won't apply the
|
// Cancel any in-progress camera flight first — otherwise Cesium won't apply the
|
||||||
// tracked entity's viewFrom on the first frame, so voice-initiated tracking (which
|
// tracked entity's viewFrom on the first frame, so voice-initiated tracking (which
|
||||||
@@ -1029,15 +985,11 @@ export function createTracking({
|
|||||||
flightState._trackedEntity.gevVisualPosition =
|
flightState._trackedEntity.gevVisualPosition =
|
||||||
parts.motion._trackedVisualCached;
|
parts.motion._trackedVisualCached;
|
||||||
refreshTrackedReadout(flightState._trackedEntity);
|
refreshTrackedReadout(flightState._trackedEntity);
|
||||||
flightState._viewer.camera.cancelFlight();
|
// Camera follows the tracked entity (the renderer cancels any flight
|
||||||
// Camera follows the tracked entity
|
// first and applies the one exact tracked frame).
|
||||||
flightState._viewer.trackedEntity = flightState._trackedEntity;
|
flightState._trackedCameraFrameStop = flightState._renderer.follow.start(
|
||||||
flightState._trackedCameraFrameStop =
|
flightState._trackedEntity,
|
||||||
applyTrackedCameraFrame(
|
);
|
||||||
flightState._viewer,
|
|
||||||
flightState._trackedEntity,
|
|
||||||
flightState._trackedEntity.viewFrom,
|
|
||||||
) || null;
|
|
||||||
|
|
||||||
// Track-history trail (PRD F1): seed from local history + async backfill.
|
// Track-history trail (PRD F1): seed from local history + async backfill.
|
||||||
// Ground traffic draws NO trail (a taxi path is noise, not a track) — if the
|
// Ground traffic draws NO trail (a taxi path is noise, not a track) — if the
|
||||||
@@ -1077,12 +1029,12 @@ export function createTracking({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function _onMilitaryActiveChange(active) {
|
function _onMilitaryActiveChange(active) {
|
||||||
if (!flightState._viewer || !flightState._billboardCollection) return;
|
if (!flightState._renderer?.fleet.attached()) return;
|
||||||
if (active) {
|
if (active) {
|
||||||
for (const [icao24, bb] of flightState._billboards) {
|
for (const [icao24, bb] of flightState._billboards) {
|
||||||
if (!isMilitaryIcao(icao24) || icao24 === flightState._trackedIcao)
|
if (!isMilitaryIcao(icao24) || icao24 === flightState._trackedIcao)
|
||||||
continue;
|
continue;
|
||||||
flightState._billboardCollection.remove(bb);
|
flightState._renderer.fleet.remove(bb);
|
||||||
flightState._billboards.delete(icao24);
|
flightState._billboards.delete(icao24);
|
||||||
parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
||||||
flightState.records.data.delete(icao24);
|
flightState.records.data.delete(icao24);
|
||||||
@@ -1093,137 +1045,122 @@ export function createTracking({
|
|||||||
flightState.records.missingPolls.delete(icao24);
|
flightState.records.missingPolls.delete(icao24);
|
||||||
}
|
}
|
||||||
flightState.feed._count = flightState._billboards.size;
|
flightState.feed._count = flightState._billboards.size;
|
||||||
} else if (flightState._billboardCollection.show) {
|
} else if (flightState._renderer.fleet.visible()) {
|
||||||
// Fire-and-forget refresh; only while the layer is actually enabled.
|
// Fire-and-forget refresh; only while the layer is actually enabled.
|
||||||
void flightsLayer.update(flightState._viewer);
|
// The query reads the renderer's camera (no viewer argument needed).
|
||||||
|
void flightsLayer.update(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global keydown handler — Escape deselects the tracked flight.
|
* Escape (through the selection dispatcher) deselects the tracked flight.
|
||||||
* @param {KeyboardEvent} e
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function _onKeyDown(e) {
|
function _onEscape() {
|
||||||
if (e.key === 'Escape' && flightState._trackedIcao) {
|
if (flightState._trackedIcao) {
|
||||||
_cancelPendingTrackingRestore();
|
_cancelPendingTrackingRestore();
|
||||||
_clearTracking(false, { origin: 'user' });
|
_clearTracking(false, { origin: 'user' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install a LEFT_CLICK handler on the scene canvas for flight selection.
|
* A click the selection dispatcher hands this layer: one of its aircraft
|
||||||
*
|
* (a fleet sprite or model, or the tracked plane's own visual). The
|
||||||
* Picking logic checks both `picked.primitive` and `picked.id` because
|
* dispatcher has already applied the gesture rules (a drag selects
|
||||||
* different CesiumJS versions surface BillboardCollection hits differently.
|
* nothing; isTrackingSelectionGesture) and the pointer claim.
|
||||||
* Also registers a global keydown listener for the Escape key.
|
* @param {string} id - The picked id this layer owns.
|
||||||
*
|
|
||||||
* Idempotent — returns immediately if a handler is already installed.
|
|
||||||
*
|
|
||||||
* @param {Cesium.Viewer} viewer
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function _installClickHandler(viewer) {
|
function _onSelect(id) {
|
||||||
if (flightState._clickHandler) return; // already installed
|
// Cockpit mode owns the camera and keeps the current aircraft as its
|
||||||
|
// first-person reference: a globe click selects nothing here.
|
||||||
|
if (document.body.classList.contains('cockpit-mode')) return;
|
||||||
|
// Clicking the tracked plane itself — its tracked visual or its
|
||||||
|
// standalone 3D model (pick id = its icao, H1) — is a no-op, not a
|
||||||
|
// deselect and not a re-track.
|
||||||
|
if (
|
||||||
|
id === flightState._trackedEntity?.id ||
|
||||||
|
(flightState._trackedIcao && id === flightState._trackedIcao)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (flightState._billboards.has(id)) {
|
||||||
|
_cancelPendingTrackingRestore();
|
||||||
|
_trackFlight(id, { origin: 'user' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty space (or a pick no layer owns). The dispatcher calls this only
|
||||||
|
* for a clean, short click (isTrackingClickGesture): a slow stationary
|
||||||
|
* press may select, but cannot release existing tracking. A pick another
|
||||||
|
* layer owns never reaches here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function _onEmptyClick() {
|
||||||
|
if (document.body.classList.contains('cockpit-mode')) return;
|
||||||
|
if (flightState._trackedIcao) {
|
||||||
|
_cancelPendingTrackingRestore();
|
||||||
|
_clearTracking(false, { origin: 'user' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join the engine's selection dispatcher (clicks with picks, Escape) and
|
||||||
|
* watch the follow camera for another layer taking it.
|
||||||
|
*
|
||||||
|
* Idempotent — returns immediately if already joined.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function _installClickHandler() {
|
||||||
|
const renderer = flightState._renderer;
|
||||||
|
if (!renderer) return;
|
||||||
|
|
||||||
// Cross-layer untrack: if ANOTHER layer (military, vessels, …) grabs the follow-camera, drop our
|
// Cross-layer untrack: if ANOTHER layer (military, vessels, …) grabs the follow-camera, drop our
|
||||||
// tracking so its model/entity/update-loop don't orphan — without touching viewer.trackedEntity
|
// tracking so its model/entity/update-loop don't orphan — without touching the follow target
|
||||||
// (the new owner controls it). Guarded so the intermediate untrack→retrack of OUR OWN switch
|
// (the new owner controls it). Guarded so the intermediate untrack→retrack of OUR OWN switch
|
||||||
// (viewer.trackedEntity briefly undefined) doesn't self-clear.
|
// (the follow target briefly empty) doesn't self-clear.
|
||||||
if (!flightState._trackedEntityChangedRemove) {
|
if (!flightState._trackedEntityChangedRemove) {
|
||||||
flightState._trackedEntityChangedRemove =
|
flightState._trackedEntityChangedRemove = renderer.follow.onChanged(
|
||||||
viewer.trackedEntityChanged.addEventListener(() => {
|
() => {
|
||||||
|
const current = flightState._renderer?.follow.current();
|
||||||
if (
|
if (
|
||||||
flightState._trackedIcao &&
|
flightState._trackedIcao &&
|
||||||
flightState._viewer &&
|
current &&
|
||||||
flightState._viewer.trackedEntity &&
|
current !== flightState._trackedEntity
|
||||||
flightState._viewer.trackedEntity !== flightState._trackedEntity
|
|
||||||
) {
|
) {
|
||||||
_clearTracking(true, {
|
_clearTracking(true, {
|
||||||
origin:
|
origin: current?.gevSelectionOrigin || 'programmatic',
|
||||||
flightState._viewer.trackedEntity?.gevSelectionOrigin ||
|
|
||||||
'programmatic',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
flightState._clickHandler = new Cesium.ScreenSpaceEventHandler(
|
if (flightState._selectionRemove) return; // already joined
|
||||||
viewer.scene.canvas,
|
const dispatcher = selectionDispatcherFor(renderer.engine);
|
||||||
);
|
if (!dispatcher) return;
|
||||||
bindTrackingClickGesture(flightState._clickHandler, (click, gesture) => {
|
flightState._selectionRemove = dispatcher.register('flights', {
|
||||||
// Camera drags never select or deselect, even if they finish over a plane.
|
// The fleet's icaos, and the tracked visual's own id: clicking it is
|
||||||
// Duration alone is allowed through so a stationary long press can still
|
// this layer's no-op, never empty space.
|
||||||
// select/switch contacts; the destructive empty-space branch below applies
|
owns: (id) =>
|
||||||
// the full travel + duration click classifier.
|
flightState._billboards.has(id) ||
|
||||||
if (!isTrackingSelectionGesture(gesture)) return;
|
(!!flightState._trackedEntity &&
|
||||||
// Cockpit mode owns the camera and keeps the current aircraft as its
|
id === String(flightState._trackedEntity.id)),
|
||||||
// first-person reference. A globe click must not fall through to the
|
onSelect: _onSelect,
|
||||||
// normal empty-space deselection path; cockpit has explicit exit controls.
|
onEmptyClick: _onEmptyClick,
|
||||||
if (document.body.classList.contains('cockpit-mode')) return;
|
onEscape: _onEscape,
|
||||||
const picked = viewer.scene.pick(click.position);
|
|
||||||
|
|
||||||
if (picked) {
|
|
||||||
// Clicking the tracked entity itself — ignore (don't deselect)
|
|
||||||
if (picked.id === flightState._trackedEntity) return;
|
|
||||||
|
|
||||||
// Clicking the plane we're ALREADY tracking (its standalone 3D model or
|
|
||||||
// any pick carrying its icao) — same no-op as the 2D tracked-entity click
|
|
||||||
// above. H1: the model used to have no pick id, so this fell through to
|
|
||||||
// "empty space" and deselected the very plane being tracked.
|
|
||||||
if (flightState._trackedIcao) {
|
|
||||||
const rawPick =
|
|
||||||
typeof picked.id === 'string' ? picked.id : picked.primitive?.id;
|
|
||||||
if (
|
|
||||||
picked.primitive === flightState._trackedModel ||
|
|
||||||
rawPick === flightState._trackedIcao
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For BillboardCollection picks, the billboard may be at picked.primitive or picked.id
|
|
||||||
const billboard = picked.primitive;
|
|
||||||
if (
|
|
||||||
billboard &&
|
|
||||||
billboard.id &&
|
|
||||||
flightState._billboards.has(billboard.id)
|
|
||||||
) {
|
|
||||||
_cancelPendingTrackingRestore();
|
|
||||||
_trackFlight(billboard.id, { origin: 'user' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Some CesiumJS versions surface the id as a string on picked.id instead
|
|
||||||
if (
|
|
||||||
picked.id &&
|
|
||||||
typeof picked.id === 'string' &&
|
|
||||||
flightState._billboards.has(picked.id)
|
|
||||||
) {
|
|
||||||
_cancelPendingTrackingRestore();
|
|
||||||
_trackFlight(picked.id, { origin: 'user' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A pick that belongs to a sibling layer (military aircraft, satellite,
|
|
||||||
// vessel, station, CCTV camera…) is not "empty space" — leave tracking
|
|
||||||
// alone and let that layer handle it. resolvePickId String()-coerces the
|
|
||||||
// heterogeneous pick ids (numeric NORAD ids, AIS record objects) so the
|
|
||||||
// registry predicates can recognize them (H2).
|
|
||||||
if (picked) {
|
|
||||||
const pickedId = resolvePickId(picked);
|
|
||||||
if (pickedId && isOwnedByOtherLayer('flights', pickedId)) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clicked empty space — deselect only for a clean, short click. A slow
|
|
||||||
// stationary press may select above, but cannot release existing tracking.
|
|
||||||
if (!isTrackingClickGesture(gesture)) return;
|
|
||||||
if (flightState._trackedIcao) {
|
|
||||||
_cancelPendingTrackingRestore();
|
|
||||||
_clearTracking(false, { origin: 'user' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', _onKeyDown);
|
/** Leave the selection dispatcher and stop watching the follow camera. */
|
||||||
|
|
||||||
|
function _removeClickHandler() {
|
||||||
|
flightState._selectionRemove?.();
|
||||||
|
flightState._selectionRemove = null;
|
||||||
|
if (flightState._trackedEntityChangedRemove) {
|
||||||
|
flightState._trackedEntityChangedRemove();
|
||||||
|
flightState._trackedEntityChangedRemove = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
_emitAwarenessEvent,
|
_emitAwarenessEvent,
|
||||||
@@ -1256,7 +1193,10 @@ export function createTracking({
|
|||||||
_routeIsPlausible,
|
_routeIsPlausible,
|
||||||
_trackFlight,
|
_trackFlight,
|
||||||
_onMilitaryActiveChange,
|
_onMilitaryActiveChange,
|
||||||
_onKeyDown,
|
_onEscape,
|
||||||
|
_onSelect,
|
||||||
|
_onEmptyClick,
|
||||||
_installClickHandler,
|
_installClickHandler,
|
||||||
|
_removeClickHandler,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Engine-neutral layer colours (phase 3): the colour constants the layers
|
||||||
|
// used to build as Cesium.Color, without Cesium.
|
||||||
|
//
|
||||||
|
// An Rgba has Cesium.Color's fields and the methods the layers call on their
|
||||||
|
// colours (`withAlpha`, `clone`, `equals`), so it is accepted wherever a
|
||||||
|
// Cesium property clones a colour from its fields (Billboard.color,
|
||||||
|
// Model.color, a billboard graphics' colour), and a renderer on another
|
||||||
|
// engine reads the same four numbers (`rgba()` gives them as an array).
|
||||||
|
// Components are 0..1 floats computed exactly as Cesium computes them
|
||||||
|
// (`fromCssColorString`: byte / 255), so a constant here equals the
|
||||||
|
// Cesium.Color it replaces bit for bit (color.test.mjs).
|
||||||
|
|
||||||
|
export class Rgba {
|
||||||
|
constructor(red = 1, green = 1, blue = 1, alpha = 1) {
|
||||||
|
this.red = red;
|
||||||
|
this.green = green;
|
||||||
|
this.blue = blue;
|
||||||
|
this.alpha = alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same colour with `alpha` (Cesium.Color#withAlpha). */
|
||||||
|
withAlpha(alpha, result) {
|
||||||
|
if (!result) return new Rgba(this.red, this.green, this.blue, alpha);
|
||||||
|
result.red = this.red;
|
||||||
|
result.green = this.green;
|
||||||
|
result.blue = this.blue;
|
||||||
|
result.alpha = alpha;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
clone(result) {
|
||||||
|
return this.withAlpha(this.alpha, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field equality with an Rgba or a Cesium.Color. */
|
||||||
|
equals(other) {
|
||||||
|
return (
|
||||||
|
this === other ||
|
||||||
|
(!!other &&
|
||||||
|
this.red === other.red &&
|
||||||
|
this.green === other.green &&
|
||||||
|
this.blue === other.blue &&
|
||||||
|
this.alpha === other.alpha)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `[red, green, blue, alpha]`, 0..1. */
|
||||||
|
rgba() {
|
||||||
|
return [this.red, this.green, this.blue, this.alpha];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEX = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A colour from `#rrggbb` or `#rrggbbaa`, as Cesium.Color.fromCssColorString
|
||||||
|
* reads it.
|
||||||
|
* @param {string} css
|
||||||
|
* @returns {Rgba}
|
||||||
|
*/
|
||||||
|
export function rgbaFromCss(css) {
|
||||||
|
const match = HEX.exec(String(css).trim());
|
||||||
|
if (!match) throw new TypeError(`Unsupported colour: ${css}`);
|
||||||
|
return new Rgba(
|
||||||
|
parseInt(match[1], 16) / 255.0,
|
||||||
|
parseInt(match[2], 16) / 255.0,
|
||||||
|
parseInt(match[3], 16) / 255.0,
|
||||||
|
parseInt(match[4] ?? 'ff', 16) / 255.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A frozen colour constant. */
|
||||||
|
export const constantRgba = (red, green, blue, alpha = 1) =>
|
||||||
|
Object.freeze(new Rgba(red, green, blue, alpha));
|
||||||
|
|
||||||
|
/** Cesium.Color.WHITE. */
|
||||||
|
export const WHITE = constantRgba(1, 1, 1, 1);
|
||||||
|
/** Cesium.Color.CYAN (#00FFFF). */
|
||||||
|
export const CYAN = constantRgba(0, 1, 1, 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A distance scale for sprites (Cesium.NearFarScalar's fields): the scale is
|
||||||
|
* `nearValue` at `near` metres from the camera and `farValue` at `far`.
|
||||||
|
* Plain data, accepted by Cesium's billboard setters, which copy the fields.
|
||||||
|
*/
|
||||||
|
export function nearFarScale(near, nearValue, far, farValue) {
|
||||||
|
return { near, nearValue, far, farValue };
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
CYAN,
|
||||||
|
Rgba,
|
||||||
|
WHITE,
|
||||||
|
constantRgba,
|
||||||
|
nearFarScale,
|
||||||
|
rgbaFromCss,
|
||||||
|
} from './color.js';
|
||||||
|
|
||||||
|
const fields = (color) => [color.red, color.green, color.blue, color.alpha];
|
||||||
|
|
||||||
|
test('css colours equal Cesium.Color.fromCssColorString bit for bit', () => {
|
||||||
|
for (const css of ['#FFB800', '#DCEEFF', '#00d4ff', '#39d0ff', '#ffd16680']) {
|
||||||
|
assert.deepEqual(
|
||||||
|
fields(rgbaFromCss(css)),
|
||||||
|
fields(Cesium.Color.fromCssColorString(css)),
|
||||||
|
css,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.throws(() => rgbaFromCss('teal'), /Unsupported colour/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the named constants equal Cesium.Color.WHITE and CYAN', () => {
|
||||||
|
assert.deepEqual(fields(WHITE), fields(Cesium.Color.WHITE));
|
||||||
|
assert.deepEqual(fields(CYAN), fields(Cesium.Color.CYAN));
|
||||||
|
assert.ok(Object.isFrozen(WHITE) && Object.isFrozen(CYAN));
|
||||||
|
assert.ok(CYAN.equals(Cesium.Color.CYAN));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('withAlpha, clone and equals behave as Cesium.Color', () => {
|
||||||
|
const amber = rgbaFromCss('#FFB800');
|
||||||
|
const cesium = Cesium.Color.fromCssColorString('#FFB800');
|
||||||
|
assert.deepEqual(
|
||||||
|
fields(amber.withAlpha(0.45)),
|
||||||
|
fields(cesium.withAlpha(0.45)),
|
||||||
|
);
|
||||||
|
const scratch = new Rgba();
|
||||||
|
assert.equal(amber.withAlpha(0.2, scratch), scratch);
|
||||||
|
assert.deepEqual(fields(scratch), fields(cesium.withAlpha(0.2)));
|
||||||
|
assert.deepEqual(
|
||||||
|
fields(CYAN.withAlpha(0)),
|
||||||
|
fields(Cesium.Color.CYAN.withAlpha(0)),
|
||||||
|
);
|
||||||
|
assert.ok(amber.clone().equals(amber));
|
||||||
|
assert.notEqual(amber.clone(), amber);
|
||||||
|
assert.deepEqual(amber.rgba(), fields(cesium));
|
||||||
|
assert.ok(Object.isFrozen(constantRgba(1, 0, 0)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cesium billboards and models take an Rgba as their colour', () => {
|
||||||
|
const collection = new Cesium.BillboardCollection();
|
||||||
|
const billboard = collection.add({
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(0, 0, 10),
|
||||||
|
color: rgbaFromCss('#FFB800').withAlpha(0.5),
|
||||||
|
scaleByDistance: nearFarScale(1000, 3, 8_000_000, 0.5),
|
||||||
|
});
|
||||||
|
assert.ok(billboard.color instanceof Cesium.Color);
|
||||||
|
assert.ok(
|
||||||
|
billboard.color.equals(
|
||||||
|
Cesium.Color.fromCssColorString('#FFB800').withAlpha(0.5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(billboard.scaleByDistance instanceof Cesium.NearFarScalar);
|
||||||
|
assert.ok(
|
||||||
|
Cesium.NearFarScalar.equals(
|
||||||
|
billboard.scaleByDistance,
|
||||||
|
new Cesium.NearFarScalar(1000, 3, 8_000_000, 0.5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
billboard.color = CYAN;
|
||||||
|
assert.ok(billboard.color.equals(Cesium.Color.CYAN));
|
||||||
|
assert.notEqual(billboard.color, CYAN, 'the billboard keeps its own copy');
|
||||||
|
collection.destroy();
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||||
import { dirname, join, basename } from 'node:path';
|
import { dirname, join, basename } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
@@ -45,9 +45,19 @@ export function readLayerSource(file) {
|
|||||||
? '../layers/cctv'
|
? '../layers/cctv'
|
||||||
: '../layers/vessels',
|
: '../layers/vessels',
|
||||||
);
|
);
|
||||||
return readdirSync(directory)
|
// A layer's code includes its renderers (render/*.js, phase 3) and, for
|
||||||
.filter((name) => name.endsWith('.js'))
|
// the aircraft layers, the shared aircraft renderer they draw through:
|
||||||
.sort()
|
// checks on what the layer does keep finding it where it moved.
|
||||||
.map((name) => readFileSync(join(directory, name), 'utf8'))
|
const directories = [directory, join(directory, 'render')];
|
||||||
|
if (['flights.js', 'militaryFlights.js'].includes(basename(path)))
|
||||||
|
directories.push(join(directory, '../aircraft/render'));
|
||||||
|
return directories
|
||||||
|
.filter((dir) => existsSync(dir))
|
||||||
|
.flatMap((dir) =>
|
||||||
|
readdirSync(dir)
|
||||||
|
.filter((name) => name.endsWith('.js'))
|
||||||
|
.sort()
|
||||||
|
.map((name) => readFileSync(join(dir, name), 'utf8')),
|
||||||
|
)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
@@ -296,6 +296,43 @@ test('the app reaches the Navara engine only through a dynamic import', (t) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('layers moved onto renderers keep each engine in its render file', (t) => {
|
||||||
|
const { root, write } = fixture(t);
|
||||||
|
write('src/layers/flights/rendering.js', "import './render/cesium.js';");
|
||||||
|
write('src/layers/flights/render/cesium.js', "import 'cesium';");
|
||||||
|
write(
|
||||||
|
'src/layers/flights/render/navara.js',
|
||||||
|
"import 'three'; import '@navaramap/three';",
|
||||||
|
);
|
||||||
|
write('src/layers/aircraft/render/cesium.js', "import 'cesium';");
|
||||||
|
write('src/layers/flights/render/cesium.test.mjs', "import 'cesium';");
|
||||||
|
write(
|
||||||
|
'src/app/navaraMode.js',
|
||||||
|
"export const load = () => import('../layers/flights/render/navara.js');",
|
||||||
|
);
|
||||||
|
// A layer not moved yet still imports cesium anywhere (until its *a task).
|
||||||
|
write('src/layers/vessels/rendering.js', "import 'cesium';");
|
||||||
|
assert.doesNotThrow(() => checkImportDirections(root));
|
||||||
|
|
||||||
|
write('src/layers/flights/tracking.js', "import 'cesium';");
|
||||||
|
assert.throws(
|
||||||
|
() => checkImportDirections(root),
|
||||||
|
/src\/layers\/flights\/tracking\.js: cesium only in the layer's render\/cesium\*\.js: cesium/,
|
||||||
|
);
|
||||||
|
write('src/layers/flights/tracking.js', '');
|
||||||
|
write('src/layers/aircraft/render/cesium.js', "import 'three';");
|
||||||
|
assert.throws(
|
||||||
|
() => checkImportDirections(root),
|
||||||
|
/src\/layers\/aircraft\/render\/cesium\.js: three and Navara only in the layer's render\/navara\*\.js: three/,
|
||||||
|
);
|
||||||
|
write('src/layers/aircraft/render/cesium.js', '');
|
||||||
|
write('src/app/data.js', "import '../layers/flights/render/navara.js';");
|
||||||
|
assert.throws(
|
||||||
|
() => checkImportDirections(root),
|
||||||
|
/src\/app\/data\.js: A Navara layer renderer loads only through the Navara mode's dynamic import\(\): src\/layers\/flights\/render\/navara\.js/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('geo modules may import each other, and its tests may import cesium', (t) => {
|
test('geo modules may import each other, and its tests may import cesium', (t) => {
|
||||||
const { root, write } = fixture(t);
|
const { root, write } = fixture(t);
|
||||||
write('src/geo/math.js', 'export const half = (value) => value / 2;');
|
write('src/geo/math.js', 'export const half = (value) => value / 2;');
|
||||||
|
|||||||
Reference in new issue
Block a user