mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +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\/)/;
|
||||
// Navara renders through three.js and its postprocessing peer.
|
||||
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) =>
|
||||
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
|
||||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
|
||||
@@ -123,6 +135,18 @@ export function checkImportDirections(root) {
|
||||
target = path.resolve(root, exported);
|
||||
} else {
|
||||
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 (
|
||||
file.startsWith('src/') &&
|
||||
(builtins.has(specifier) || specifier.startsWith('node:'))
|
||||
@@ -157,6 +181,20 @@ export function checkImportDirections(root) {
|
||||
file,
|
||||
`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/'))
|
||||
report(file, `Browser imports server: ${to}`);
|
||||
if (
|
||||
|
||||
@@ -321,6 +321,13 @@
|
||||
"src/layers/flights/testing.js",
|
||||
"src/layers/flights/evidence.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/index.js",
|
||||
"src/layers/flights/enrichment.js",
|
||||
|
||||
@@ -896,7 +896,8 @@
|
||||
"src/geo/developerError.js",
|
||||
"src/time/clock.js",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cesium/engineFromViewer.js"
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/layers/shared/color.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -2587,7 +2588,11 @@
|
||||
"src/globe/surfaceDetail.js",
|
||||
"src/globe/navara/followGestures.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": [
|
||||
"hls.js",
|
||||
@@ -3158,7 +3163,11 @@
|
||||
"src/globe/follow.js",
|
||||
"src/globe/cesium/follow.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": [
|
||||
"hls.js",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createCivilFlightLayer } from '../../layers/flights/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as trails from '../../data/trailRenderer.js';
|
||||
import * as selection from '../../data/selectionDispatcher.js';
|
||||
import * as aircraftPresentation from '../../data/tr3bRegistry.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 geoid from '../../data/geoid.js';
|
||||
import * as focus from '../../data/focusDeemphasis.js';
|
||||
@@ -20,6 +20,13 @@ export function createApplicationFlights({
|
||||
militaryRegistry,
|
||||
resolveAsset = (url) =>
|
||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||
// Test seam: the renderer factory (default: the engine's, from the registry).
|
||||
renderer = {
|
||||
create: ({ viewer, engine = null, adopt }) =>
|
||||
engine
|
||||
? rendererFor('flights', engine, { viewer, adopt })
|
||||
: createFlightsCesiumRenderer(null, { viewer, adopt }),
|
||||
},
|
||||
}) {
|
||||
const { groundFloor, meshFloor, groundSnap } = surface;
|
||||
return createCivilFlightLayer({
|
||||
@@ -27,10 +34,12 @@ export function createApplicationFlights({
|
||||
resolveAsset,
|
||||
services: {
|
||||
picking,
|
||||
sprites,
|
||||
trails,
|
||||
selection,
|
||||
// The engine's flights renderer from the registry (render/cesium.js on
|
||||
// Cesium); a bare viewer without an engine (unit tests) draws on Cesium.
|
||||
renderer,
|
||||
aircraftPresentation,
|
||||
camera: { ...trackedCamera, applyTrackedCameraFrame },
|
||||
camera: trackedCamera,
|
||||
militaryRegistry,
|
||||
labels,
|
||||
groundFloor,
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
// provider (`{ engine, unavailable() }` is the part it reads).
|
||||
|
||||
import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
|
||||
import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js';
|
||||
|
||||
const ENGINE_KINDS = new Set(['cesium', 'navara']);
|
||||
|
||||
@@ -81,11 +82,19 @@ export const rendererFor = (layerId, engine, options) =>
|
||||
export const 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(
|
||||
'cesium',
|
||||
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 {
|
||||
CESIUM_BUILTIN,
|
||||
CESIUM_LAYER_RENDERERS,
|
||||
rendererFor,
|
||||
createLayerAvailability,
|
||||
createRendererRegistry,
|
||||
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', () => {
|
||||
const registry = createRendererRegistry();
|
||||
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
|
||||
// alone would report a preserved subject as FRESH from hidden stale data.
|
||||
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)),
|
||||
/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)),
|
||||
/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)),
|
||||
|
||||
@@ -371,7 +371,7 @@ function normalBillboardScaleByDistance(sourceFile) {
|
||||
const src = readLayerSource(path.join(ROOT, sourceFile));
|
||||
const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/);
|
||||
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`);
|
||||
return scalar.slice(1).map(Number);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRendering as createFirmsRendering } from '../layers/firms/rendering.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
registerSpriteCollection,
|
||||
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]);
|
||||
|
||||
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(firmsLayer.enable.toString(), /restoreSpriteOrderOnEnable\('firms', viewer\)/);
|
||||
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('./militaryFlights.js', import.meta.url)),
|
||||
];
|
||||
for (const source of sources) {
|
||||
assert.match(source, /isTrackingSelectionGesture\(gesture\)[\s\S]+scene\.pick/);
|
||||
assert.match(source, /isTrackingClickGesture\(gesture\)[\s\S]+(?:parts\.\w+\.)?_clearTracking\([^)]*\{ origin: 'user' \}\)/);
|
||||
}
|
||||
// Military still owns its handler; flights moved to the selection
|
||||
// dispatcher (phase 3), whose source applies the same two rules: the
|
||||
// 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(
|
||||
sources[0],
|
||||
/(?: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();
|
||||
}
|
||||
|
||||
function _flightQuery(viewer) {
|
||||
const cartographic = viewer?.camera?.positionCartographic;
|
||||
/** The feed query around the camera of `view` (a viewer or the renderer's view). */
|
||||
function _flightQuery(view) {
|
||||
const cartographic = view?.camera?.positionCartographic;
|
||||
return cartographic
|
||||
? {
|
||||
latitude: geo.math.toDegrees(cartographic.latitude),
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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 {
|
||||
ENRICH_MAX_INFLIGHT,
|
||||
@@ -181,32 +179,21 @@ export function createEnrichment({
|
||||
_refillAmbientBudget(Date.now());
|
||||
if (
|
||||
flightState._enrichAmbientBudget <= 0 ||
|
||||
!flightState._viewer ||
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show
|
||||
!flightState._renderer?.fleet.visible()
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const camera = flightState._viewer.camera;
|
||||
const camPos = camera.positionWC;
|
||||
const occluder = horizonOccluder(camera);
|
||||
const cull = camera.frustum.computeCullingVolume(
|
||||
camPos,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
const view = flightState._renderer.view;
|
||||
const camPos = view.camera.positionWC;
|
||||
const occluder = view.horizon();
|
||||
const inFrustum = view.frustumTester();
|
||||
const cand = [];
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
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 (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
|
||||
geo.cartesian3.clone(bb.position, flightState._scratchModelBS.center);
|
||||
if (
|
||||
cull.computeVisibility(flightState._scratchModelBS) ===
|
||||
Cesium.Intersect.OUTSIDE
|
||||
)
|
||||
continue; // off-screen
|
||||
if (!inFrustum(bb.position)) continue; // off-screen
|
||||
cand.push([
|
||||
icao24,
|
||||
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 { FOCUS_EVIDENCE_DEV } from './policy.js';
|
||||
|
||||
@@ -18,7 +18,7 @@ export function createEvidence({
|
||||
cartesian.length >= 3 &&
|
||||
cartesian.slice(0, 3).every(Number.isFinite)
|
||||
) {
|
||||
return Cesium.Cartesian3.fromElements(
|
||||
return geo.cartesian3.fromElements(
|
||||
cartesian[0],
|
||||
cartesian[1],
|
||||
cartesian[2],
|
||||
@@ -29,7 +29,7 @@ export function createEvidence({
|
||||
!Number.isFinite(record?.latitude)
|
||||
)
|
||||
return null;
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
return geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
Number.isFinite(record.altitudeM) ? record.altitudeM : 3_000,
|
||||
@@ -39,16 +39,12 @@ export function createEvidence({
|
||||
/** Replace the real fleet with deterministic explicit-position contacts. */
|
||||
|
||||
function _setFocusEvidenceAircraft(records = []) {
|
||||
if (
|
||||
!FOCUS_EVIDENCE_DEV ||
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._viewer
|
||||
)
|
||||
if (!FOCUS_EVIDENCE_DEV || !flightState._renderer?.fleet.attached())
|
||||
return { ok: false, count: 0 };
|
||||
if (flightState._trackedIcao) parts.tracking._clearTracking();
|
||||
parts.rendering._releaseModels();
|
||||
for (const bb of flightState._billboards.values())
|
||||
flightState._billboardCollection.remove(bb);
|
||||
flightState._renderer.fleet.remove(bb);
|
||||
flightState._billboards.clear();
|
||||
flightState.records.data.clear();
|
||||
flightState._positionHistory.clear();
|
||||
@@ -65,7 +61,7 @@ export function createEvidence({
|
||||
const klass = record.klass || 'airliner';
|
||||
const altitudeM = Number.isFinite(record.altitudeM)
|
||||
? record.altitudeM
|
||||
: Cesium.Cartographic.fromCartesian(position)?.height || 3_000;
|
||||
: geo.cartographic.fromCartesian(position)?.height || 3_000;
|
||||
const meta = {
|
||||
callsign: String(record.callsign || id).toUpperCase(),
|
||||
altitude: altitudeM,
|
||||
@@ -83,14 +79,14 @@ export function createEvidence({
|
||||
flightState.records.data.set(id, meta);
|
||||
flightState._cullPositions.delete(id);
|
||||
flightState._focusEvidenceIds.add(id);
|
||||
const bb = flightState._billboardCollection.add({
|
||||
const bb = flightState._renderer.fleet.add({
|
||||
position,
|
||||
image: aircraftIcon(parts.rendering._iconKind(id, klass)),
|
||||
width: 20,
|
||||
height: 20,
|
||||
scale: parts.rendering._fleetBillboardScale(id, klass),
|
||||
rotation: 0,
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
alignedAxis: geo.cartesian3.ZERO,
|
||||
color: parts.rendering._fleetBillboardColor(id),
|
||||
sizeInMeters: false,
|
||||
scaleByDistance: parts.rendering._normalBillboardScaleByDistance(),
|
||||
@@ -102,7 +98,7 @@ export function createEvidence({
|
||||
}
|
||||
flightState.feed._count = flightState._billboards.size;
|
||||
flightState._lastFleetTickMs = 0;
|
||||
flightState._viewer.scene.requestRender?.();
|
||||
flightState._renderer.view.requestRender();
|
||||
return { ok: true, count: flightState.feed._count };
|
||||
}
|
||||
|
||||
@@ -129,22 +125,18 @@ export function createEvidence({
|
||||
moved += 1;
|
||||
}
|
||||
flightState._lastFleetTickMs = 0;
|
||||
flightState._viewer?.scene?.requestRender?.();
|
||||
flightState._renderer?.view.requestRender();
|
||||
return { ok: true, moved };
|
||||
}
|
||||
|
||||
/** JSON-safe visual snapshot for the evidence report. */
|
||||
|
||||
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) => {
|
||||
const bb = flightState._billboards.get(id);
|
||||
const screen = bb?.position
|
||||
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
flightState._viewer.scene,
|
||||
bb.position,
|
||||
)
|
||||
: null;
|
||||
const screen = bb?.position ? view.worldToWindow(bb.position) : null;
|
||||
return {
|
||||
id,
|
||||
show: bb?.show === true,
|
||||
@@ -153,10 +145,7 @@ export function createEvidence({
|
||||
x: screen?.x ?? null,
|
||||
y: screen?.y ?? null,
|
||||
cameraDistanceM: bb?.position
|
||||
? Cesium.Cartesian3.distance(
|
||||
flightState._viewer.camera.positionWC,
|
||||
bb.position,
|
||||
)
|
||||
? geo.cartesian3.distance(view.camera.positionWC, bb.position)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ export function createCivilFlightLayer({
|
||||
parts.ingestion = createIngestion({
|
||||
feed: flightState.feed,
|
||||
getQuery: (viewer) =>
|
||||
parts.controller._flightQuery(viewer || flightState._viewer),
|
||||
parts.controller._flightQuery(viewer || flightState._renderer?.view),
|
||||
applySnapshot,
|
||||
setSourceLabel: (source) => {
|
||||
layer.source = source;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { PLANE_MODEL_URL } from './policy.js';
|
||||
|
||||
export function createLifecycle({
|
||||
@@ -9,11 +8,6 @@ export function createLifecycle({
|
||||
resolveAsset,
|
||||
}) {
|
||||
const { clearFocusTarget } = services.focus;
|
||||
const {
|
||||
registerSpriteCollection,
|
||||
restoreSpriteOrder,
|
||||
restoreSpriteOrderOnEnable,
|
||||
} = services.sprites;
|
||||
const { onMilitaryLayerActiveChange } = services.militaryRegistry;
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
const { ensureGeoidReady } = services.geoid;
|
||||
@@ -22,7 +16,7 @@ export function createLifecycle({
|
||||
const methods = {
|
||||
/** Configure the source before initialization; an active layer keeps its owner. */
|
||||
setSource(source) {
|
||||
if (flightState._viewer)
|
||||
if (flightState._renderer)
|
||||
throw new Error('Configure the source before layer initialization');
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('A snapshot source is required');
|
||||
@@ -34,12 +28,14 @@ export function createLifecycle({
|
||||
|
||||
/**
|
||||
* Initialize the flights layer.
|
||||
* Creates the BillboardCollection, resets all state, and installs the
|
||||
* click-to-track handler on the scene canvas.
|
||||
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
|
||||
* Creates the engine's aircraft renderer (fleet sprites, models), resets
|
||||
* all state, and joins the engine's selection dispatcher.
|
||||
* @param {object|null} viewer - The Cesium viewer (null on an engine
|
||||
* without one).
|
||||
* @param {{ engine?: object }} [context] - LayerLifecycle's context.
|
||||
*/
|
||||
init(viewer) {
|
||||
if (flightState._viewer)
|
||||
init(viewer, { engine = null } = {}) {
|
||||
if (flightState._renderer)
|
||||
throw new Error('Flight layer is already initialized');
|
||||
if (typeof flightState.feed._source?.getSnapshot !== 'function')
|
||||
throw new TypeError('A snapshot source is required');
|
||||
@@ -47,31 +43,22 @@ export function createLifecycle({
|
||||
flightState.lifetime = new AbortController();
|
||||
clearFocusTarget('flights');
|
||||
flightState._focusEvidenceIds.clear();
|
||||
flightState._viewer = viewer;
|
||||
flightState._billboardCollection = new Cesium.BillboardCollection();
|
||||
viewer.scene.primitives.add(flightState._billboardCollection);
|
||||
registerSpriteCollection('flights', flightState._billboardCollection);
|
||||
flightState._modelCollection = new Cesium.PrimitiveCollection();
|
||||
viewer.scene.primitives.add(flightState._modelCollection);
|
||||
const renderer = services.renderer.create({ viewer, engine });
|
||||
flightState._renderer = renderer;
|
||||
renderer.fleet.attach();
|
||||
// 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
|
||||
// destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle.
|
||||
if (!flightState._preloadModel) {
|
||||
const epoch = flightState._modelEpoch;
|
||||
Cesium.Model.fromGltfAsync({
|
||||
url: resolveAsset(PLANE_MODEL_URL),
|
||||
asynchronous: false,
|
||||
})
|
||||
renderer.models
|
||||
.preload(resolveAsset(PLANE_MODEL_URL))
|
||||
.then((m) => {
|
||||
if (epoch === flightState._modelEpoch) {
|
||||
flightState._preloadModel = m;
|
||||
flightState._planeModelLoaded = true;
|
||||
} else {
|
||||
try {
|
||||
m.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
renderer.models.destroy(m);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -114,7 +101,7 @@ export function createLifecycle({
|
||||
parts.enrichment._ambientBudgetKnobs().ceil;
|
||||
flightState._enrichAmbientRefillAnchorMs = 0;
|
||||
|
||||
parts.tracking._installClickHandler(viewer);
|
||||
parts.tracking._installClickHandler();
|
||||
|
||||
// React to Military-layer toggles IMMEDIATELY (suppress/restore sweep)
|
||||
// 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');
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the billboard collection and re-install the click handler.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* Show the fleet and re-join the selection dispatcher.
|
||||
*/
|
||||
enable(viewer) {
|
||||
if (flightState._billboardCollection)
|
||||
flightState._billboardCollection.show = true;
|
||||
enable() {
|
||||
const renderer = flightState._renderer;
|
||||
renderer?.fleet.setVisible(true);
|
||||
holdContinuousRender('flights'); // per-frame animator (perf wave 2)
|
||||
if (flightState._modelCollection)
|
||||
flightState._modelCollection.show = true;
|
||||
renderer?.models.setVisible(true);
|
||||
parts.tracking._setCockpitContactMode(
|
||||
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 */
|
||||
});
|
||||
}
|
||||
parts.tracking._installClickHandler(viewer);
|
||||
parts.tracking._installClickHandler();
|
||||
registerPickOwner('flights', (pickedId) =>
|
||||
flightState._billboards.has(pickedId),
|
||||
);
|
||||
// Force a fresh rotation pass on the first tick after re-enable
|
||||
flightState._lastCamPoseSig = '';
|
||||
if (!flightState._preRenderRemove && viewer?.scene) {
|
||||
flightState._preRenderRemove = viewer.scene.preRender.addEventListener(
|
||||
if (!flightState._preRenderRemove && renderer) {
|
||||
flightState._preRenderRemove = renderer.view.on(
|
||||
'preRender',
|
||||
parts.rendering._fleetTick,
|
||||
);
|
||||
}
|
||||
if (!flightState._trackedModelPreUpdateRemove && viewer?.scene) {
|
||||
flightState._trackedModelPreUpdateRemove =
|
||||
viewer.scene.preUpdate.addEventListener(
|
||||
parts.rendering._updateTrackedModel,
|
||||
);
|
||||
if (!flightState._trackedModelPreUpdateRemove && renderer) {
|
||||
flightState._trackedModelPreUpdateRemove = renderer.view.on(
|
||||
'preUpdate',
|
||||
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
|
||||
// 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
|
||||
@@ -182,43 +168,30 @@ export function createLifecycle({
|
||||
// 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
|
||||
// extra rotation pass per completed camera gesture — nothing per-frame.
|
||||
flightState._moveEndRemove = viewer.camera.moveEnd.addEventListener(
|
||||
() => {
|
||||
flightState._lastCamPoseSig = '';
|
||||
flightState._lastFleetTickMs = 0;
|
||||
},
|
||||
);
|
||||
flightState._moveEndRemove = renderer.view.on('moveEnd', () => {
|
||||
flightState._lastCamPoseSig = '';
|
||||
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.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
disable(viewer) {
|
||||
disable() {
|
||||
parts.controller._abortActiveUpdates();
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._billboardCollection)
|
||||
flightState._billboardCollection.show = false;
|
||||
flightState._renderer?.fleet.setVisible(false);
|
||||
releaseContinuousRender('flights');
|
||||
parts.rendering._releaseModels();
|
||||
if (flightState._modelCollection)
|
||||
flightState._modelCollection.show = false;
|
||||
flightState._renderer?.models.setVisible(false);
|
||||
parts.tracking._clearTracking();
|
||||
parts.tracking._destroyTrail();
|
||||
// Remove click handler + keydown listener while disabled to avoid
|
||||
// intercepting input when the layer is off
|
||||
if (flightState._clickHandler) {
|
||||
flightState._clickHandler.destroy();
|
||||
flightState._clickHandler = null;
|
||||
}
|
||||
if (flightState._trackedEntityChangedRemove) {
|
||||
flightState._trackedEntityChangedRemove();
|
||||
flightState._trackedEntityChangedRemove = null;
|
||||
}
|
||||
document.removeEventListener('keydown', parts.tracking._onKeyDown);
|
||||
// Leave the selection dispatcher (clicks, Escape) while disabled to
|
||||
// avoid intercepting input when the layer is off
|
||||
parts.tracking._removeClickHandler();
|
||||
unregisterPickOwner('flights');
|
||||
if (flightState._preRenderRemove) {
|
||||
flightState._preRenderRemove();
|
||||
@@ -235,9 +208,9 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
/**
|
||||
* Fully tear down the flights layer — remove primitives, handlers,
|
||||
* tracked entities, and clear all internal state maps.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* Fully tear down the flights layer — remove its drawings, handlers,
|
||||
* the tracked visual, and clear all internal state maps.
|
||||
* @param {object|null} viewer - The viewer the layer was drawn on.
|
||||
*/
|
||||
destroy(viewer) {
|
||||
flightState.lifetime.abort();
|
||||
@@ -248,19 +221,11 @@ export function createLifecycle({
|
||||
parts.tracking._clearTracking();
|
||||
parts.tracking._destroyTrail();
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._clickHandler) {
|
||||
flightState._clickHandler.destroy();
|
||||
flightState._clickHandler = null;
|
||||
}
|
||||
if (flightState._trackedEntityChangedRemove) {
|
||||
flightState._trackedEntityChangedRemove();
|
||||
flightState._trackedEntityChangedRemove = null;
|
||||
}
|
||||
parts.tracking._removeClickHandler();
|
||||
if (flightState._milActiveChangeUnsub) {
|
||||
flightState._milActiveChangeUnsub();
|
||||
flightState._milActiveChangeUnsub = null;
|
||||
}
|
||||
document.removeEventListener('keydown', parts.tracking._onKeyDown);
|
||||
if (flightState._cockpitModeListener) {
|
||||
window.removeEventListener(
|
||||
'gev:cockpit-mode-changed',
|
||||
@@ -282,23 +247,13 @@ export function createLifecycle({
|
||||
flightState._moveEndRemove = null;
|
||||
}
|
||||
parts.rendering._releaseModels();
|
||||
if (flightState._billboardCollection) {
|
||||
viewer.scene.primitives.remove(flightState._billboardCollection);
|
||||
flightState._billboardCollection = null;
|
||||
}
|
||||
if (flightState._modelCollection) {
|
||||
viewer.scene.primitives.remove(flightState._modelCollection); // removing destroys it + its models
|
||||
flightState._modelCollection = null;
|
||||
}
|
||||
// Removing the model collection destroys it and its models.
|
||||
flightState._renderer?.fleet.detach(viewer);
|
||||
flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy)
|
||||
flightState._modelPending.clear();
|
||||
flightState._modelGen.clear();
|
||||
if (flightState._preloadModel) {
|
||||
try {
|
||||
flightState._preloadModel.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
flightState._renderer?.models.destroy(flightState._preloadModel);
|
||||
flightState._preloadModel = null;
|
||||
}
|
||||
flightState._planeModelLoaded = false;
|
||||
@@ -332,7 +287,8 @@ export function createLifecycle({
|
||||
coverage: flightState.feed._lastCoverage,
|
||||
};
|
||||
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. */
|
||||
|
||||
function _trackedFocusSizePx(icao24, position) {
|
||||
const camera = flightState._viewer?.camera;
|
||||
const scene = flightState._viewer?.scene;
|
||||
const view = flightState._renderer?.view;
|
||||
const camera = view?.camera;
|
||||
const scene = view?.scene;
|
||||
if (!camera?.positionWC || !position || !scene) return 28;
|
||||
const rangeM = geo.cartesian3.distance(camera.positionWC, position);
|
||||
if (parts.rendering._modelOwnsVisual(icao24)) {
|
||||
@@ -309,12 +310,12 @@ export function createMotion({
|
||||
baseScale: spec.scale,
|
||||
nativeRadiusM: spec.nativeRadiusM,
|
||||
rangeM,
|
||||
viewportHeightPx: scene.canvas.clientHeight,
|
||||
viewportHeightPx: view.viewportHeightPx(),
|
||||
fovyRad: camera.frustum.fovy,
|
||||
maximumPixelSize: TRACKED_MODEL_MAX_PX,
|
||||
});
|
||||
const focalLengthPx =
|
||||
scene.canvas.clientHeight / (2 * Math.tan(camera.frustum.fovy / 2));
|
||||
view.viewportHeightPx() / (2 * Math.tan(camera.frustum.fovy / 2));
|
||||
const projectedDiameterPx =
|
||||
(2 * spec.nativeRadiusM * scale * focalLengthPx) / rangeM;
|
||||
return Math.max(
|
||||
@@ -323,16 +324,16 @@ export function createMotion({
|
||||
);
|
||||
}
|
||||
|
||||
const billboard = flightState._trackedEntity?.billboard;
|
||||
const time = flightState._viewer.clock.currentTime;
|
||||
const width = billboard?.width?.getValue(time) ?? 28;
|
||||
const height = billboard?.height?.getValue(time) ?? 28;
|
||||
const billboard = flightState._renderer.tracked.billboardSize(
|
||||
flightState._trackedEntity,
|
||||
);
|
||||
const width = billboard?.width ?? 28;
|
||||
const height = billboard?.height ?? 28;
|
||||
const scale =
|
||||
billboard?.scale?.getValue(time) ??
|
||||
billboard?.scale ??
|
||||
(CLASS_SCALE_2D[flightState.records.data.get(icao24)?.klass] || 1);
|
||||
const scaleByDistance =
|
||||
billboard?.scaleByDistance?.getValue(time) ??
|
||||
TRACKED_BILLBOARD_SCALE_BY_DISTANCE;
|
||||
billboard?.scaleByDistance ?? TRACKED_BILLBOARD_SCALE_BY_DISTANCE;
|
||||
const distanceScale = nearFarScalarValueAtDistance(scaleByDistance, rangeM);
|
||||
return Math.max(width, height) * scale * distanceScale;
|
||||
}
|
||||
@@ -347,7 +348,7 @@ export function createMotion({
|
||||
*/
|
||||
|
||||
function _trackedDisplayPosition(icao24) {
|
||||
const frame = flightState._viewer?.scene?.frameState?.frameNumber ?? -1;
|
||||
const frame = flightState._renderer?.view.frameNumber() ?? -1;
|
||||
if (
|
||||
frame === flightState._cachedDRFrame &&
|
||||
icao24 === flightState._drReconcileIcao
|
||||
@@ -434,8 +435,8 @@ export function createMotion({
|
||||
publishFocusTargetFromCachedPosition({
|
||||
ownerLayer: 'flights',
|
||||
id: icao24,
|
||||
scene: flightState._viewer?.scene,
|
||||
camera: flightState._viewer?.camera,
|
||||
scene: flightState._renderer?.view.scene,
|
||||
camera: flightState._renderer?.view.camera,
|
||||
displayPosition: flightState._cachedDRPosition,
|
||||
widthPx: focusSizePx,
|
||||
heightPx: focusSizePx,
|
||||
|
||||
@@ -7,8 +7,8 @@ import { createEnrichment } from './enrichment.js';
|
||||
function services() {
|
||||
const names = [
|
||||
'picking',
|
||||
'sprites',
|
||||
'trails',
|
||||
'selection',
|
||||
'renderer',
|
||||
'aircraftPresentation',
|
||||
'camera',
|
||||
'militaryRegistry',
|
||||
|
||||
@@ -6,13 +6,18 @@ export {
|
||||
MISSING_POLL_LIMIT,
|
||||
ERROR_BACKOFF_INTERVAL,
|
||||
} 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;
|
||||
|
||||
/** 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
|
||||
// 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 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_CIVILIAN_COLOR =
|
||||
Cesium.Color.fromCssColorString('#DCEEFF');
|
||||
export const COCKPIT_CIVILIAN_COLOR = Object.freeze(rgbaFromCss('#DCEEFF'));
|
||||
|
||||
export const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(
|
||||
1000,
|
||||
3.0,
|
||||
8000000,
|
||||
0.5,
|
||||
export const TRACKED_BILLBOARD_SCALE_BY_DISTANCE = Object.freeze(
|
||||
nearFarScale(1000, 3.0, 8000000, 0.5),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -26,7 +26,6 @@ export function createQueries({
|
||||
const { tr3bTypeLabel, tr3bAircraftClass } = services.aircraftPresentation;
|
||||
const { formatFlightLevel } = services.labels;
|
||||
const { isMilitaryIcao } = services.militaryRegistry;
|
||||
const { applyTrackedCameraFrame } = services.camera;
|
||||
const {
|
||||
setFocusDeemphasisParams,
|
||||
getFocusDeemphasisParams,
|
||||
@@ -303,7 +302,7 @@ export function createQueries({
|
||||
// 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
|
||||
// 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) {
|
||||
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}>}
|
||||
*/
|
||||
getDetectableObjects(options = {}) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show
|
||||
)
|
||||
return [];
|
||||
if (!flightState._renderer?.fleet.visible()) return [];
|
||||
// Compute a stride that evenly samples the billboard map.
|
||||
// seed shifts the starting offset so successive calls can sample
|
||||
// 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}>}
|
||||
*/
|
||||
getNearby(center, range, maxCount = 50, { includeHidden = false } = {}) {
|
||||
if (
|
||||
!center ||
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show
|
||||
)
|
||||
return [];
|
||||
if (!center || !flightState._renderer?.fleet.visible()) return [];
|
||||
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
@@ -625,8 +615,7 @@ export function createQueries({
|
||||
*/
|
||||
hasContact(icao24) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show ||
|
||||
!flightState._renderer?.fleet.visible() ||
|
||||
flightState._billboards.size === 0
|
||||
)
|
||||
return null;
|
||||
@@ -640,7 +629,7 @@ export function createQueries({
|
||||
|
||||
getAllPositions(maxCount = 500) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._renderer?.fleet.attached() ||
|
||||
flightState._billboards.size === 0
|
||||
)
|
||||
return [];
|
||||
@@ -695,8 +684,7 @@ export function createQueries({
|
||||
*/
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show ||
|
||||
!flightState._renderer?.fleet.visible() ||
|
||||
flightState.records.data.size === 0
|
||||
)
|
||||
return [];
|
||||
@@ -792,7 +780,7 @@ export function createQueries({
|
||||
if (
|
||||
!icao24 ||
|
||||
flightState._cockpitContactMode ||
|
||||
!flightState._viewer ||
|
||||
!flightState._renderer ||
|
||||
!flightState._trackedEntity
|
||||
)
|
||||
return false;
|
||||
@@ -800,19 +788,13 @@ export function createQueries({
|
||||
if (!flightState._billboards.has(id)) id = id.toLowerCase();
|
||||
if (
|
||||
id !== flightState._trackedIcao ||
|
||||
!flightState._viewer.entities?.contains?.(flightState._trackedEntity) ||
|
||||
flightState._viewer.trackedEntity !== flightState._trackedEntity
|
||||
!flightState._renderer.tracked.isLive(flightState._trackedEntity) ||
|
||||
!flightState._renderer.follow.isFollowing(flightState._trackedEntity)
|
||||
)
|
||||
return false;
|
||||
flightState._trackedCameraFrameStop?.();
|
||||
flightState._viewer.camera.cancelFlight();
|
||||
flightState._viewer.trackedEntity = flightState._trackedEntity;
|
||||
flightState._trackedCameraFrameStop =
|
||||
applyTrackedCameraFrame(
|
||||
flightState._viewer,
|
||||
flightState._trackedEntity,
|
||||
flightState._trackedEntity.viewFrom,
|
||||
) || null;
|
||||
flightState._renderer.follow.refocus(flightState._trackedEntity);
|
||||
parts.tracking._publishTrackedSelection(id, origin);
|
||||
return true;
|
||||
},
|
||||
@@ -886,9 +868,10 @@ export function createQueries({
|
||||
};
|
||||
},
|
||||
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 };
|
||||
flightState._viewer.useDefaultRenderLoop = false;
|
||||
view.holdFrames();
|
||||
setFocusEvidenceNowMs(startMs);
|
||||
flightState._lastFleetTickMs = startMs - FLEET_DR_INTERVAL_MS;
|
||||
// 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.
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => {
|
||||
if (flightState._viewer?._cesiumWidget)
|
||||
flightState._viewer._cesiumWidget._renderLoopRunning = false;
|
||||
flightState._renderer?.view.closeFrameLatch();
|
||||
resolve({ ok: true, nowMs: startMs });
|
||||
}, 0),
|
||||
);
|
||||
@@ -907,8 +889,7 @@ export function createQueries({
|
||||
},
|
||||
releaseFrameClock() {
|
||||
setFocusEvidenceNowMs(null);
|
||||
if (flightState._viewer)
|
||||
flightState._viewer.useDefaultRenderLoop = true;
|
||||
flightState._renderer?.view.releaseFrames();
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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 { selectModelEligible } from '../../data/modelEligibility.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 {
|
||||
cameraPoseSignature,
|
||||
horizonOccluder,
|
||||
screenProjectedRotation,
|
||||
} from '../../data/iconOrientation.js';
|
||||
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
MODEL_PROX_ADD_M,
|
||||
MODEL_ALL_KEEP_M,
|
||||
MODEL_PROX_KEEP_M,
|
||||
MODEL_HEADING_OFFSET_DEG,
|
||||
IR_RELOAD_BATCH,
|
||||
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). */
|
||||
|
||||
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. */
|
||||
@@ -97,11 +96,11 @@ export function createRendering({
|
||||
function _normalBillboardScaleByDistance() {
|
||||
// Preserve the established close-range 3× scale. Any smaller owner-visible
|
||||
// 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() {
|
||||
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
|
||||
@@ -161,8 +160,8 @@ export function createRendering({
|
||||
/** Model tint, mirroring the billboard color rules. */
|
||||
|
||||
function _modelColor(icao24) {
|
||||
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
|
||||
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
|
||||
if (icao24 === flightState._trackedIcao) return CYAN;
|
||||
return isMilitaryIcao(icao24) ? MIL_TINT : WHITE;
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
if (!flightState._models3dEnabled) return false;
|
||||
const h =
|
||||
flightState._viewer?.camera?.positionCartographic?.height ?? Infinity;
|
||||
flightState._renderer?.view.camera?.positionCartographic?.height ??
|
||||
Infinity;
|
||||
return h < MODEL_ALT_CEIL_M;
|
||||
}
|
||||
|
||||
@@ -212,30 +212,13 @@ export function createRendering({
|
||||
: MODEL_PROX_KEEP_M;
|
||||
}
|
||||
|
||||
/** World model matrix from a position + course heading (pitch/roll 0; ENU frame). Writes into
|
||||
* `result` and returns it — pass each model's OWN `.modelMatrix` so models never share one
|
||||
* mutable matrix object. `Model.modelMatrix` is a plain field (not a cloning setter); Cesium
|
||||
* clones it per frame in updateModelMatrix(). Sharing a single scratch made every model render at
|
||||
* 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. */
|
||||
/** World model transform from a position + course heading (pitch/roll 0;
|
||||
* ENU frame), written by the renderer into the model's OWN transform —
|
||||
* never a shared scratch (see the renderer's setPose: one shared matrix
|
||||
* stacked every model on the last-written plane, "flickering like mad"). */
|
||||
|
||||
function _modelMatrix(
|
||||
pos,
|
||||
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,
|
||||
);
|
||||
function _modelMatrix(pos, headingDeg, model) {
|
||||
return flightState._renderer.models.setPose(model, pos, headingDeg);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (!meta || !meta.onGround) return pos;
|
||||
const h = flightState._groundSnap.heightFor(
|
||||
flightState._viewer,
|
||||
flightState._renderer?.view.surfaceOwner,
|
||||
icao24,
|
||||
pos,
|
||||
_groundSampleExclusions,
|
||||
);
|
||||
if (h == null) return null;
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
const carto = geo.cartographic.fromCartesian(
|
||||
pos,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
geo.ellipsoid.WGS84,
|
||||
flightState._scratchGroundCarto,
|
||||
);
|
||||
carto.height = h + _modelSpec(meta.klass).bellyM;
|
||||
return Cesium.Cartesian3.fromRadians(
|
||||
return geo.cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
carto.height,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
geo.ellipsoid.WGS84,
|
||||
result,
|
||||
);
|
||||
}
|
||||
@@ -316,7 +299,7 @@ export function createRendering({
|
||||
// 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
|
||||
// stale load-start matrix is the one-frame jump this ordering prevents.
|
||||
_modelMatrix(displayPos, course, model.modelMatrix);
|
||||
_modelMatrix(displayPos, course, model);
|
||||
if (!model.ready) {
|
||||
model.show = false; // not loaded yet → keep the 2D icon, no half-model flash
|
||||
bb.show = true;
|
||||
@@ -477,22 +460,19 @@ export function createRendering({
|
||||
// toggle must reject too (the reload queue only covers ADMITTED models).
|
||||
const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass);
|
||||
const loadIrBoost = flightState._irBoost;
|
||||
const renderer = flightState._renderer;
|
||||
try {
|
||||
const spec = _modelSpec(flightState.records.data.get(icao24)?.klass);
|
||||
model = await Cesium.Model.fromGltfAsync({
|
||||
model = await renderer.models.loadFleet({
|
||||
url: resolveAsset(spec.url),
|
||||
asynchronous: false,
|
||||
minimumPixelSize: MODEL_MIN_PX,
|
||||
scale: spec.scale,
|
||||
color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24),
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
color: flightState._irBoost ? WHITE : _modelColor(icao24),
|
||||
// Launch presentation keeps the code-side tint dominant for every approved
|
||||
// model; IR boost removes the remaining diffuse hint with flat UNLIT white.
|
||||
colorBlendAmount: flightState._irBoost ? 1.0 : spec.blendAmount,
|
||||
customShader: flightState._irBoost
|
||||
? flightState._IR_UNLIT_SHADER
|
||||
: undefined,
|
||||
id: icao24, // so scene.pick returns the icao for click-to-track
|
||||
blendAmount: flightState._irBoost ? 1.0 : spec.blendAmount,
|
||||
unlit: flightState._irBoost,
|
||||
id: icao24, // so a pick returns the icao for click-to-track
|
||||
});
|
||||
} catch {
|
||||
// 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
|
||||
// epoch's _modelPending/_modelGen or add to the new collection — just drop its model.
|
||||
if (epoch !== flightState._modelEpoch) {
|
||||
try {
|
||||
model.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
renderer.models.destroy(model);
|
||||
return;
|
||||
}
|
||||
flightState._modelPending.delete(icao24);
|
||||
@@ -522,8 +498,7 @@ export function createRendering({
|
||||
const stale =
|
||||
(flightState._modelGen.get(icao24) || 0) !== gen ||
|
||||
!_modelRegimeActive() ||
|
||||
!flightState._modelCollection ||
|
||||
flightState._modelCollection.isDestroyed() ||
|
||||
!flightState._renderer?.models.live() ||
|
||||
!flightState.records.data.has(icao24) ||
|
||||
icao24 === flightState._trackedIcao ||
|
||||
flightState._models.has(icao24) ||
|
||||
@@ -533,11 +508,7 @@ export function createRendering({
|
||||
// IR boost flipped mid-load → this model baked the wrong shader/tint.
|
||||
flightState._irBoost !== loadIrBoost;
|
||||
if (stale) {
|
||||
try {
|
||||
model.destroy();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
renderer.models.destroy(model);
|
||||
_cleanupModelGen(icao24); // bound the map
|
||||
return;
|
||||
}
|
||||
@@ -551,7 +522,7 @@ export function createRendering({
|
||||
// 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.
|
||||
model.show = false;
|
||||
flightState._modelCollection.add(model);
|
||||
flightState._renderer.models.add(model);
|
||||
flightState._models.set(icao24, model);
|
||||
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 (
|
||||
flightState._modelCollection &&
|
||||
!flightState._modelCollection.isDestroyed()
|
||||
) {
|
||||
try {
|
||||
flightState._modelCollection.remove(m);
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
flightState._renderer?.models.remove(m);
|
||||
flightState._models.delete(icao24);
|
||||
}
|
||||
// Do NOT clear _modelPending here — the in-flight load's OWN post-await removes it. Keeping
|
||||
@@ -610,17 +572,9 @@ export function createRendering({
|
||||
icao,
|
||||
(flightState._modelGen.get(icao) || 0) + 1,
|
||||
);
|
||||
if (
|
||||
flightState._modelCollection &&
|
||||
!flightState._modelCollection.isDestroyed()
|
||||
) {
|
||||
for (const m of flightState._models.values()) {
|
||||
try {
|
||||
flightState._modelCollection.remove(m);
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
if (flightState._renderer?.models.live()) {
|
||||
for (const m of flightState._models.values())
|
||||
flightState._renderer.models.remove(m);
|
||||
}
|
||||
flightState._models.clear();
|
||||
}
|
||||
@@ -631,16 +585,7 @@ export function createRendering({
|
||||
flightState._trackedModelGen++;
|
||||
flightState._trackedModelLoading = false;
|
||||
if (flightState._trackedModel) {
|
||||
if (
|
||||
flightState._modelCollection &&
|
||||
!flightState._modelCollection.isDestroyed()
|
||||
) {
|
||||
try {
|
||||
flightState._modelCollection.remove(flightState._trackedModel);
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
flightState._renderer?.models.remove(flightState._trackedModel);
|
||||
flightState._trackedModel = null;
|
||||
}
|
||||
}
|
||||
@@ -654,8 +599,7 @@ export function createRendering({
|
||||
const active =
|
||||
flightState._trackedIcao &&
|
||||
parts.tracking._trackedModelRegimeActive() &&
|
||||
flightState._modelCollection &&
|
||||
!flightState._modelCollection.isDestroyed();
|
||||
flightState._renderer?.models.live();
|
||||
if (!active) {
|
||||
if (flightState._trackedModel) flightState._trackedModel.show = false;
|
||||
return;
|
||||
@@ -685,36 +629,29 @@ export function createRendering({
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
);
|
||||
const trackedIrBoost = flightState._irBoost;
|
||||
Cesium.Model.fromGltfAsync({
|
||||
url: resolveAsset(trackedSpec.url),
|
||||
asynchronous: false,
|
||||
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
||||
scale: trackedSpec.scale,
|
||||
color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN,
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
// The tracked aircraft uses the same dominant light tint as the fleet;
|
||||
// IR boost removes the remaining diffuse hint with flat UNLIT white.
|
||||
colorBlendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount,
|
||||
customShader: flightState._irBoost
|
||||
? flightState._IR_UNLIT_SHADER
|
||||
: undefined,
|
||||
// Pick id (H1): without it, clicking the very plane being tracked read as
|
||||
// EMPTY SPACE (scene.pick → primitive with no id) → an unintended
|
||||
// deselect. With the icao, the click handler recognizes it as ours.
|
||||
id: flightState._trackedIcao,
|
||||
})
|
||||
const renderer = flightState._renderer;
|
||||
renderer.models
|
||||
.loadTracked({
|
||||
url: resolveAsset(trackedSpec.url),
|
||||
minimumPixelSize: TRACKED_MODEL_MIN_PX,
|
||||
scale: trackedSpec.scale,
|
||||
color: flightState._irBoost ? WHITE : CYAN,
|
||||
// The tracked aircraft uses the same dominant light tint as the fleet;
|
||||
// IR boost removes the remaining diffuse hint with flat UNLIT white.
|
||||
blendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount,
|
||||
unlit: flightState._irBoost,
|
||||
// Pick id (H1): without it, clicking the very plane being tracked read as
|
||||
// EMPTY SPACE (a pick with no id) → an unintended deselect. With the
|
||||
// icao, the click handler recognizes it as ours.
|
||||
id: flightState._trackedIcao,
|
||||
})
|
||||
.then((m) => {
|
||||
// Untracked / re-tracked / torn down during the load → drop it.
|
||||
if (
|
||||
gen !== flightState._trackedModelGen ||
|
||||
!flightState._modelCollection ||
|
||||
flightState._modelCollection.isDestroyed()
|
||||
!flightState._renderer?.models.live()
|
||||
) {
|
||||
try {
|
||||
m.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
renderer.models.destroy(m);
|
||||
return;
|
||||
}
|
||||
// Class reclassified OR boost flipped mid-load (no release ran —
|
||||
@@ -725,11 +662,7 @@ export function createRendering({
|
||||
) !== trackedKey ||
|
||||
flightState._irBoost !== trackedIrBoost
|
||||
) {
|
||||
try {
|
||||
m.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
renderer.models.destroy(m);
|
||||
flightState._trackedModelLoading = false;
|
||||
return;
|
||||
}
|
||||
@@ -751,11 +684,7 @@ export function createRendering({
|
||||
flightState._scratchGroundPos,
|
||||
);
|
||||
if (displayPos)
|
||||
_modelMatrix(
|
||||
displayPos,
|
||||
parts.motion._trackedDisplayCourse(),
|
||||
m.modelMatrix,
|
||||
);
|
||||
_modelMatrix(displayPos, parts.motion._trackedDisplayCourse(), m);
|
||||
}
|
||||
flightState._trackedModel = m;
|
||||
flightState._trackedModelLoading = false;
|
||||
@@ -764,7 +693,7 @@ export function createRendering({
|
||||
flightState._trackedModelFailIcao = null;
|
||||
flightState._trackedModelFailCount = 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
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -790,21 +719,19 @@ export function createRendering({
|
||||
_modelMatrix(
|
||||
displayPos,
|
||||
parts.motion._trackedDisplayCourse(),
|
||||
flightState._trackedModel.modelMatrix,
|
||||
flightState._trackedModel,
|
||||
);
|
||||
if (!flightState._trackedModel.ready) return;
|
||||
const spec = _modelSpec(
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
);
|
||||
const view = flightState._renderer.view;
|
||||
flightState._trackedModel.scale = trackedModelScaleForPixelCap({
|
||||
baseScale: spec.scale,
|
||||
nativeRadiusM: spec.nativeRadiusM,
|
||||
rangeM: Cesium.Cartesian3.distance(
|
||||
flightState._viewer.camera.positionWC,
|
||||
displayPos,
|
||||
),
|
||||
viewportHeightPx: flightState._viewer.scene.canvas.clientHeight,
|
||||
fovyRad: flightState._viewer.camera.frustum.fovy,
|
||||
rangeM: geo.cartesian3.distance(view.camera.positionWC, displayPos),
|
||||
viewportHeightPx: view.viewportHeightPx(),
|
||||
fovyRad: view.camera.frustum.fovy,
|
||||
maximumPixelSize: TRACKED_MODEL_MAX_PX,
|
||||
});
|
||||
flightState._trackedModel.show = true;
|
||||
@@ -812,14 +739,10 @@ export function createRendering({
|
||||
}
|
||||
|
||||
function _fleetTick() {
|
||||
if (
|
||||
!flightState._viewer ||
|
||||
!flightState._billboardCollection ||
|
||||
!flightState._billboardCollection.show
|
||||
)
|
||||
return;
|
||||
const scene = flightState._viewer.scene;
|
||||
const camera = flightState._viewer.camera;
|
||||
if (!flightState._renderer?.fleet.visible()) return;
|
||||
const view = flightState._renderer.view;
|
||||
const scene = view.scene;
|
||||
const camera = view.camera;
|
||||
const nowMs = focusNowMs(Date.now());
|
||||
|
||||
// (The tracked trail head is now the per-frame _trailHeadEntity segment — no 1 Hz
|
||||
@@ -848,7 +771,7 @@ export function createRendering({
|
||||
flightState._lastRotPassMs = nowMs;
|
||||
}
|
||||
|
||||
const occluder = horizonOccluder(camera);
|
||||
const occluder = view.horizon();
|
||||
const focusTarget = getFocusTarget();
|
||||
|
||||
// 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 keepM = _modelKeepDistM();
|
||||
const keepDistSq = keepM * keepM;
|
||||
const cull = camera.frustum.computeCullingVolume(
|
||||
camPos,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
const inFrustum = view.frustumTester();
|
||||
// Candidates = planes within the KEEP radius, nearest first, each tagged with on-screen-ness.
|
||||
const cand = [];
|
||||
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
|
||||
// below already bound airport clusters; grounded placement is handled by the
|
||||
// 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
|
||||
Cesium.Cartesian3.clone(
|
||||
bb.position,
|
||||
flightState._scratchModelBS.center,
|
||||
);
|
||||
cand.push([
|
||||
icao,
|
||||
d2,
|
||||
cull.computeVisibility(flightState._scratchModelBS) !==
|
||||
Cesium.Intersect.OUTSIDE,
|
||||
]);
|
||||
cand.push([icao, d2, inFrustum(bb.position)]);
|
||||
}
|
||||
cand.sort((a, b) => a[1] - b[1]); // nearest first
|
||||
if (cand.length > cap && nowMs - flightState._lastModelCapWarnMs > 5000) {
|
||||
@@ -952,7 +862,7 @@ export function createRendering({
|
||||
// collection's vertex buffer, so skip sub-meter moves.
|
||||
if (
|
||||
display &&
|
||||
Cesium.Cartesian3.distanceSquared(display, bb.position) > 1.0
|
||||
geo.cartesian3.distanceSquared(display, bb.position) > 1.0
|
||||
) {
|
||||
bb.position = display;
|
||||
}
|
||||
@@ -985,7 +895,7 @@ export function createRendering({
|
||||
// locked NearFarScalar remains a separate multiplicative stage. This
|
||||
// narrowly amends always-visible rendering without count culling or zero
|
||||
// alpha; nearer focus behavior remains tunable rather than universal.
|
||||
const cameraDistanceM = Cesium.Cartesian3.distance(
|
||||
const cameraDistanceM = geo.cartesian3.distance(
|
||||
camera.positionWC,
|
||||
bb.position,
|
||||
);
|
||||
@@ -1101,9 +1011,7 @@ export function createRendering({
|
||||
// white. Boosted models also skip the recession fade: hot targets
|
||||
// stay full-strength at any range (billboards keep their normal
|
||||
// fade — full-opacity glyph walls read as overwhelming).
|
||||
baseColor: flightState._irBoost
|
||||
? Cesium.Color.WHITE
|
||||
: _modelColor(icao24),
|
||||
baseColor: flightState._irBoost ? WHITE : _modelColor(icao24),
|
||||
alpha: flightState._irBoost ? 1 : treatment.alpha,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import {
|
||||
@@ -8,8 +7,9 @@ import {
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.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({
|
||||
flightState,
|
||||
records,
|
||||
@@ -32,7 +32,8 @@ export function createFlightSnapshotRenderer({
|
||||
// subpoint + collected floor cells for the viewer-proximate low-contact
|
||||
// clamp below — one carto read per poll, one batch warm after the loop.
|
||||
const viewerCarto =
|
||||
(viewer || flightState._viewer)?.camera?.positionCartographic || null;
|
||||
(viewer || flightState._renderer?.view)?.camera?.positionCartographic ||
|
||||
null;
|
||||
const viewerLatDeg = viewerCarto
|
||||
? geo.math.toDegrees(viewerCarto.latitude)
|
||||
: null;
|
||||
@@ -55,7 +56,7 @@ export function createFlightSnapshotRenderer({
|
||||
if (isMil && tracking._militaryLayerSuppresses(icao24)) {
|
||||
const dupe = flightState._billboards.get(icao24);
|
||||
if (dupe) {
|
||||
flightState._billboardCollection.remove(dupe);
|
||||
flightState._renderer.fleet.remove(dupe);
|
||||
flightState._billboards.delete(icao24);
|
||||
rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
||||
records.forget(icao24);
|
||||
@@ -200,7 +201,7 @@ export function createFlightSnapshotRenderer({
|
||||
rendering._modelOwnsVisual(icao24),
|
||||
)) ||
|
||||
position;
|
||||
const bb = flightState._billboardCollection.add({
|
||||
const bb = flightState._renderer.fleet.add({
|
||||
position: displayed,
|
||||
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
|
||||
width: isTracked ? 24 : 20,
|
||||
@@ -209,9 +210,7 @@ export function createFlightSnapshotRenderer({
|
||||
// Screen-projected rotation lands on the next fleet tick.
|
||||
rotation: 0,
|
||||
alignedAxis: geo.cartesian3.ZERO,
|
||||
color: isTracked
|
||||
? Cesium.Color.CYAN
|
||||
: rendering._fleetBillboardColor(icao24),
|
||||
color: isTracked ? CYAN : rendering._fleetBillboardColor(icao24),
|
||||
sizeInMeters: false,
|
||||
scaleByDistance: rendering._normalBillboardScaleByDistance(),
|
||||
// Grounded/near-surface planes sit at/below the photoreal tile
|
||||
@@ -272,7 +271,7 @@ export function createFlightSnapshotRenderer({
|
||||
tracking._clearTracking(false, { evicted: true });
|
||||
}
|
||||
|
||||
flightState._billboardCollection.remove(bb);
|
||||
flightState._renderer.fleet.remove(bb);
|
||||
flightState._billboards.delete(icao24);
|
||||
rendering._releaseModel(icao24); // aged-out aircraft: drop its 3D model (no orphan / cap leak)
|
||||
records.forget(icao24);
|
||||
@@ -308,15 +307,22 @@ export function createFlightSnapshotRenderer({
|
||||
// cell, budget-capped, viewer-proximate, google-3d regime only). Own
|
||||
// billboards/models are excluded so a vertical probe can't land on an
|
||||
// aircraft instead of the pavement.
|
||||
sampleMeshFloorCells(flightState._viewer?.scene, floorWarmPoints, {
|
||||
excludeObjects: [
|
||||
...flightState._billboards.values(),
|
||||
...flightState._models.values(),
|
||||
flightState._trackedModel,
|
||||
].filter(Boolean),
|
||||
viewerLat: viewerLatDeg,
|
||||
viewerLon: viewerLonDeg,
|
||||
});
|
||||
sampleMeshFloorCells(
|
||||
flightState._renderer?.view.surfaceScene,
|
||||
floorWarmPoints,
|
||||
{
|
||||
excludeObjects:
|
||||
flightState._renderer?.surfaceExclusions({
|
||||
sprites: flightState._billboards.values(),
|
||||
models: [
|
||||
...flightState._models.values(),
|
||||
flightState._trackedModel,
|
||||
],
|
||||
}) ?? [],
|
||||
viewerLat: viewerLatDeg,
|
||||
viewerLon: viewerLonDeg,
|
||||
},
|
||||
);
|
||||
|
||||
// Round 5: the grounded exact-key warm that used to live here is gone —
|
||||
// see the note where _warmGroundedAircraftSurfaceCache was removed. The
|
||||
|
||||
+21
-46
@@ -1,6 +1,5 @@
|
||||
import { FlightRecords } from './records.js';
|
||||
import { createFlightFeed } from './ingestion.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
|
||||
|
||||
@@ -36,11 +35,14 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
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();
|
||||
|
||||
@@ -79,33 +81,20 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
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._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. */
|
||||
|
||||
flightState._billboardLimbScale = new WeakMap();
|
||||
// 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} */
|
||||
|
||||
flightState._billboardCollection = null;
|
||||
|
||||
/** @type {Map<string, Cesium.Billboard>} icao24 -> billboard primitive */
|
||||
/** @type {Map<string, object>} icao24 -> fleet sprite handle */
|
||||
|
||||
flightState._billboards = new Map();
|
||||
|
||||
@@ -133,7 +122,7 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
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;
|
||||
|
||||
@@ -141,7 +130,7 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
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
|
||||
* 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
|
||||
@@ -155,13 +144,10 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
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;
|
||||
|
||||
/** @type {Cesium.Viewer|null} Cached viewer reference */
|
||||
|
||||
flightState._viewer = null;
|
||||
flightState._selectionRemove = null;
|
||||
|
||||
/** Cockpit presentation switches ambient AIR contacts between near aircraft and far dots. */
|
||||
|
||||
@@ -183,7 +169,7 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
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
|
||||
* 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
|
||||
* map LUMINANCE, so mid-gray textured models read cold and vanish into
|
||||
* 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
|
||||
* setStyle via the `irBoost` layer param. */
|
||||
* (hottest) and UNLIT (`unlit` in the model spec: a side-lit airframe under
|
||||
* 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;
|
||||
|
||||
/** 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;
|
||||
return flightState;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { CYAN_TRANSPARENT } from './policy.js';
|
||||
import { CYAN } from '../shared/color.js';
|
||||
|
||||
export function createTesting({
|
||||
flightState,
|
||||
@@ -65,9 +65,11 @@ export function createTesting({
|
||||
models = [],
|
||||
modelCollection = null,
|
||||
}) {
|
||||
flightState._viewer = viewer;
|
||||
flightState._modelCollection = modelCollection;
|
||||
flightState._billboardCollection = billboardCollection;
|
||||
// The production renderer over the test's viewer and (fake) collections.
|
||||
flightState._renderer = services.renderer.create({
|
||||
viewer,
|
||||
adopt: { billboardCollection, modelCollection },
|
||||
});
|
||||
flightState._billboards = new Map([[icao24, billboard]]);
|
||||
flightState._cullPositions.clear();
|
||||
flightState._models.clear();
|
||||
@@ -175,7 +177,7 @@ export function createTesting({
|
||||
function _trackedBillboardColorForTest() {
|
||||
return parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
||||
? CYAN_TRANSPARENT
|
||||
: Cesium.Color.CYAN;
|
||||
: CYAN;
|
||||
}
|
||||
|
||||
/** 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 { nextCockpitNearContacts } from '../../data/cockpitAirLod.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 { routePlausible } from '../../data/routePlausible.js';
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import {
|
||||
bindTrackingClickGesture,
|
||||
isTrackingSelectionGesture,
|
||||
isTrackingClickGesture,
|
||||
} from '../../data/trackingClickGesture.js';
|
||||
import {
|
||||
TRACKED_MODEL_MAX_LOAD_FAILS,
|
||||
TRACKED_MODEL_RETRY_BACKOFF_MS,
|
||||
@@ -38,15 +34,13 @@ export function createTracking({
|
||||
} = services.context;
|
||||
const { isTr3b, tr3bTypeLabel, tr3bConvertedIds } =
|
||||
services.aircraftPresentation;
|
||||
const { createTrail } = services.trails;
|
||||
const { ensureGeoidReady, geoidHeight } = services.geoid;
|
||||
const { resolveGroundFloorCellsBounded, floorAltitudeM, cachedGroundFloor } =
|
||||
services.groundFloor;
|
||||
const { clearFocusTarget } = services.focus;
|
||||
const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry;
|
||||
const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout;
|
||||
const { applyTrackedCameraFrame } = services.camera;
|
||||
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
||||
const { selectionDispatcherFor } = services.selection;
|
||||
const flightsLayer = layer;
|
||||
|
||||
function _emitAwarenessEvent(type, detail) {
|
||||
@@ -73,7 +67,7 @@ export function createTracking({
|
||||
// a callsign-less contact reached Context as its raw hex even once adsbdb
|
||||
// had supplied a registration. Identity below stays `icao24`.
|
||||
label: parts.queries._contactLabel(icao24, info),
|
||||
position: Cesium.Cartesian3.clone(bb.position),
|
||||
position: geo.cartesian3.clone(bb.position),
|
||||
origin,
|
||||
});
|
||||
selectTrackedSubjectContext(_contextSubjectMetadata(icao24));
|
||||
@@ -147,10 +141,8 @@ export function createTracking({
|
||||
*/
|
||||
|
||||
function _refreshCockpitNearContacts() {
|
||||
if (
|
||||
!flightState._cockpitContactMode ||
|
||||
!flightState._viewer?.camera?.positionWC
|
||||
) {
|
||||
const camera = flightState._renderer?.view.camera;
|
||||
if (!flightState._cockpitContactMode || !camera?.positionWC) {
|
||||
if (flightState._cockpitNearContacts.size)
|
||||
flightState._cockpitNearContacts = new Set();
|
||||
return;
|
||||
@@ -161,10 +153,7 @@ export function createTracking({
|
||||
if (icao24 === flightState._trackedIcao || !bb?.position) continue;
|
||||
distancesSquared.push([
|
||||
icao24,
|
||||
Cesium.Cartesian3.distanceSquared(
|
||||
flightState._viewer.camera.positionWC,
|
||||
bb.position,
|
||||
),
|
||||
geo.cartesian3.distanceSquared(camera.positionWC, bb.position),
|
||||
]);
|
||||
}
|
||||
const next = nextCockpitNearContacts(
|
||||
@@ -195,7 +184,7 @@ export function createTracking({
|
||||
// aircraft silhouette until a ready model takes over; far contacts are pips.
|
||||
// Never destroy on entry: tearing down hundreds of live glTF instances
|
||||
// synchronously blocked Chrome's renderer into Page Unresponsive.
|
||||
if (flightState._modelCollection) flightState._modelCollection.show = true;
|
||||
flightState._renderer?.models.setVisible(true);
|
||||
flightState._trail?.setVisible(!next);
|
||||
if (flightState._trailHeadEntity) flightState._trailHeadEntity.show = !next;
|
||||
for (const [icao24, bb] of flightState._billboards)
|
||||
@@ -300,7 +289,7 @@ export function createTracking({
|
||||
return false;
|
||||
}
|
||||
flightState._trackedZoomLatched = trackedModelZoomActive(
|
||||
flightState._viewer?.camera?.positionCartographic?.height,
|
||||
flightState._renderer?.view.camera?.positionCartographic?.height,
|
||||
flightState._trackedZoomLatched,
|
||||
);
|
||||
return flightState._trackedZoomLatched;
|
||||
@@ -309,13 +298,13 @@ export function createTracking({
|
||||
/** Seed the tracked billboard's 2D orientation before a 3D→2D handoff. */
|
||||
|
||||
function _syncTracked2dRotation() {
|
||||
if (!flightState._trackedIcao || !flightState._viewer) return;
|
||||
if (!flightState._trackedIcao || !flightState._renderer) return;
|
||||
const pos =
|
||||
parts.motion._trackedDisplayCached() ||
|
||||
flightState._billboards.get(flightState._trackedIcao)?.position;
|
||||
if (!pos) return;
|
||||
const projected = screenProjectedRotation(
|
||||
flightState._viewer.scene,
|
||||
flightState._renderer.view.scene,
|
||||
pos,
|
||||
parts.motion._trackedDisplayCourse(),
|
||||
flightState._lastTrackedRotation,
|
||||
@@ -331,7 +320,7 @@ export function createTracking({
|
||||
/**
|
||||
* Append one fix to the tracked aircraft's trail accumulation and refresh
|
||||
* 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) {
|
||||
@@ -382,11 +371,11 @@ export function createTracking({
|
||||
const seedRenderTimeMs = appClock.now() - RENDER_DELAY_SEC * 1000;
|
||||
for (const fix of history) {
|
||||
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) {
|
||||
flightState._trail = createTrail(flightState._viewer, {
|
||||
if (!flightState._trail && flightState._renderer) {
|
||||
flightState._trail = flightState._renderer.trails.create({
|
||||
color: TRAIL_COLOR,
|
||||
width: 2.5,
|
||||
});
|
||||
@@ -396,60 +385,54 @@ export function createTracking({
|
||||
// a CallbackProperty (Cesium updates entity-polyline positions cheaply, unlike the
|
||||
// trail primitive which fully rebuilds on setPositions). Keeps the head glued to the
|
||||
// 12 Hz icon instead of lagging ~1 s behind it.
|
||||
if (!flightState._trailHeadEntity && flightState._viewer) {
|
||||
flightState._trailHeadEntity = flightState._viewer.entities.add({
|
||||
if (!flightState._trailHeadEntity && flightState._renderer) {
|
||||
flightState._trailHeadEntity = flightState._renderer.trails.createHead({
|
||||
// 'gev-trail' namespace (round 6): claimed by trailRenderer's pick
|
||||
// owner so a click on the head segment never reads as empty space.
|
||||
id: `gev-trail:fl-head-${++flightState._trailHeadSeq}`,
|
||||
show: !flightState._cockpitContactMode,
|
||||
polyline: {
|
||||
positions: new Cesium.CallbackProperty(() => {
|
||||
// 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
|
||||
// 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
|
||||
// icon predates all real history, so there is no valid body point behind it.
|
||||
if (
|
||||
!flightState._trackedIcao ||
|
||||
flightState._trailPositions.length < 2 ||
|
||||
parts.motion._isTrackWarmingUp()
|
||||
)
|
||||
return [];
|
||||
const head =
|
||||
parts.motion._trackedTrailCached() ||
|
||||
parts.motion._trackedDisplayPosition(flightState._trackedIcao);
|
||||
if (!head) return [];
|
||||
// body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a
|
||||
// backward/reversing segment.
|
||||
const start =
|
||||
flightState._trailPositions[
|
||||
flightState._trailPositions.length - 2
|
||||
];
|
||||
// On a contact that has not moved this segment runs from inside the
|
||||
// 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
|
||||
// START is what slides, from nothing on a parked contact out to the
|
||||
// whole segment once it has cleared its own envelope.
|
||||
// See trailHeadStart. Read `head` in place and clone only on the draw
|
||||
// path — a suppressed parked contact runs this every frame.
|
||||
const from = trailHeadStart(
|
||||
start,
|
||||
head,
|
||||
parts.motion._trackedModelCenterWorld(),
|
||||
parts.motion._trackedModelEnvelopeM(),
|
||||
flightState._scratchTrailHead,
|
||||
);
|
||||
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)
|
||||
positions: () => {
|
||||
// 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
|
||||
// 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
|
||||
// icon predates all real history, so there is no valid body point behind it.
|
||||
if (
|
||||
!flightState._trackedIcao ||
|
||||
flightState._trailPositions.length < 2 ||
|
||||
parts.motion._isTrackWarmingUp()
|
||||
)
|
||||
return [];
|
||||
const head =
|
||||
parts.motion._trackedTrailCached() ||
|
||||
parts.motion._trackedDisplayPosition(flightState._trackedIcao);
|
||||
if (!head) return [];
|
||||
// body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a
|
||||
// backward/reversing segment.
|
||||
const start =
|
||||
flightState._trailPositions[flightState._trailPositions.length - 2];
|
||||
// On a contact that has not moved this segment runs from inside the
|
||||
// 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
|
||||
// START is what slides, from nothing on a parked contact out to the
|
||||
// whole segment once it has cleared its own envelope.
|
||||
// See trailHeadStart. Read `head` in place and clone only on the draw
|
||||
// path — a suppressed parked contact runs this every frame.
|
||||
const from = trailHeadStart(
|
||||
start,
|
||||
head,
|
||||
parts.motion._trackedModelCenterWorld(),
|
||||
parts.motion._trackedModelEnvelopeM(),
|
||||
flightState._scratchTrailHead,
|
||||
);
|
||||
if (!from) return [];
|
||||
return [from, geo.cartesian3.clone(head)];
|
||||
},
|
||||
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();
|
||||
@@ -547,7 +530,7 @@ export function createTracking({
|
||||
// with nothing to carry keep the old 10 km airborne default.
|
||||
if (altM == null) altM = lastAltM != null ? lastAltM : 10000;
|
||||
lastAltM = altM;
|
||||
older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM));
|
||||
older.push(geo.cartesian3.fromDegrees(lon, lat, altM));
|
||||
}
|
||||
|
||||
flightState._trailPositions = older.concat(flightState._trailPositions);
|
||||
@@ -567,17 +550,8 @@ export function createTracking({
|
||||
flightState._trailBackfillToken += 1;
|
||||
flightState._trailPositions = [];
|
||||
if (flightState._trail) flightState._trail.clear();
|
||||
if (
|
||||
flightState._trailHeadEntity &&
|
||||
flightState._viewer &&
|
||||
!flightState._viewer.isDestroyed()
|
||||
) {
|
||||
try {
|
||||
flightState._viewer.entities.remove(flightState._trailHeadEntity);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
if (flightState._trailHeadEntity)
|
||||
flightState._renderer?.trails.removeHead(flightState._trailHeadEntity);
|
||||
flightState._trailHeadEntity = null;
|
||||
}
|
||||
|
||||
@@ -653,11 +627,11 @@ export function createTracking({
|
||||
// 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
|
||||
// the follow left it and the user can immediately orbit/zoom.
|
||||
if (flightState._viewer && !skipViewerUntrack) {
|
||||
flightState._viewer.trackedEntity = undefined;
|
||||
if (flightState._renderer && !skipViewerUntrack) {
|
||||
flightState._renderer.follow.release();
|
||||
}
|
||||
if (flightState._trackedEntity) {
|
||||
flightState._viewer.entities.remove(flightState._trackedEntity);
|
||||
flightState._renderer.tracked.remove(flightState._trackedEntity);
|
||||
flightState._trackedEntity = null;
|
||||
}
|
||||
parts.rendering._releaseTrackedModel();
|
||||
@@ -715,7 +689,7 @@ export function createTracking({
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
!flightState._billboardCollection?.show ||
|
||||
!flightState._renderer?.fleet.visible() ||
|
||||
!flightState._billboards.has(pending.id)
|
||||
)
|
||||
return false;
|
||||
@@ -787,14 +761,16 @@ export function createTracking({
|
||||
/** Re-image the tracked entity's billboard from the current class/conversion. */
|
||||
|
||||
function _syncTrackedBillboardImage() {
|
||||
if (!flightState._trackedIcao || !flightState._trackedEntity?.billboard)
|
||||
return;
|
||||
flightState._trackedEntity.billboard.image = aircraftIcon(
|
||||
parts.rendering._iconKind(
|
||||
flightState._trackedIcao,
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
if (!flightState._trackedIcao || !flightState._trackedEntity) return;
|
||||
flightState._renderer.tracked.setImage(
|
||||
flightState._trackedEntity,
|
||||
aircraftIcon(
|
||||
parts.rendering._iconKind(
|
||||
flightState._trackedIcao,
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
),
|
||||
TRACKED_ICON_PX,
|
||||
),
|
||||
TRACKED_ICON_PX,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -829,7 +805,7 @@ export function createTracking({
|
||||
_syncTrackedBillboardImage();
|
||||
_updateTrackedLabelModel(id);
|
||||
}
|
||||
flightState._viewer?.scene?.requestRender?.();
|
||||
flightState._renderer?.view.requestRender();
|
||||
return flightState._billboards.has(id) || id === flightState._trackedIcao;
|
||||
}
|
||||
|
||||
@@ -850,15 +826,15 @@ export function createTracking({
|
||||
const info = flightState.records.data.get(icao24);
|
||||
const bb = flightState._billboards.get(icao24);
|
||||
if (!info || !bb || !bb.position) return true;
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
const carto = geo.cartographic.fromCartesian(
|
||||
bb.position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
geo.ellipsoid.WGS84,
|
||||
flightState._scratchCarto,
|
||||
);
|
||||
if (!carto) return true;
|
||||
return routePlausible({
|
||||
latDeg: Cesium.Math.toDegrees(carto.latitude),
|
||||
lonDeg: Cesium.Math.toDegrees(carto.longitude),
|
||||
latDeg: geo.math.toDegrees(carto.latitude),
|
||||
lonDeg: geo.math.toDegrees(carto.longitude),
|
||||
altitudeM: info.altitude ?? null,
|
||||
verticalRateMps: info.verticalRate ?? null,
|
||||
origin: route.origin,
|
||||
@@ -909,11 +885,6 @@ export function createTracking({
|
||||
const getTrackedPosition = () =>
|
||||
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
|
||||
// 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
|
||||
@@ -929,68 +900,53 @@ export function createTracking({
|
||||
// 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
|
||||
// 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
|
||||
// overlap collections, and an explicit ICAO ID would throw on that duplicate.
|
||||
flightState._trackedEntity = flightState._viewer.entities.add({
|
||||
position: positionProperty,
|
||||
// Force Cesium's built-in EntityView and our close-range camera guard to
|
||||
// use the same local frame. AUTO can select a velocity frame while the
|
||||
// model matrix uses aircraft orientation; alternating between those
|
||||
// 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,
|
||||
flightState._trackedEntity = flightState._renderer.tracked.create({
|
||||
// Dead-reckoning position — smooth continuous motion between API updates.
|
||||
position: getTrackedPosition,
|
||||
image: aircraftIcon(
|
||||
parts.rendering._iconKind(
|
||||
flightState._trackedIcao,
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass,
|
||||
),
|
||||
width: 28,
|
||||
height: 28,
|
||||
scale:
|
||||
CLASS_SCALE_2D[
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass
|
||||
] || 1,
|
||||
// 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).
|
||||
color: new Cesium.CallbackProperty(
|
||||
() =>
|
||||
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
||||
? CYAN_TRANSPARENT
|
||||
: Cesium.Color.CYAN,
|
||||
false,
|
||||
),
|
||||
sizeInMeters: false,
|
||||
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5),
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
// The tracked target must never vanish into tile geometry — tracking a
|
||||
// taxiing plane at street level would otherwise bury the cyan icon inside
|
||||
// the runway skin exactly like the fleet ground icons (_groundDepthDistance);
|
||||
// its shared-host tracked card is top-composited separately.
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
// Screen-projected rotation, evaluated per frame: exact in tracked-orbit
|
||||
// mode where camera.heading lives in the entity's reference frame.
|
||||
rotation: new Cesium.CallbackProperty(() => {
|
||||
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;
|
||||
TRACKED_ICON_PX,
|
||||
),
|
||||
width: 28,
|
||||
height: 28,
|
||||
scale:
|
||||
CLASS_SCALE_2D[
|
||||
flightState.records.data.get(flightState._trackedIcao)?.klass
|
||||
] || 1,
|
||||
// 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).
|
||||
color: () =>
|
||||
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
|
||||
? CYAN_TRANSPARENT
|
||||
: CYAN,
|
||||
scaleByDistance: nearFarScale(1000, 3.0, 8000000, 0.5),
|
||||
// The tracked target must never vanish into tile geometry — tracking a
|
||||
// taxiing plane at street level would otherwise bury the cyan icon inside
|
||||
// the runway skin exactly like the fleet ground icons (_groundDepthDistance);
|
||||
// its shared-host tracked card is top-composited separately.
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
// Screen-projected rotation, evaluated per frame: exact in tracked-orbit
|
||||
// mode where camera.heading lives in the entity's reference frame.
|
||||
rotation: () => {
|
||||
const tracked = flightState.records.data.get(flightState._trackedIcao);
|
||||
const pos = getTrackedPosition();
|
||||
if (!tracked || !pos || !flightState._renderer)
|
||||
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;
|
||||
@@ -1008,11 +964,11 @@ export function createTracking({
|
||||
Math.max((info.altitude || 1500) * 1.1 + 2500, 3000),
|
||||
30000,
|
||||
);
|
||||
flightState._trackedEntity.viewFrom = new Cesium.Cartesian3(
|
||||
0,
|
||||
-followRange * 0.8,
|
||||
followRange * 0.55,
|
||||
);
|
||||
flightState._renderer.tracked.setViewFrom(flightState._trackedEntity, {
|
||||
x: 0,
|
||||
y: -followRange * 0.8,
|
||||
z: followRange * 0.55,
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -1029,15 +985,11 @@ export function createTracking({
|
||||
flightState._trackedEntity.gevVisualPosition =
|
||||
parts.motion._trackedVisualCached;
|
||||
refreshTrackedReadout(flightState._trackedEntity);
|
||||
flightState._viewer.camera.cancelFlight();
|
||||
// Camera follows the tracked entity
|
||||
flightState._viewer.trackedEntity = flightState._trackedEntity;
|
||||
flightState._trackedCameraFrameStop =
|
||||
applyTrackedCameraFrame(
|
||||
flightState._viewer,
|
||||
flightState._trackedEntity,
|
||||
flightState._trackedEntity.viewFrom,
|
||||
) || null;
|
||||
// Camera follows the tracked entity (the renderer cancels any flight
|
||||
// first and applies the one exact tracked frame).
|
||||
flightState._trackedCameraFrameStop = flightState._renderer.follow.start(
|
||||
flightState._trackedEntity,
|
||||
);
|
||||
|
||||
// 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
|
||||
@@ -1077,12 +1029,12 @@ export function createTracking({
|
||||
*/
|
||||
|
||||
function _onMilitaryActiveChange(active) {
|
||||
if (!flightState._viewer || !flightState._billboardCollection) return;
|
||||
if (!flightState._renderer?.fleet.attached()) return;
|
||||
if (active) {
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (!isMilitaryIcao(icao24) || icao24 === flightState._trackedIcao)
|
||||
continue;
|
||||
flightState._billboardCollection.remove(bb);
|
||||
flightState._renderer.fleet.remove(bb);
|
||||
flightState._billboards.delete(icao24);
|
||||
parts.rendering._releaseModel(icao24); // military-suppression: drop any 3D model too
|
||||
flightState.records.data.delete(icao24);
|
||||
@@ -1093,137 +1045,122 @@ export function createTracking({
|
||||
flightState.records.missingPolls.delete(icao24);
|
||||
}
|
||||
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.
|
||||
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.
|
||||
* @param {KeyboardEvent} e
|
||||
* Escape (through the selection dispatcher) deselects the tracked flight.
|
||||
*/
|
||||
|
||||
function _onKeyDown(e) {
|
||||
if (e.key === 'Escape' && flightState._trackedIcao) {
|
||||
function _onEscape() {
|
||||
if (flightState._trackedIcao) {
|
||||
_cancelPendingTrackingRestore();
|
||||
_clearTracking(false, { origin: 'user' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a LEFT_CLICK handler on the scene canvas for flight selection.
|
||||
*
|
||||
* Picking logic checks both `picked.primitive` and `picked.id` because
|
||||
* different CesiumJS versions surface BillboardCollection hits differently.
|
||||
* Also registers a global keydown listener for the Escape key.
|
||||
*
|
||||
* Idempotent — returns immediately if a handler is already installed.
|
||||
*
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* 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
|
||||
* dispatcher has already applied the gesture rules (a drag selects
|
||||
* nothing; isTrackingSelectionGesture) and the pointer claim.
|
||||
* @param {string} id - The picked id this layer owns.
|
||||
*/
|
||||
|
||||
function _installClickHandler(viewer) {
|
||||
if (flightState._clickHandler) return; // already installed
|
||||
function _onSelect(id) {
|
||||
// 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
|
||||
// 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
|
||||
// (viewer.trackedEntity briefly undefined) doesn't self-clear.
|
||||
// (the follow target briefly empty) doesn't self-clear.
|
||||
if (!flightState._trackedEntityChangedRemove) {
|
||||
flightState._trackedEntityChangedRemove =
|
||||
viewer.trackedEntityChanged.addEventListener(() => {
|
||||
flightState._trackedEntityChangedRemove = renderer.follow.onChanged(
|
||||
() => {
|
||||
const current = flightState._renderer?.follow.current();
|
||||
if (
|
||||
flightState._trackedIcao &&
|
||||
flightState._viewer &&
|
||||
flightState._viewer.trackedEntity &&
|
||||
flightState._viewer.trackedEntity !== flightState._trackedEntity
|
||||
current &&
|
||||
current !== flightState._trackedEntity
|
||||
) {
|
||||
_clearTracking(true, {
|
||||
origin:
|
||||
flightState._viewer.trackedEntity?.gevSelectionOrigin ||
|
||||
'programmatic',
|
||||
origin: current?.gevSelectionOrigin || 'programmatic',
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
flightState._clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
bindTrackingClickGesture(flightState._clickHandler, (click, gesture) => {
|
||||
// Camera drags never select or deselect, even if they finish over a plane.
|
||||
// Duration alone is allowed through so a stationary long press can still
|
||||
// select/switch contacts; the destructive empty-space branch below applies
|
||||
// the full travel + duration click classifier.
|
||||
if (!isTrackingSelectionGesture(gesture)) return;
|
||||
// Cockpit mode owns the camera and keeps the current aircraft as its
|
||||
// first-person reference. A globe click must not fall through to the
|
||||
// normal empty-space deselection path; cockpit has explicit exit controls.
|
||||
if (document.body.classList.contains('cockpit-mode')) return;
|
||||
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' });
|
||||
}
|
||||
if (flightState._selectionRemove) return; // already joined
|
||||
const dispatcher = selectionDispatcherFor(renderer.engine);
|
||||
if (!dispatcher) return;
|
||||
flightState._selectionRemove = dispatcher.register('flights', {
|
||||
// The fleet's icaos, and the tracked visual's own id: clicking it is
|
||||
// this layer's no-op, never empty space.
|
||||
owns: (id) =>
|
||||
flightState._billboards.has(id) ||
|
||||
(!!flightState._trackedEntity &&
|
||||
id === String(flightState._trackedEntity.id)),
|
||||
onSelect: _onSelect,
|
||||
onEmptyClick: _onEmptyClick,
|
||||
onEscape: _onEscape,
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
_emitAwarenessEvent,
|
||||
@@ -1256,7 +1193,10 @@ export function createTracking({
|
||||
_routeIsPlausible,
|
||||
_trackFlight,
|
||||
_onMilitaryActiveChange,
|
||||
_onKeyDown,
|
||||
_onEscape,
|
||||
_onSelect,
|
||||
_onEmptyClick,
|
||||
_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 { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -45,9 +45,19 @@ export function readLayerSource(file) {
|
||||
? '../layers/cctv'
|
||||
: '../layers/vessels',
|
||||
);
|
||||
return readdirSync(directory)
|
||||
.filter((name) => name.endsWith('.js'))
|
||||
.sort()
|
||||
.map((name) => readFileSync(join(directory, name), 'utf8'))
|
||||
// A layer's code includes its renderers (render/*.js, phase 3) and, for
|
||||
// the aircraft layers, the shared aircraft renderer they draw through:
|
||||
// checks on what the layer does keep finding it where it moved.
|
||||
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');
|
||||
}
|
||||
@@ -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) => {
|
||||
const { root, write } = fixture(t);
|
||||
write('src/geo/math.js', 'export const half = (value) => value / 2;');
|
||||
|
||||
Reference in new issue
Block a user