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:
German Khokhlov committed 2026-10-17 01:04:25 +04:00
1 parent 8e702f0c3b
commit 7f13ef4a87
33 files changed
+1974 -748

No files matched your search

+38
View File
@@ -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 (
+7
View File
@@ -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",
+12 -3
View File
@@ -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",
+15 -6
View File
@@ -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,
+11 -2
View File
@@ -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,
]),
),
);
+11
View File
@@ -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: () => ({}) });
+2 -1
View File
@@ -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)),
+1 -1
View File
@@ -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);
}
+13 -1
View File
@@ -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(
+12 -4
View File
@@ -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:/,
+420
View File
@@ -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;
},
};
}
+307
View File
@@ -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'],
);
});
+159
View File
@@ -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;
}
+3 -2
View File
@@ -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),
+6 -19
View File
@@ -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),
+14 -25
View File
@@ -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,
};
});
+1 -1
View File
@@ -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;
+52 -96
View File
@@ -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;
},
};
+15 -14
View File
@@ -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,
+2 -2
View File
@@ -7,8 +7,8 @@ import { createEnrichment } from './enrichment.js';
function services() {
const names = [
'picking',
'sprites',
'trails',
'selection',
'renderer',
'aircraftPresentation',
'camera',
'militaryRegistry',
+16 -10
View File
@@ -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),
);
// ---------------------------------------------------------------------------
+15 -34
View File
@@ -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();
},
}),
}
+23
View File
@@ -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,
});
}
+249
View File
@@ -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
View File
@@ -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,
});
},
+24 -18
View File
@@ -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
View File
@@ -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;
}
+7 -5
View File
@@ -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
View File
@@ -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,
};
}
+88
View File
@@ -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 };
}
+77
View File
@@ -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();
});
+15 -5
View File
@@ -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');
}
+37
View File
@@ -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;');