mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 06:42:23 +00:00
Delete dead render code and consolidate QA helpers
Remove code that nothing in the app, the runners or CI uses: - the one-off migration compare, look and bench scripts and the vector ground prototype they measured, with the bench tables in the docs; - the unused Navara-named shared render modules (polygons, ground picks, mover bank, ground ring); - the Cesium-shaped aircraft and vessel test renderers, and the trail, engineFromViewer and horizon-occluder helpers only they kept alive. Their tests now run on the production displays over the fake engine; - Navara's unread diagnostics, fallback ports, dead exports and parameters, stale effect-chain anchors and the unread capability flags. The QA scripts share one keyless guard and readiness wait, the session harness reuses the strict harness's browser flags and block list, and the harness honours QA_ANGLE and QA_RESERVED_PORTS. The bikeshare fixture keeps only what its scenario reads. The docs name the engine picker where it is (VISUAL PRESETS) and drop dated run results. No behaviour changes on either engine. Navara layer renderer lines fall from 189 to 0 and viewer lines from 891 to 849.
This commit is contained in:
1 parent
18f69c2699
commit
371d848eec
130 files changed
+633
-4542
No files matched your search
@@ -1,10 +1,8 @@
|
||||
import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js';
|
||||
import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js';
|
||||
|
||||
/**
|
||||
* Build the page's one globe engine on the application viewer and tie its
|
||||
* teardown to the scene lifetime; it stays registered for the viewer
|
||||
* (engineFromViewer) while it lives. Call after the viewer's own cleanup is
|
||||
* teardown to the scene lifetime. Call after the viewer's own cleanup is
|
||||
* deferred, so the engine is destroyed first. Render holds taken before the
|
||||
* render governor is installed apply when it is (src/renderGovernor.js).
|
||||
* @param {Cesium.Viewer} viewer
|
||||
@@ -20,9 +18,7 @@ export function createSceneEngine(
|
||||
{ tilesetRef, defer, createEngine = createCesiumEngine },
|
||||
) {
|
||||
const engine = createEngine(viewer, { tilesetRef });
|
||||
const unregister = registerViewerEngine(viewer, engine);
|
||||
defer(() => {
|
||||
unregister();
|
||||
engine.destroy();
|
||||
});
|
||||
return engine;
|
||||
|
||||
@@ -69,24 +69,3 @@ test('the debug handle exposes the engine beside the viewer', () => {
|
||||
/Object\.assign\(debug, \{\n\s+viewer: next\.viewer \?\? null,\n\s+engine: next\.engine,/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the scene engine is the one engineFromViewer returns for its viewer until teardown', async () => {
|
||||
const { engineFromViewer } =
|
||||
await import('../globe/cesium/engineFromViewer.js');
|
||||
const { createFakeEngine } = await import('../globe/testing/fakeEngine.js');
|
||||
const viewer = { name: 'viewer' };
|
||||
const cleanups = [];
|
||||
const engine = createSceneEngine(viewer, {
|
||||
tilesetRef: null,
|
||||
defer: (dispose) => cleanups.push(dispose),
|
||||
createEngine: () => createFakeEngine(),
|
||||
});
|
||||
assert.equal(engineFromViewer(viewer), engine);
|
||||
for (const cleanup of cleanups.reverse()) cleanup();
|
||||
assert.equal(engine.fake.destroyed, true);
|
||||
assert.equal(
|
||||
engineFromViewer(viewer),
|
||||
null,
|
||||
'no longer registered, and none is built',
|
||||
);
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
rendererFor,
|
||||
} from './renderers.js';
|
||||
import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
|
||||
import { ENGINE_KINDS, engineCapabilities } from '../../globe/contract.js';
|
||||
import { ENGINE_KINDS, assertGlobeEngine } from '../../globe/contract.js';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
|
||||
test('the manifest lists every directory layer, in its order', () => {
|
||||
@@ -25,9 +25,9 @@ test('the manifest lists every directory layer, in its order', () => {
|
||||
});
|
||||
|
||||
test('the primitives a display needs are the contract primitives every engine has', () => {
|
||||
const capabilities = engineCapabilities(createFakeEngine());
|
||||
for (const name of CONTRACT_PRIMITIVES)
|
||||
assert.equal(capabilities[name], true, name);
|
||||
const engine = createFakeEngine();
|
||||
assertGlobeEngine(engine);
|
||||
for (const name of CONTRACT_PRIMITIVES) assert.ok(engine[name], name);
|
||||
for (const [id, entry] of Object.entries(LAYER_MANIFEST))
|
||||
for (const name of entry.needs ?? [])
|
||||
assert.ok(CONTRACT_PRIMITIVES.includes(name), `${id} needs ${name}`);
|
||||
|
||||
@@ -34,7 +34,7 @@ import { registerEntityContext, selectEntityContext } from './contextStore.js';
|
||||
import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
|
||||
import { ensureGeoidReady, geoidHeight } from './geoid.js';
|
||||
import { pickRegistryFor } from './pickRegistry.js';
|
||||
const wireEngine = {};
|
||||
const wireEngine = createFakeEngine({ width: 1600, height: 900 });
|
||||
const { registerPickOwner, unregisterPickOwner } = pickRegistryFor(wireEngine);
|
||||
import { applyVesselOverlayPolicy } from './vesselLabels.js';
|
||||
import { layerFeedState } from './manager.js';
|
||||
@@ -42,12 +42,20 @@ import {
|
||||
presentSelectedVessel,
|
||||
subscribeSelectedVessel,
|
||||
} from '../hudReadouts.js';
|
||||
import { vesselViewerRenderer } from '../testSupport/vesselViewerRenderer.mjs';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { createVesselsDisplay, vesselAtlasLayout } from '../layers/vessels/render/display.js';
|
||||
|
||||
function vesselDisplay(engine = createFakeEngine({ width: 1600, height: 900 })) {
|
||||
const layout = vesselAtlasLayout();
|
||||
const renderer = createVesselsDisplay(engine, {
|
||||
drawAtlas: async () => ({ canvas: { width: layout.width, height: layout.height }, cells: layout.cells }),
|
||||
});
|
||||
renderer.fleet.attach({ show: true });
|
||||
return renderer;
|
||||
}
|
||||
|
||||
// The layer's state over the test's viewer, drawn by the Cesium-shaped test
|
||||
// renderer (its billboard collection, projection and trackedEntityChanged).
|
||||
const _setVesselStateForTest = (options = {}) =>
|
||||
setVesselStateForTest({ renderer: vesselViewerRenderer, ...options });
|
||||
setVesselStateForTest({ renderer: () => options.enabled === false ? null : vesselDisplay(options.engine), ...options });
|
||||
|
||||
test('open feed with vessels is healthy (null)', () => {
|
||||
assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null);
|
||||
@@ -953,16 +961,17 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
globalThis.document = {
|
||||
getElementById: () => null,
|
||||
};
|
||||
// The test renderer draws in no scene; the harness viewer's
|
||||
// trackedEntityChanged is its follow signal.
|
||||
const followViewer = {
|
||||
get trackedEntity() {
|
||||
return viewer.trackedEntity;
|
||||
},
|
||||
trackedEntityChanged,
|
||||
Object.defineProperty(wireEngine.camera, 'followChanged', { configurable: true, value: trackedEntityChanged });
|
||||
wireEngine.camera.following = () => viewer.trackedEntity ?? null;
|
||||
const pickedRecord = picked?.id && typeof picked.id === 'object' && Object.hasOwn(picked.id, 'mmsi') ? picked.id : null;
|
||||
const renderer = () => {
|
||||
const display = vesselDisplay(wireEngine);
|
||||
if (pickedRecord) {
|
||||
const handle = display.fleet.add({ id: pickedRecord, position: pickedRecord.position });
|
||||
picked = { id: handle.pickId };
|
||||
}
|
||||
return display;
|
||||
};
|
||||
const renderer = ({ billboardCollection }) =>
|
||||
({ ...vesselViewerRenderer({ viewer: followViewer, billboardCollection }), engine: wireEngine });
|
||||
_setVesselStateForTest({
|
||||
records: [record],
|
||||
selectedRecord: record,
|
||||
@@ -1413,7 +1422,8 @@ test('buildSelectedVesselCard: full detail card with MMSI + position time', () =
|
||||
|
||||
test('vessel host publication preserves the shipped grid winner and separation selector', () => {
|
||||
const publications = [];
|
||||
const originalProjection = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
const engine = createFakeEngine({ width: 1600, height: 900 });
|
||||
engine.projector.worldToWindow = (position) => position.screen;
|
||||
const makeCandidate = (mmsi, name, x, y, speed = 1) => makeRecord({
|
||||
mmsi,
|
||||
name,
|
||||
@@ -1423,7 +1433,6 @@ test('vessel host publication preserves the shipped grid winner and separation s
|
||||
const low = makeCandidate('100', 'VESSEL', 20, 20, 1);
|
||||
const winner = makeCandidate('200', 'NAMED WINNER', 40, 30, 16);
|
||||
const separated = makeCandidate('300', 'SEPARATED', 400, 300, 3);
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = (_scene, position) => position.screen;
|
||||
_setVesselOverlayHostForTest({
|
||||
setEntries(sourceId, entries, options) { publications.push({ sourceId, entries, options }); },
|
||||
setVisible() {},
|
||||
@@ -1432,6 +1441,7 @@ test('vessel host publication preserves the shipped grid winner and separation s
|
||||
_setVesselStateForTest({
|
||||
viewer: { scene: { canvas: { clientWidth: 1600, clientHeight: 900 } } },
|
||||
records: [low, winner, separated],
|
||||
engine,
|
||||
});
|
||||
try {
|
||||
_updateVesselCardsForTest([low, winner, separated]);
|
||||
@@ -1444,7 +1454,7 @@ test('vessel host publication preserves the shipped grid winner and separation s
|
||||
assert.equal(publications[0].options.cohortLimit, 112);
|
||||
assert.equal(publications[0].options.collisionCapacity, 112);
|
||||
} finally {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProjection;
|
||||
engine.destroy();
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
_setVesselOverlayHostForTest();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import militaryFlightsLayer, {
|
||||
_setCockpitDetectionSubjectForTest as setMilitaryCockpitSubject,
|
||||
_setTrackedMilitaryRefreshStateForTest,
|
||||
} from './militaryFlights.js';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
const SUBJECT = 'abc123';
|
||||
const NEXT_SUBJECT = 'def456';
|
||||
@@ -26,7 +26,9 @@ const LAYERS = [
|
||||
setSubject: setFlightsCockpitSubject,
|
||||
seed(icao24) {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard: candidateBillboard(icao24),
|
||||
@@ -48,7 +50,9 @@ const LAYERS = [
|
||||
setSubject: setMilitaryCockpitSubject,
|
||||
seed(icao24) {
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard: candidateBillboard(icao24),
|
||||
|
||||
+47
-75
@@ -28,8 +28,9 @@ import {
|
||||
destroyTrackedReadout,
|
||||
initTrackedReadout,
|
||||
} from './trackedReadout.js';
|
||||
import { trackingEngine } from '../testSupport/viewerEngine.mjs';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { createAircraftDisplay, aircraftAtlasLayout } from '../layers/aircraft/render/display.js';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
test('share-Follow absence requires an accepted OpenSky snapshot', async () => {
|
||||
_setFlightTrackingRefreshOutcomeForTest({ status: 'source-unavailable' });
|
||||
@@ -185,7 +186,9 @@ test('flights first update forwards caller cancellation into the feed request',
|
||||
|
||||
test('nonempty OpenSky payload with zero usable rows cannot prove share target absence', async () => {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'abc123',
|
||||
entity: { gevLabelModel: { title: 'WARM', details: [] } },
|
||||
billboard: { show: false },
|
||||
@@ -241,7 +244,9 @@ test('a newly seen aircraft appears at its delayed position, not ahead of it', a
|
||||
};
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'seed00',
|
||||
entity: null,
|
||||
billboard: {
|
||||
@@ -330,7 +335,9 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA
|
||||
const billboardCollection = { show: false, remove() {} };
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity,
|
||||
billboard,
|
||||
@@ -411,45 +418,17 @@ test('real civil track path creates no native label and publishes every cached h
|
||||
const icao24 = 'civ001';
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.6699, 30.1945, 10_668);
|
||||
const now = Cesium.JulianDate.now();
|
||||
const entities = new Cesium.EntityCollection();
|
||||
const trackedEntityChanged = new Cesium.Event();
|
||||
let trackedEntity;
|
||||
let cancelledFlights = 0;
|
||||
let appliedFrames = 0;
|
||||
const viewer = {
|
||||
entities,
|
||||
trackedEntityChanged,
|
||||
clock: { currentTime: now },
|
||||
camera: {
|
||||
cancelFlight() {
|
||||
cancelledFlights += 1;
|
||||
},
|
||||
position: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||
positionWC: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||
direction: Cesium.Cartesian3.UNIT_Y,
|
||||
transform: Cesium.Matrix4.IDENTITY,
|
||||
viewMatrix: Cesium.Matrix4.IDENTITY,
|
||||
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
|
||||
lookAtTransform() {
|
||||
appliedFrames += 1;
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 1600, clientHeight: 900 },
|
||||
frameState: { frameNumber: 41, mode: Cesium.SceneMode.SCENE3D },
|
||||
preUpdate: new Cesium.Event(),
|
||||
screenSpaceCameraController: null,
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
Object.defineProperty(viewer, 'trackedEntity', {
|
||||
get: () => trackedEntity,
|
||||
set(value) {
|
||||
trackedEntity = value;
|
||||
trackedEntityChanged.raiseEvent(value);
|
||||
},
|
||||
const engine = createFakeEngine();
|
||||
let followCalls = 0;
|
||||
const follow = engine.camera.follow;
|
||||
engine.camera.follow = (options) => { followCalls++; return follow(options); };
|
||||
const layout = aircraftAtlasLayout();
|
||||
const renderer = createAircraftDisplay(engine, {
|
||||
layerId: 'flights',
|
||||
drawAtlas: async () => ({ canvas: { width: layout.width, height: layout.height }, cells: layout.cells }),
|
||||
});
|
||||
viewer.scene.camera = viewer.camera;
|
||||
renderer.fleet.attach();
|
||||
renderer.fleet.setVisible(true);
|
||||
const billboard = {
|
||||
position,
|
||||
rotation: 0.3,
|
||||
@@ -478,17 +457,15 @@ test('real civil track path creates no native label and publishes every cached h
|
||||
},
|
||||
);
|
||||
globalThis.fetch = async () => ({ ok: false });
|
||||
const engine = trackingEngine(viewer);
|
||||
const readoutServices = createSceneServices(engine, { trackedOverlay: host });
|
||||
try {
|
||||
initTrackedReadout(engine);
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: (options) => aircraftViewerRenderer({ ...options, engine }),
|
||||
renderer: () => renderer,
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard,
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
viewer,
|
||||
tracked: false,
|
||||
history: [
|
||||
{
|
||||
@@ -525,27 +502,19 @@ test('real civil track path creates no native label and publishes every cached h
|
||||
flightsLayer.trackById(icao24, { origin: 'programmatic' }),
|
||||
true,
|
||||
);
|
||||
const entity = viewer.trackedEntity;
|
||||
assert.ok(
|
||||
entity instanceof Cesium.Entity,
|
||||
'trackById must create the real Cesium entity',
|
||||
);
|
||||
assert.equal(entity.label, undefined);
|
||||
assert.ok(
|
||||
entities.values.every((candidate) => candidate.label === undefined),
|
||||
);
|
||||
const entity = renderer.follow.current();
|
||||
assert.ok(entity, 'tracking creates a display handle');
|
||||
assert.deepEqual(entity.gevLabelModel, {
|
||||
title: 'N12345 · FL350 · 486 kts',
|
||||
details: ['TEST AIR · A320', 'AUS → LAX'],
|
||||
accent: '#39d0ff',
|
||||
});
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
const initialAppliedFrames = appliedFrames;
|
||||
const initialCancelledFlights = cancelledFlights;
|
||||
engine.fake.frame();
|
||||
const initialFollowCalls = followCalls;
|
||||
assert.equal(flightsLayer.trackById(icao24, { origin: 'user' }), true);
|
||||
assert.equal(
|
||||
cancelledFlights,
|
||||
initialCancelledFlights,
|
||||
followCalls,
|
||||
initialFollowCalls,
|
||||
'ordinary repeated tracking stays camera-idempotent',
|
||||
);
|
||||
assert.equal(
|
||||
@@ -565,21 +534,16 @@ test('real civil track path creates no native label and publishes every cached h
|
||||
'same-target refocus forwards explicit authority',
|
||||
);
|
||||
assert.equal(flightsLayer.refocusTrackedById(icao24), true);
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
engine.fake.frame();
|
||||
assert.equal(
|
||||
viewer.trackedEntity,
|
||||
renderer.follow.current(),
|
||||
entity,
|
||||
'refocus must retain the exact tracked entity',
|
||||
);
|
||||
assert.equal(
|
||||
appliedFrames,
|
||||
initialAppliedFrames + 1,
|
||||
'repeated refocus keeps one camera-frame owner',
|
||||
);
|
||||
assert.equal(
|
||||
cancelledFlights,
|
||||
initialCancelledFlights + 2,
|
||||
'only explicit refocus requests cancel camera flights',
|
||||
followCalls,
|
||||
initialFollowCalls + 2,
|
||||
'only explicit refocus requests restart following',
|
||||
);
|
||||
const publication = publications.at(-1);
|
||||
assert.equal(publication.sourceId, 'tracked');
|
||||
@@ -589,7 +553,7 @@ test('real civil track path creates no native label and publishes every cached h
|
||||
assert.equal(entry.title, entity.gevLabelModel.title);
|
||||
assert.deepEqual(entry.details, entity.gevLabelModel.details);
|
||||
|
||||
const display = entity.position.getValue(now);
|
||||
const display = entity.spec.position();
|
||||
assert.ok(display, 'tracked position callback must seed its frame cache');
|
||||
assert.equal(
|
||||
entry.position(),
|
||||
@@ -710,9 +674,11 @@ test('real civil track path creates no native label and publishes every cached h
|
||||
assert.equal(flightsLayer.getParams().selectedFlightsTrackingId, 'late005');
|
||||
} finally {
|
||||
flightsLayer.stopTracking();
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
engine.fake.frame();
|
||||
destroyTrackedReadout(engine);
|
||||
readoutServices.destroy();
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
globalThis.fetch = realFetch;
|
||||
globalThis.window = realWindow;
|
||||
}
|
||||
@@ -743,7 +709,9 @@ function seedLabelContact({
|
||||
meta: metaOverrides = {},
|
||||
}) {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: LABEL_ICAO,
|
||||
entity: null,
|
||||
billboard: {
|
||||
@@ -952,7 +920,9 @@ test('civil tracked readout: a callsign-less enriched contact reads as its regis
|
||||
const entity = { gevLabelModel: { title: 'OLD', details: [] } };
|
||||
seedLabelContact({ callsign: ' ', registration: ' N123AB ', tracked: true });
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: LABEL_ICAO,
|
||||
entity,
|
||||
billboard: {
|
||||
@@ -1091,7 +1061,9 @@ test('a newly seen taxiing aircraft starts on the floor under its delayed positi
|
||||
};
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'seed00',
|
||||
entity: null,
|
||||
billboard: {
|
||||
|
||||
@@ -21,10 +21,7 @@
|
||||
* keeps rotation continuous through tracked orbits; field evidence, not an
|
||||
* unverified math swap, decides whether exact projection becomes preferred.
|
||||
*/
|
||||
import {
|
||||
worldToWindowCoordinates,
|
||||
horizonOccluder,
|
||||
} from '../globe/cesium/sceneProjection.js';
|
||||
import { worldToWindowCoordinates } from '../globe/cesium/sceneProjection.js';
|
||||
import * as geo from '../geo/index.js';
|
||||
|
||||
// A window position through the engine projector the scene carries (the
|
||||
@@ -148,7 +145,7 @@ const _wgs84OneOverRadii = geo.ellipsoid.WGS84.oneOverRadii;
|
||||
* ABOVE the ellipsoid the horizon is the tangent silhouette and the test is a
|
||||
* genuine ray/ellipsoid hit test: 1 means the view ray through the point misses
|
||||
* the planet entirely. Here the function is the exact complement of
|
||||
* {@link horizonOccluder} — the occluder asks whether the planet is in FRONT of
|
||||
* the horizon visibility test — the occluder asks whether the planet is in FRONT of
|
||||
* a point (cull it), this asks whether it is BEHIND (what a screen-space label
|
||||
* anchored there is read against). For any point the occluder keeps, a ray that
|
||||
* hits the ellipsoid must hit it beyond the point, so the hit/miss test alone
|
||||
@@ -252,7 +249,6 @@ export function skyBackdropFactor(
|
||||
* @param {Cesium.Camera} camera - The scene camera.
|
||||
* @returns {Cesium.EllipsoidalOccluder}
|
||||
*/
|
||||
export { horizonOccluder };
|
||||
|
||||
/**
|
||||
* Cheap camera pose signature for "did the camera move" gating of rotation
|
||||
|
||||
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
HORIZON_FEATHER_RAD,
|
||||
horizonOccluder,
|
||||
screenProjectedRotation,
|
||||
skyBackdropFactor,
|
||||
stabilizeScreenRotation,
|
||||
@@ -483,7 +482,7 @@ test('below the surface the backdrop test agrees with the occluder beside it', (
|
||||
// halves use that plane. The old fail-closed broke exactly this: the occluder
|
||||
// kept a contact against sky and the backdrop test called it ground.
|
||||
const camera = Cesium.Cartesian3.fromDegrees(JFK_LON, JFK_LAT, -18);
|
||||
const occluder = horizonOccluder({ positionWC: camera });
|
||||
const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, camera);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(camera);
|
||||
const offsetBy = (up) =>
|
||||
Cesium.Cartesian3.add(
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
presentAwarenessDistance,
|
||||
} from '../layers/awareness/panel.js';
|
||||
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
import { registerLayerRenderers } from '../app/layers/renderers.js';
|
||||
|
||||
// The layer's view of a stub viewer (render/display.js's interface), drawn
|
||||
@@ -979,7 +979,9 @@ test('Contacts activation adopts the production military tracked-subject descrip
|
||||
|
||||
try {
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
@@ -1062,7 +1064,9 @@ test('Contacts activation adopts the production military tracked-subject descrip
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
@@ -1090,7 +1094,9 @@ test('production military tracked-subject label falls back to registration befor
|
||||
|
||||
try {
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
@@ -1144,7 +1150,9 @@ test('production military tracked-subject label falls back to registration befor
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
@@ -1191,7 +1199,9 @@ test('a cached Context subject re-reads its label when enrichment lands after se
|
||||
|
||||
try {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, color: Cesium.Color.WHITE, show: true },
|
||||
@@ -1280,7 +1290,9 @@ test('a cached Context subject re-reads its label when enrichment lands after se
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
@@ -1303,7 +1315,9 @@ test('Contacts activation adopts the production civilian tracked-subject descrip
|
||||
|
||||
try {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
@@ -1393,7 +1407,9 @@ test('Contacts activation adopts the production civilian tracked-subject descrip
|
||||
} finally {
|
||||
restores.reverse().forEach((restore) => restore());
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: id,
|
||||
entity: null,
|
||||
billboard: { position, show: true },
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
registerMilitaryIcaos,
|
||||
setMilitaryLayerActive,
|
||||
} from './militaryRegistry.js';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
const SHARED_HEX = 'ae5e01';
|
||||
const CIVIL_HEX = 'a0c1v1';
|
||||
@@ -85,7 +85,9 @@ test('edge:military: an ICAO in both feeds draws one target and keeps the milita
|
||||
const civilBillboards = collection();
|
||||
_armFlightTrackingRestoreForTest(null);
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'seedm0',
|
||||
entity: null,
|
||||
billboard: { show: false, position: Cesium.Cartesian3.ZERO },
|
||||
@@ -95,7 +97,9 @@ test('edge:military: an ICAO in both feeds draws one target and keeps the milita
|
||||
tracked: false,
|
||||
});
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'seedf0',
|
||||
entity: null,
|
||||
billboard: { show: false, position: Cesium.Cartesian3.ZERO },
|
||||
@@ -173,8 +177,10 @@ test('edge:military: losing the tracked contact from the owning feed releases th
|
||||
entities: { add: (value) => value, remove: () => true },
|
||||
trackedEntity: entity,
|
||||
};
|
||||
const renderer = recordingAircraftRenderer(null, { fleet: { shown: true } });
|
||||
renderer.follow.start(entity);
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: () => renderer,
|
||||
icao24: SHARED_HEX,
|
||||
entity,
|
||||
billboard: {
|
||||
@@ -214,7 +220,7 @@ test('edge:military: losing the tracked contact from the owning feed releases th
|
||||
}
|
||||
assert.equal(militaryFlightsLayer.hasContact(SHARED_HEX), false);
|
||||
assert.equal(
|
||||
viewer.trackedEntity,
|
||||
renderer.follow.current(),
|
||||
undefined,
|
||||
'the follow camera is released',
|
||||
);
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
destroyTrackedReadout,
|
||||
initTrackedReadout,
|
||||
} from './trackedReadout.js';
|
||||
import { trackingEngine } from '../testSupport/viewerEngine.mjs';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { createAircraftDisplay, aircraftAtlasLayout } from '../layers/aircraft/render/display.js';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
test('share-Follow absence requires an accepted adsb.lol snapshot', async () => {
|
||||
_setMilitaryTrackingRefreshOutcomeForTest({ status: 'source-unavailable' });
|
||||
@@ -148,7 +149,9 @@ test('military first update forwards caller cancellation into the feed request',
|
||||
|
||||
test('nonempty adsb.lol payload with zero usable rows cannot prove share target absence', async () => {
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'ae1234',
|
||||
entity: { gevLabelModel: { title: 'WARM', details: [] } },
|
||||
billboard: { show: false },
|
||||
@@ -195,7 +198,9 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p
|
||||
const billboardCollection = { show: false, remove() {} };
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity,
|
||||
billboard,
|
||||
@@ -267,7 +272,9 @@ test('cached military snapshots retain their observation time and expose stale f
|
||||
const history = [];
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'ae01ce',
|
||||
entity: { gevLabelModel: { title: 'RCH451', details: [] } },
|
||||
billboard: {
|
||||
@@ -339,45 +346,17 @@ test('real military track path creates no native label and publishes every cache
|
||||
const icao24 = 'ae01ce';
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.03, 31.05, 8_534.4);
|
||||
const now = Cesium.JulianDate.now();
|
||||
const entities = new Cesium.EntityCollection();
|
||||
const trackedEntityChanged = new Cesium.Event();
|
||||
let trackedEntity;
|
||||
let cancelledFlights = 0;
|
||||
let appliedFrames = 0;
|
||||
const viewer = {
|
||||
entities,
|
||||
trackedEntityChanged,
|
||||
clock: { currentTime: now },
|
||||
camera: {
|
||||
cancelFlight() {
|
||||
cancelledFlights += 1;
|
||||
},
|
||||
position: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||
positionWC: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||
direction: Cesium.Cartesian3.UNIT_Y,
|
||||
transform: Cesium.Matrix4.IDENTITY,
|
||||
viewMatrix: Cesium.Matrix4.IDENTITY,
|
||||
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
|
||||
lookAtTransform() {
|
||||
appliedFrames += 1;
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 1600, clientHeight: 900 },
|
||||
frameState: { frameNumber: 61, mode: Cesium.SceneMode.SCENE3D },
|
||||
preUpdate: new Cesium.Event(),
|
||||
screenSpaceCameraController: null,
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
viewer.scene.camera = viewer.camera;
|
||||
Object.defineProperty(viewer, 'trackedEntity', {
|
||||
get: () => trackedEntity,
|
||||
set(value) {
|
||||
trackedEntity = value;
|
||||
trackedEntityChanged.raiseEvent(value);
|
||||
},
|
||||
const engine = createFakeEngine();
|
||||
let followCalls = 0;
|
||||
const follow = engine.camera.follow;
|
||||
engine.camera.follow = (options) => { followCalls++; return follow(options); };
|
||||
const layout = aircraftAtlasLayout();
|
||||
const renderer = createAircraftDisplay(engine, {
|
||||
layerId: 'military',
|
||||
drawAtlas: async () => ({ canvas: { width: layout.width, height: layout.height }, cells: layout.cells }),
|
||||
});
|
||||
renderer.fleet.attach();
|
||||
renderer.fleet.setVisible(true);
|
||||
const billboard = {
|
||||
position,
|
||||
rotation: 0.4,
|
||||
@@ -406,17 +385,15 @@ test('real military track path creates no native label and publishes every cache
|
||||
},
|
||||
);
|
||||
globalThis.fetch = async () => ({ ok: false });
|
||||
const engine = trackingEngine(viewer);
|
||||
const readoutServices = createSceneServices(engine, { trackedOverlay: host });
|
||||
try {
|
||||
initTrackedReadout(engine);
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: (options) => aircraftViewerRenderer({ ...options, engine }),
|
||||
renderer: () => renderer,
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard,
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
viewer,
|
||||
tracked: false,
|
||||
history: [
|
||||
{
|
||||
@@ -440,30 +417,22 @@ test('real military track path creates no native label and publishes every cache
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
|
||||
true,
|
||||
);
|
||||
const entity = viewer.trackedEntity;
|
||||
assert.ok(
|
||||
entity instanceof Cesium.Entity,
|
||||
'trackById must create the real Cesium entity',
|
||||
);
|
||||
assert.equal(entity.label, undefined);
|
||||
assert.ok(
|
||||
entities.values.every((candidate) => candidate.label === undefined),
|
||||
);
|
||||
const entity = renderer.follow.current();
|
||||
assert.ok(entity, 'tracking creates a display handle');
|
||||
assert.deepEqual(entity.gevLabelModel, {
|
||||
title: 'RCH451',
|
||||
details: ['C17 · 05-8152', 'United States Air Force · 28000 ft · 450 kt'],
|
||||
accent: '#ffd166',
|
||||
});
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
const initialAppliedFrames = appliedFrames;
|
||||
const initialCancelledFlights = cancelledFlights;
|
||||
engine.fake.frame();
|
||||
const initialFollowCalls = followCalls;
|
||||
assert.equal(
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
cancelledFlights,
|
||||
initialCancelledFlights,
|
||||
followCalls,
|
||||
initialFollowCalls,
|
||||
'ordinary repeated tracking stays camera-idempotent',
|
||||
);
|
||||
assert.equal(
|
||||
@@ -486,21 +455,16 @@ test('real military track path creates no native label and publishes every cache
|
||||
'same-target refocus forwards explicit authority',
|
||||
);
|
||||
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true);
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
engine.fake.frame();
|
||||
assert.equal(
|
||||
viewer.trackedEntity,
|
||||
renderer.follow.current(),
|
||||
entity,
|
||||
'refocus must retain the exact tracked entity',
|
||||
);
|
||||
assert.equal(
|
||||
appliedFrames,
|
||||
initialAppliedFrames + 1,
|
||||
'repeated refocus keeps one camera-frame owner',
|
||||
);
|
||||
assert.equal(
|
||||
cancelledFlights,
|
||||
initialCancelledFlights + 2,
|
||||
'only explicit refocus requests cancel camera flights',
|
||||
followCalls,
|
||||
initialFollowCalls + 2,
|
||||
'only explicit refocus requests restart following',
|
||||
);
|
||||
const publication = publications.at(-1);
|
||||
assert.equal(publication.sourceId, 'tracked');
|
||||
@@ -510,7 +474,7 @@ test('real military track path creates no native label and publishes every cache
|
||||
assert.equal(entry.title, entity.gevLabelModel.title);
|
||||
assert.deepEqual(entry.details, entity.gevLabelModel.details);
|
||||
|
||||
const display = entity.position.getValue(now);
|
||||
const display = entity.spec.position();
|
||||
assert.ok(display, 'tracked position callback must seed its frame cache');
|
||||
assert.equal(
|
||||
entry.position(),
|
||||
@@ -649,9 +613,11 @@ test('real military track path creates no native label and publishes every cache
|
||||
);
|
||||
} finally {
|
||||
militaryFlightsLayer.stopTracking();
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
engine.fake.frame();
|
||||
destroyTrackedReadout(engine);
|
||||
readoutServices.destroy();
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
globalThis.fetch = realFetch;
|
||||
globalThis.window = realWindow;
|
||||
}
|
||||
@@ -851,7 +817,9 @@ test('the military tracked descriptor publishes the reported vertical rate', ()
|
||||
// every military contact as level flight.
|
||||
const seedRate = (verticalRateMps) =>
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: 'ae7f01',
|
||||
entity: { gevLabelModel: { title: 'TEST', details: [] } },
|
||||
billboard: {
|
||||
|
||||
@@ -12,7 +12,7 @@ import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import flightsLayer, { _setTrackedFlightRefreshStateForTest } from './flights.js';
|
||||
import militaryFlightsLayer, { _setTrackedMilitaryRefreshStateForTest } from './militaryFlights.js';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
const ICAO = 'abc123';
|
||||
const OTHER = 'def456';
|
||||
@@ -42,7 +42,9 @@ const LAYERS = [
|
||||
visualCenterM: Cesium.Cartesian3.ZERO,
|
||||
seed({ billboardShow, models = [] }) {
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: ICAO,
|
||||
entity: null,
|
||||
billboard: contactBillboard(billboardShow),
|
||||
@@ -65,7 +67,9 @@ const LAYERS = [
|
||||
visualCenterM: Cesium.Cartesian3.ZERO,
|
||||
seed({ billboardShow, models = [] }) {
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24: ICAO,
|
||||
entity: null,
|
||||
billboard: contactBillboard(billboardShow),
|
||||
|
||||
@@ -33,7 +33,7 @@ import militaryFlightsLayer, {
|
||||
import { findCompatibleHistoryIndex } from './militaryAwareness.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
import { ANALYST_LAYERS, createAnalystEngine } from './analystEngine.js';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
/** Strip block and line comments so source pins scan CODE, not prose. */
|
||||
function stripComments(source) {
|
||||
@@ -162,7 +162,9 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke
|
||||
};
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity,
|
||||
billboard,
|
||||
@@ -297,7 +299,9 @@ test('conversions are session-scoped and no lifecycle path clears them', async (
|
||||
setTr3b(icao24, true);
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard: {
|
||||
@@ -462,7 +466,9 @@ test('cockpit class filter matches a converted contact end to end', async () =>
|
||||
|
||||
// 2) Real getNearby record for a real (converted) contact in the layer.
|
||||
const seed = () => _setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard: {
|
||||
@@ -509,7 +515,9 @@ test('military records and detection cards agree with the conversion', () => {
|
||||
const icao24 = 'ae01ce';
|
||||
const center = Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 200);
|
||||
const seed = () => _setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) => recordingAircraftRenderer(null, {
|
||||
fleet: { ...billboardCollection, shown: billboardCollection?.show !== false },
|
||||
}),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard: {
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
FLEET_MODEL_ALT_CEIL_M,
|
||||
TRACKED_MODEL_EXIT_RATIO,
|
||||
} from './trackedModelRegime.js';
|
||||
import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import flightsLayer, {
|
||||
_setTrackedFlightRefreshStateForTest,
|
||||
@@ -46,7 +45,7 @@ import militaryFlightsLayer, {
|
||||
} from './militaryFlights.js';
|
||||
import { clearTr3bRegistry, setTr3b } from './tr3bRegistry.js';
|
||||
import { reportMeshFloorCell, _clearMeshFloorCellsForTest } from './groundFloor.js';
|
||||
import { aircraftViewerRenderer } from '../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { recordingAircraftRenderer } from '../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
// Sample altitudes are expressed RELATIVE to the band rather than as absolute
|
||||
// offsets. The thresholds have already been retuned once (1_000_000 → 150_000
|
||||
@@ -173,9 +172,7 @@ function viewerAtHeight(height, { tilesLoaded = true, sampleHeight = () => undef
|
||||
trackedEntity: undefined,
|
||||
entities: { remove() {} },
|
||||
};
|
||||
// The ground snap samples through the viewer's engine (the test
|
||||
// renderer's surfaceOwner, engineFromViewer): the engine surface over this stub's tileset and scene.sampleHeight.
|
||||
registerViewerEngine(viewer, {
|
||||
viewer.engine = {
|
||||
isDestroyed: () => false,
|
||||
surface: {
|
||||
tilesLoaded: () => viewer.tileset.tilesLoaded === true,
|
||||
@@ -184,7 +181,7 @@ function viewerAtHeight(height, { tilesLoaded = true, sampleHeight = () => undef
|
||||
return Number.isFinite(h) ? { height: h, source: 'tiles' } : undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
return viewer;
|
||||
}
|
||||
|
||||
@@ -207,6 +204,28 @@ function recordingModelCollection() {
|
||||
return { added, add(m) { added.push(m); }, remove() {}, isDestroyed() { return false; } };
|
||||
}
|
||||
|
||||
let loadModel = () => Promise.reject(new Error('No model fixture'));
|
||||
function rendererFor(viewer, collection) {
|
||||
return recordingAircraftRenderer(viewer.engine, {
|
||||
view: { camera: viewer.camera, scene: viewer.scene },
|
||||
fleet: { shown: true },
|
||||
models: {
|
||||
live: () => Boolean(collection) && !collection.isDestroyed(),
|
||||
loadFleet: (spec) => loadModel(spec),
|
||||
loadTracked: (spec) => loadModel(spec),
|
||||
preload: (url) => loadModel({ url }),
|
||||
add: (model) => collection.add(model),
|
||||
remove: (model) => collection?.remove(model),
|
||||
destroy: (model) => model.destroy(),
|
||||
setPose(model, position, headingDeg, offsetDeg = 180) {
|
||||
return Cesium.Transforms.headingPitchRollToFixedFrame(position,
|
||||
new Cesium.HeadingPitchRoll(Cesium.Math.toRadians((headingDeg || 0) + offsetDeg), 0, 0),
|
||||
Cesium.Ellipsoid.WGS84, undefined, model.modelMatrix);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const LAYERS = [
|
||||
{
|
||||
name: 'flights',
|
||||
@@ -220,7 +239,7 @@ const LAYERS = [
|
||||
seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) {
|
||||
const billboard = contactBillboard();
|
||||
_setTrackedFlightRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: () => rendererFor(viewer, modelCollection),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard,
|
||||
@@ -246,7 +265,7 @@ const LAYERS = [
|
||||
seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) {
|
||||
const billboard = contactBillboard();
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: () => rendererFor(viewer, modelCollection),
|
||||
icao24,
|
||||
entity: null,
|
||||
billboard,
|
||||
@@ -268,7 +287,7 @@ for (const fixture of LAYERS) {
|
||||
fixture.layer.stopTracking();
|
||||
fixture.layer.setParams({ models3d: true });
|
||||
|
||||
const realFrom = Cesium.Model.fromGltfAsync;
|
||||
const realFrom = loadModel;
|
||||
const viewer = viewerAtHeight(30_000, { tilesLoaded: false });
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668);
|
||||
const loadedCollection = recordingModelCollection();
|
||||
@@ -282,7 +301,7 @@ for (const fixture of LAYERS) {
|
||||
|
||||
try {
|
||||
const loadingIcao = fixture.name === 'flights' ? 'a0f201' : 'b0f201';
|
||||
Cesium.Model.fromGltfAsync = () => Promise.resolve(loadingModel);
|
||||
loadModel = () => Promise.resolve(loadingModel);
|
||||
const billboard = fixture.seed({
|
||||
viewer,
|
||||
tracked: false,
|
||||
@@ -316,7 +335,7 @@ for (const fixture of LAYERS) {
|
||||
|
||||
const failedIcao = fixture.name === 'flights' ? 'a0f202' : 'b0f202';
|
||||
const failedCollection = recordingModelCollection();
|
||||
Cesium.Model.fromGltfAsync = () => Promise.reject(new Error('simulated fleet GLB failure'));
|
||||
loadModel = () => Promise.reject(new Error('simulated fleet GLB failure'));
|
||||
const failedBillboard = fixture.seed({
|
||||
viewer,
|
||||
tracked: false,
|
||||
@@ -333,7 +352,7 @@ for (const fixture of LAYERS) {
|
||||
.some((object) => object.icao24 === failedIcao),
|
||||
'fleet load rejection leaves the contact in the billboard proximity cohort');
|
||||
} finally {
|
||||
Cesium.Model.fromGltfAsync = realFrom;
|
||||
loadModel = realFrom;
|
||||
fixture.layer.setParams({ models3d: false });
|
||||
fixture.layer.stopTracking();
|
||||
}
|
||||
@@ -593,7 +612,7 @@ for (const fixture of LAYERS) {
|
||||
fixture.layer.stopTracking();
|
||||
fixture.layer.setParams({ models3d: false });
|
||||
|
||||
const realFrom = Cesium.Model.fromGltfAsync;
|
||||
const realFrom = loadModel;
|
||||
const realNow = Date.now;
|
||||
let now = 3_000_000;
|
||||
Date.now = () => now;
|
||||
@@ -611,7 +630,7 @@ for (const fixture of LAYERS) {
|
||||
scale: 1,
|
||||
destroy() {},
|
||||
};
|
||||
Cesium.Model.fromGltfAsync = () => Promise.resolve(model);
|
||||
loadModel = () => Promise.resolve(model);
|
||||
|
||||
try {
|
||||
const billboard = fixture.seed({
|
||||
@@ -704,7 +723,7 @@ for (const fixture of LAYERS) {
|
||||
assert.equal(fixture.trackedBillboardColor().alpha, 1,
|
||||
'there is no invisible-owner gap while the replacement snap is unavailable');
|
||||
} finally {
|
||||
Cesium.Model.fromGltfAsync = realFrom;
|
||||
loadModel = realFrom;
|
||||
Date.now = realNow;
|
||||
fixture.layer.stopTracking();
|
||||
}
|
||||
@@ -851,14 +870,14 @@ for (const fixture of LAYERS) {
|
||||
clearTr3bRegistry();
|
||||
fixture.layer.setParams({ models3d: false });
|
||||
|
||||
const realFrom = Cesium.Model.fromGltfAsync;
|
||||
const realFrom = loadModel;
|
||||
const realNow = Date.now;
|
||||
let now = 1_000_000;
|
||||
const clock = { advance(ms) { now += ms; } };
|
||||
Date.now = () => now;
|
||||
let attempts = 0;
|
||||
const requested = [];
|
||||
Cesium.Model.fromGltfAsync = ({ url }) => {
|
||||
loadModel = ({ url }) => {
|
||||
attempts++; requested.push(url);
|
||||
return Promise.reject(new Error('simulated missing GLB'));
|
||||
};
|
||||
@@ -901,7 +920,7 @@ for (const fixture of LAYERS) {
|
||||
await driveFrames(fixture, 3, clock);
|
||||
assert.ok(attempts > settled, 'selecting another contact restores the retry budget');
|
||||
} finally {
|
||||
Cesium.Model.fromGltfAsync = realFrom;
|
||||
loadModel = realFrom;
|
||||
Date.now = realNow;
|
||||
console.warn = realWarn;
|
||||
fixture.layer.stopTracking();
|
||||
@@ -912,14 +931,14 @@ for (const fixture of LAYERS) {
|
||||
clearTr3bRegistry();
|
||||
fixture.layer.setParams({ models3d: false });
|
||||
|
||||
const realFrom = Cesium.Model.fromGltfAsync;
|
||||
const realFrom = loadModel;
|
||||
const realNow = Date.now;
|
||||
const realWarn = console.warn;
|
||||
let now = 2_000_000;
|
||||
const clock = { advance(ms) { now += ms; } };
|
||||
Date.now = () => now;
|
||||
let attempts = 0;
|
||||
Cesium.Model.fromGltfAsync = () => { attempts++; return Promise.reject(new Error('simulated missing GLB')); };
|
||||
loadModel = () => { attempts++; return Promise.reject(new Error('simulated missing GLB')); };
|
||||
console.warn = () => {};
|
||||
|
||||
try {
|
||||
@@ -938,7 +957,7 @@ for (const fixture of LAYERS) {
|
||||
assert.ok(attempts > latched,
|
||||
're-tracking the same contact is a new selection and gets its retries back');
|
||||
} finally {
|
||||
Cesium.Model.fromGltfAsync = realFrom;
|
||||
loadModel = realFrom;
|
||||
Date.now = realNow;
|
||||
console.warn = realWarn;
|
||||
fixture.layer.stopTracking();
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { createTrail } from '../globe/cesium/trail.js';
|
||||
@@ -1,39 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createTrail } from './trailRenderer.js';
|
||||
|
||||
test('trail visibility can change without discarding its accumulated geometry', () => {
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const viewer = {
|
||||
isDestroyed: () => false,
|
||||
entities: {
|
||||
add(definition) {
|
||||
added.push(definition);
|
||||
return definition;
|
||||
},
|
||||
remove(entity) {
|
||||
removed.push(entity);
|
||||
},
|
||||
},
|
||||
};
|
||||
const trail = createTrail(viewer, { color: '#ffffff' });
|
||||
const positions = [
|
||||
new Cesium.Cartesian3(1, 2, 3),
|
||||
new Cesium.Cartesian3(4, 5, 6),
|
||||
];
|
||||
|
||||
trail.setVisible(false);
|
||||
trail.setPositions(positions);
|
||||
assert.equal(added.length, 1);
|
||||
assert.equal(added[0].show, false);
|
||||
assert.deepEqual(added[0].polyline.positions.getValue(), positions);
|
||||
|
||||
trail.setVisible(true);
|
||||
assert.equal(added[0].show, true);
|
||||
assert.deepEqual(added[0].polyline.positions.getValue(), positions);
|
||||
|
||||
trail.destroy();
|
||||
assert.deepEqual(removed, [added[0]]);
|
||||
});
|
||||
@@ -56,11 +56,6 @@ test('the engine draws the visual styles, bloom and contact sonar, not the cloud
|
||||
bloom: true,
|
||||
cloudCover: false,
|
||||
contactSonar: true,
|
||||
movers: true,
|
||||
lines: true,
|
||||
ground: true,
|
||||
imagery: true,
|
||||
meshes: true,
|
||||
});
|
||||
assert.equal(engine.contactSonarDiagnostics().contacts, 0);
|
||||
engine.destroy();
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// The bridge for shell callers that hold only a Cesium viewer: the engine
|
||||
// registered for that viewer. The app registers
|
||||
// its scene engine (src/app/globeEngine.js) and unregisters it before
|
||||
// destroying it. Nothing is ever built here, so no engine can outlive its
|
||||
// viewer without a destroy path: an unregistered or torn-down viewer has none.
|
||||
|
||||
import { isGlobeEngine } from '../contract.js';
|
||||
|
||||
const engines = new WeakMap();
|
||||
|
||||
/**
|
||||
* Make `engine` the one returned for `viewer`.
|
||||
* @returns {() => void} Unregister (only while it is still the registered one).
|
||||
*/
|
||||
export function registerViewerEngine(viewer, engine) {
|
||||
// The viewer's scene and Cesium camera name the same engine, so callers
|
||||
// that hold only viewer.scene (surface samplers) or viewer.camera (camera
|
||||
// sensitivity claims) reach the engine.
|
||||
const keys = [
|
||||
viewer,
|
||||
viewer?.scene,
|
||||
viewer?.camera,
|
||||
viewer?.scene?.camera,
|
||||
].filter((key, index, all) => key != null && all.indexOf(key) === index);
|
||||
for (const key of keys) engines.set(key, engine);
|
||||
return () => {
|
||||
for (const key of keys)
|
||||
if (engines.get(key) === engine) engines.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object | null | undefined} viewer A Cesium viewer, its scene or
|
||||
* camera, or an engine (returned as is).
|
||||
* @returns {import('../contract.js').GlobeEngine | null} The registered
|
||||
* engine, or null.
|
||||
*/
|
||||
export function engineFromViewer(viewer) {
|
||||
if (viewer == null) return null;
|
||||
if (isGlobeEngine(viewer)) return viewer;
|
||||
return engines.get(viewer) ?? null;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isGlobeEngine } from '../contract.js';
|
||||
import { createFakeEngine } from '../testing/fakeEngine.js';
|
||||
import { engineFromViewer, registerViewerEngine } from './engineFromViewer.js';
|
||||
import { createFakeViewer } from './testing/fakeViewer.mjs';
|
||||
|
||||
test('a viewer without a registered engine has none: nothing is built', () => {
|
||||
const viewer = createFakeViewer();
|
||||
assert.equal(engineFromViewer(viewer), null);
|
||||
assert.deepEqual(viewer.calls, []);
|
||||
});
|
||||
|
||||
test('the engine registered for a viewer is returned until it is unregistered', () => {
|
||||
const viewer = createFakeViewer();
|
||||
const engine = createFakeEngine();
|
||||
const unregister = registerViewerEngine(viewer, engine);
|
||||
assert.equal(engineFromViewer(viewer), engine);
|
||||
unregister();
|
||||
assert.equal(engineFromViewer(viewer), null, 'a torn-down viewer has none');
|
||||
const next = createFakeEngine();
|
||||
registerViewerEngine(viewer, next);
|
||||
unregister();
|
||||
assert.equal(
|
||||
engineFromViewer(viewer),
|
||||
next,
|
||||
'a stale unregister keeps the new one',
|
||||
);
|
||||
});
|
||||
|
||||
test('an engine passes through, and no viewer is no engine', () => {
|
||||
const engine = createFakeEngine();
|
||||
assert.equal(engineFromViewer(engine), engine);
|
||||
assert.equal(engineFromViewer(null), null);
|
||||
assert.equal(engineFromViewer(undefined), null);
|
||||
});
|
||||
|
||||
test('isGlobeEngine tells engines from viewers', () => {
|
||||
assert.equal(isGlobeEngine(createFakeEngine()), true);
|
||||
assert.equal(isGlobeEngine(createFakeViewer()), false);
|
||||
assert.equal(isGlobeEngine(null), false);
|
||||
assert.equal(isGlobeEngine({ kind: 'cesium' }), false);
|
||||
});
|
||||
@@ -8,20 +8,16 @@ import { createEngineEvent } from '../contract.js';
|
||||
/**
|
||||
* @param {{ addEventListener: (listener: Function) => () => void }} source A
|
||||
* Cesium.Event (its addEventListener returns the remover the bridge uses).
|
||||
* @param {((...args: any[]) => any[]) | null} [mapArgs] Cesium arguments →
|
||||
* contract arguments.
|
||||
* @param {{ unary?: (...args: any[]) => any }} [options] `unary` maps the
|
||||
* Cesium arguments to the one contract argument without building an
|
||||
* argument list (for per-frame events).
|
||||
* @returns {{ event: import('../contract.js').EngineEvent, detach: () => void }}
|
||||
*/
|
||||
export function bridgeEvent(source, mapArgs, { unary } = {}) {
|
||||
export function bridgeEvent(source, { unary } = {}) {
|
||||
const event = createEngineEvent();
|
||||
const forward = unary
|
||||
? (a, b, c) => event.raiseValue(unary(a, b, c))
|
||||
: mapArgs
|
||||
? (...args) => event.raiseEvent(...mapArgs(...args))
|
||||
: (...args) => event.raiseEvent(...args);
|
||||
: (...args) => event.raiseEvent(...args);
|
||||
let removeForward = null;
|
||||
let detached = false;
|
||||
const sync = () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ test('a bridged event is a contract event that subscribes to Cesium lazily', ()
|
||||
|
||||
test('a bridged event maps Cesium arguments and keeps contract semantics', () => {
|
||||
const source = new Cesium.Event();
|
||||
const { event } = bridgeEvent(source, (scene, time) => [time * 2]);
|
||||
const { event } = bridgeEvent(source, { unary: (scene, time) => time * 2 });
|
||||
const seen = [];
|
||||
const listener = (value) => seen.push(value);
|
||||
event.addEventListener(listener);
|
||||
@@ -84,7 +84,7 @@ test('a bridge detaches through the remover the source returned', () => {
|
||||
|
||||
test('a unary bridge raises one mapped value', () => {
|
||||
const source = new Cesium.Event();
|
||||
const { event, detach } = bridgeEvent(source, null, {
|
||||
const { event, detach } = bridgeEvent(source, {
|
||||
unary: (scene, time) => time * 2,
|
||||
});
|
||||
const seen = [];
|
||||
|
||||
@@ -31,7 +31,7 @@ export function createCesiumFrames(viewer) {
|
||||
const { scene } = viewer;
|
||||
const bridges = ['preUpdate', 'preRender', 'postRender'].map((name) => [
|
||||
name,
|
||||
bridgeEvent(scene[name], null, { unary: unixMilliseconds }),
|
||||
bridgeEvent(scene[name], { unary: unixMilliseconds }),
|
||||
]);
|
||||
return {
|
||||
frames: Object.fromEntries(
|
||||
|
||||
@@ -106,7 +106,7 @@ czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
}`;
|
||||
|
||||
/** The uniforms of an animated corridor's material (pure, for tests). */
|
||||
export function corridorUniforms(item) {
|
||||
function corridorUniforms(item) {
|
||||
const { glow, pulse, dash } = item.animation;
|
||||
return {
|
||||
color: colorOf(item.fill),
|
||||
@@ -146,7 +146,7 @@ const corridorMaterial = (item) =>
|
||||
});
|
||||
|
||||
/** The radial disc material, including its soft outside edge. */
|
||||
export function radialMaterial(item) {
|
||||
function radialMaterial(item) {
|
||||
return new Cesium.Material({
|
||||
fabric: {
|
||||
type: 'GevGroundRadial',
|
||||
@@ -181,7 +181,7 @@ function positionsOf(flat) {
|
||||
const longitude = (degrees) => degrees - 360 * Math.round(degrees / 360);
|
||||
|
||||
/** The fill geometry of one item of a set of `kind` (pure, for tests). */
|
||||
export function fillGeometry(kind, item) {
|
||||
function fillGeometry(kind, item) {
|
||||
const vertexFormat = item.radial
|
||||
? Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat
|
||||
: Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT;
|
||||
|
||||
@@ -70,7 +70,7 @@ const toColor = ([red, green, blue, alpha]) =>
|
||||
* @param {Cesium.Matrix4} [result]
|
||||
* @returns {Cesium.Matrix4}
|
||||
*/
|
||||
export function discFollowMatrix(from, to, result = new Cesium.Matrix4()) {
|
||||
function discFollowMatrix(from, to, result = new Cesium.Matrix4()) {
|
||||
const a = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3());
|
||||
const b = Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3());
|
||||
const axis = Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3());
|
||||
|
||||
@@ -27,7 +27,7 @@ const NO_HOST = Object.freeze({ collection: null, surface: 'none' });
|
||||
/** Delay before the one frame that retries tiles a provider deferred. */
|
||||
export const TILE_RETRY_DELAY_MS = 250;
|
||||
/** How often a staged source waiting out its quiet interval rechecks readiness. */
|
||||
export const STAGE_WAKE_MS = 150;
|
||||
const STAGE_WAKE_MS = 150;
|
||||
// A staged source's own tiles are in once no request was made or settled
|
||||
// for this long; two frames in a row must find it so.
|
||||
const STAGE_QUIET_MS = 200;
|
||||
@@ -118,7 +118,7 @@ function singleImageProvider(image, rectangle, credit) {
|
||||
* for one frame when it settles), and a request the provider itself
|
||||
* deferred goes through one coalesced delayed retry.
|
||||
*/
|
||||
export function admitTileRequests(provider, admission, requestRender, retry) {
|
||||
function admitTileRequests(provider, admission, requestRender, retry) {
|
||||
const original = provider.requestImage.bind(provider);
|
||||
provider.requestImage = (x, y, level, request) => {
|
||||
if (admission.inFlight >= admission.limit) return undefined;
|
||||
|
||||
@@ -66,7 +66,7 @@ const sameEdges = (a, b) =>
|
||||
|
||||
/** Keep the shells first in the primitive collection, by rank: higher ranks
|
||||
* draw over lower ones and other opaque content draws over both. */
|
||||
export function orderImageryShells(primitives, primitive, rank) {
|
||||
function orderImageryShells(primitives, primitive, rank) {
|
||||
ranks.set(primitive, rank);
|
||||
if (typeof primitives.lowerToBottom !== 'function') return;
|
||||
const shells = [];
|
||||
|
||||
@@ -78,7 +78,7 @@ const nearFar = (value) =>
|
||||
* A model set's colour blend as Cesium Model options. Without colorBlend the
|
||||
* instance colour only sets the alpha (MIX by 0), as on Navara.
|
||||
*/
|
||||
export function modelBlend(colorBlend) {
|
||||
function modelBlend(colorBlend) {
|
||||
if (!colorBlend)
|
||||
return { colorBlendMode: Cesium.ColorBlendMode.MIX, colorBlendAmount: 0 };
|
||||
return colorBlend.mode === 'replace'
|
||||
|
||||
@@ -52,7 +52,7 @@ const BLOOM_UNIFORMS = [
|
||||
* Samples a 3x3 neighborhood, computes box blur, then adds the
|
||||
* difference (center - blur) scaled by `amount` for edge enhancement.
|
||||
*/
|
||||
export const SHARPEN_SHADER = /* glsl */ `
|
||||
const SHARPEN_SHADER = /* glsl */ `
|
||||
uniform sampler2D colorTexture;
|
||||
uniform vec2 colorTextureDimensions;
|
||||
uniform float amount;
|
||||
|
||||
@@ -6,12 +6,3 @@ export function worldToWindowCoordinates(scene, position, result) {
|
||||
result,
|
||||
);
|
||||
}
|
||||
let occluder;
|
||||
export function horizonOccluder(camera) {
|
||||
occluder ??= new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
occluder.cameraPosition = camera.positionWC;
|
||||
return occluder;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* @module trailRenderer
|
||||
* @description Shared track-history renderer (PRD WS-F F4; rebuilt round 6,
|
||||
* 2026-07-06).
|
||||
*
|
||||
* One trail = one ENTITY polyline. Round 6 replaced the faded per-vertex
|
||||
* Primitive for two owner directives from the field:
|
||||
* - "the line must ALWAYS be visible": the Primitive's depthFailAppearance
|
||||
* did not reliably render segments below the photoreal mesh — entity
|
||||
* polylines with `depthFailMaterial` DO (in-repo proof: CCTV's frustum
|
||||
* wireframes read through geometry with exactly this), so occluded
|
||||
* segments now draw dimmed instead of vanishing.
|
||||
* - "show actual tracks, don't style them too much": the tail-fade is gone;
|
||||
* the whole history renders at one readable alpha (dimmer where it passes
|
||||
* behind/below geometry).
|
||||
*
|
||||
* Trails update at poll cadence (~15-60 s) plus once on history backfill, so
|
||||
* assigning a fresh positions array per update is cheap. `allowPicking` has
|
||||
* no entity equivalent; the polyline entity is excluded from clicks by never
|
||||
* carrying a pick id the layers' click handlers resolve.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { registerPickOwner } from '../../data/pickRegistry.js';
|
||||
|
||||
// Round 6: trail ENTITIES are pickable (the old Primitive had
|
||||
// allowPicking:false). A trail hugs its aircraft, so an unclaimed pick would
|
||||
// read as "empty space" in every layer's click handler and deselect the very
|
||||
// plane being tracked. Claiming the 'gev-trail:' id namespace makes
|
||||
// isOwnedByOtherLayer() true for every layer — clicking a trail is a no-op
|
||||
// everywhere. Registered once at module load; the predicate is pure.
|
||||
registerPickOwner('trails', (pickedId) =>
|
||||
String(pickedId).startsWith('gev-trail:'),
|
||||
);
|
||||
|
||||
/** @type {number} Uniquifier for trail entity ids (Cesium requires unique entity ids). */
|
||||
let _trailSeq = 0;
|
||||
|
||||
/** @constant {number} Alpha where the trail passes the depth test. */
|
||||
const TRAIL_ALPHA = 0.85;
|
||||
/** @constant {number} Alpha where the trail is behind/below scene geometry —
|
||||
* visible (owner: never vanish) but readable as occluded. */
|
||||
const TRAIL_OCCLUDED_ALPHA = 0.4;
|
||||
/** @constant {number} Squared distance (m^2) below which consecutive points are merged. */
|
||||
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
|
||||
|
||||
/**
|
||||
* Create an always-visible polyline trail bound to a viewer.
|
||||
* @param {Cesium.Viewer} viewer - Viewer whose entity collection owns the trail.
|
||||
* @param {object} options - Trail options.
|
||||
* @param {string} options.color - CSS color string for the trail hue.
|
||||
* @param {number} [options.width=2.5] - Polyline width in pixels.
|
||||
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
|
||||
* Trail handle: setPositions replaces the geometry, setVisible temporarily
|
||||
* hides it without discarding history, clear empties it, and destroy removes
|
||||
* the entity permanently.
|
||||
*/
|
||||
export function createTrail(viewer, { color, width = 2.5 }) {
|
||||
const baseColor = Cesium.Color.fromCssColorString(color);
|
||||
/** @type {Cesium.Cartesian3[]} Current deduped positions (owned copy). */
|
||||
let current = [];
|
||||
let destroyed = false;
|
||||
let visible = true;
|
||||
/** @type {Cesium.Entity|null} */
|
||||
let entity = null;
|
||||
|
||||
function ensureEntity() {
|
||||
if (entity || destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
entity = viewer.entities.add({
|
||||
id: `gev-trail:${++_trailSeq}`,
|
||||
show: visible,
|
||||
polyline: {
|
||||
// CallbackProperty so a positions swap never rebuilds the entity —
|
||||
// Cesium re-reads on change; `false` marks it non-constant.
|
||||
positions: new Cesium.CallbackProperty(() => current, false),
|
||||
width,
|
||||
material: baseColor.withAlpha(TRAIL_ALPHA),
|
||||
// The owner-locked rule (round 6): a segment below the photoreal
|
||||
// mesh renders dimmed — it must never disappear into the ground.
|
||||
depthFailMaterial: baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA),
|
||||
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
|
||||
// globe"): NONE draws straight 3D chords between waypoints — over a
|
||||
// sparse trans-oceanic trace a single segment spans hundreds of km
|
||||
// and tunnels through the planet. GEODESIC subdivides each segment
|
||||
// along the curved surface (heights interpolated), so long legs hug
|
||||
// the globe instead of chording through it.
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Replace the trail geometry with a chronological position list
|
||||
* (oldest first). Fewer than 2 distinct positions clears the trail.
|
||||
* @param {Cesium.Cartesian3[]} cartesians - Positions, oldest -> newest.
|
||||
*/
|
||||
setPositions(cartesians) {
|
||||
if (destroyed || !viewer || viewer.isDestroyed()) return;
|
||||
// Drop consecutive near-duplicates: zero-length segments add nothing.
|
||||
const positions = [];
|
||||
for (const position of Array.isArray(cartesians) ? cartesians : []) {
|
||||
if (!position) continue;
|
||||
const last = positions[positions.length - 1];
|
||||
if (
|
||||
last &&
|
||||
Cesium.Cartesian3.distanceSquared(last, position) <
|
||||
MIN_SEGMENT_DISTANCE_SQ
|
||||
)
|
||||
continue;
|
||||
positions.push(position);
|
||||
}
|
||||
current = positions.length >= 2 ? positions : [];
|
||||
ensureEntity();
|
||||
},
|
||||
|
||||
/** Temporarily hide/show the trail without discarding accumulated history. */
|
||||
setVisible(nextVisible) {
|
||||
visible = nextVisible !== false;
|
||||
if (entity) entity.show = visible;
|
||||
},
|
||||
|
||||
/** Empty the trail without removing the entity (cheap re-arm). */
|
||||
clear() {
|
||||
current = [];
|
||||
},
|
||||
|
||||
/** Remove the trail entity permanently (layer disable/teardown). */
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
current = [];
|
||||
if (entity && viewer && !viewer.isDestroyed()) {
|
||||
try {
|
||||
viewer.entities.remove(entity);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
}
|
||||
entity = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import * as Cesium from 'cesium';
|
||||
/** Whole-Earth input limit; explicit camera destinations can go farther. */
|
||||
export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000;
|
||||
/** Below this, oblique browsing is left alone. */
|
||||
export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000;
|
||||
const PITCH_CLAMP_MIN_HEIGHT_M = 300_000;
|
||||
// Plain numbers: on a page that starts on Navara the Cesium SDK loads only
|
||||
// with the first Cesium globe, after this module (src/app/loadCesium.js).
|
||||
const RADIANS_PER_DEGREE = Math.PI / 180;
|
||||
|
||||
+6
-16
@@ -1083,10 +1083,6 @@ const OPTIONAL_SHAPE = {
|
||||
},
|
||||
};
|
||||
|
||||
// Required capabilities engineCapabilities reports as well: true for an
|
||||
// engine that has them whole, as every valid engine does.
|
||||
const REPORTED_REQUIRED = ['movers', 'lines', 'ground', 'imagery', 'meshes'];
|
||||
|
||||
const memberAt = (engine, path) =>
|
||||
path.split('.').reduce((value, key) => value?.[key], engine);
|
||||
|
||||
@@ -1094,26 +1090,20 @@ const memberAt = (engine, path) =>
|
||||
* Which optional capabilities an engine has: { visualStyles, ... } as
|
||||
* booleans. A caller that refuses a feature on an engine without one
|
||||
* (voice, say) asks here. Cesium and Navara draw the styles, bloom and
|
||||
* contact sonar; only Navara has cloudCover. `movers`, `lines`, `ground`
|
||||
* and `imagery`, plus `meshes`, are required; they are reported too, true for every valid
|
||||
* engine.
|
||||
* contact sonar; only Navara has cloudCover.
|
||||
*/
|
||||
export function engineCapabilities(engine) {
|
||||
const whole = (members, prefix = '') =>
|
||||
const whole = (members) =>
|
||||
isObject(engine) &&
|
||||
Object.entries(members).every(([path, valid]) =>
|
||||
valid(memberAt(engine, prefix + path)),
|
||||
valid(memberAt(engine, path)),
|
||||
);
|
||||
return Object.fromEntries([
|
||||
...Object.entries(OPTIONAL_SHAPE).map(([flag, members]) => [
|
||||
return Object.fromEntries(
|
||||
Object.entries(OPTIONAL_SHAPE).map(([flag, members]) => [
|
||||
flag,
|
||||
whole(members),
|
||||
]),
|
||||
...REPORTED_REQUIRED.map((capability) => [
|
||||
capability,
|
||||
whole(SHAPE[capability], `${capability}.`),
|
||||
]),
|
||||
]);
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -293,19 +293,14 @@ test('mergeSharpen keeps omitted fields and rejects bad values', () => {
|
||||
assert.throws(() => mergeSharpen(on, bad), TypeError);
|
||||
});
|
||||
|
||||
test('movers and lines are required and reported as capabilities', () => {
|
||||
test('movers and lines are required', () => {
|
||||
const engine = createFakeEngine();
|
||||
assert.equal(engineCapabilities(engine).movers, true);
|
||||
assert.equal(engineCapabilities(engine).lines, true);
|
||||
const without = { ...engine };
|
||||
delete without.movers;
|
||||
delete without.lines;
|
||||
assert.throws(() => assertGlobeEngine(without), /\bmovers\b.*\blines\b/);
|
||||
assert.equal(engineCapabilities(without).movers, false);
|
||||
assert.equal(engineCapabilities(without).lines, false);
|
||||
const half = { ...engine, movers: { createSet: () => {} } };
|
||||
assert.throws(() => assertGlobeEngine(half), /movers\.list/);
|
||||
assert.equal(engineCapabilities(half).movers, false);
|
||||
// The suite draws movers and lines on every engine.
|
||||
const names = createContractSuite(engine, {
|
||||
waitFrames: async () => {},
|
||||
@@ -317,13 +312,11 @@ test('movers and lines are required and reported as capabilities', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('ground is required and reported as a capability', () => {
|
||||
test('ground is required', () => {
|
||||
const engine = createFakeEngine();
|
||||
assert.equal(engineCapabilities(engine).ground, true);
|
||||
const without = { ...engine };
|
||||
delete without.ground;
|
||||
assert.throws(() => assertGlobeEngine(without), /\bground\b/);
|
||||
assert.equal(engineCapabilities(without).ground, false);
|
||||
const half = { ...engine, ground: { createSet: () => {} } };
|
||||
assert.throws(
|
||||
() => assertGlobeEngine(half),
|
||||
@@ -331,13 +324,11 @@ test('ground is required and reported as a capability', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('imagery is required and reported as a capability', () => {
|
||||
test('imagery is required', () => {
|
||||
const engine = createFakeEngine();
|
||||
assert.equal(engineCapabilities(engine).imagery, true);
|
||||
const without = { ...engine };
|
||||
delete without.imagery;
|
||||
assert.throws(() => assertGlobeEngine(without), /\bimagery\b/);
|
||||
assert.equal(engineCapabilities(without).imagery, false);
|
||||
const half = { ...engine, imagery: { createLayer: () => {} } };
|
||||
assert.throws(() => assertGlobeEngine(half), /imagery\.list/);
|
||||
const names = createContractSuite(engine, {
|
||||
@@ -346,9 +337,8 @@ test('imagery is required and reported as a capability', () => {
|
||||
assert.ok(names.some((name) => name.startsWith('imagery:')));
|
||||
});
|
||||
|
||||
test('meshes are required and reported as a capability', () => {
|
||||
test('meshes are required', () => {
|
||||
const engine = createFakeEngine();
|
||||
assert.equal(engineCapabilities(engine).meshes, true);
|
||||
assert.throws(
|
||||
() => assertGlobeEngine({ ...engine, meshes: undefined }),
|
||||
/meshes/,
|
||||
|
||||
+2
-21
@@ -9,13 +9,8 @@
|
||||
import * as geo from '../geo/index.js';
|
||||
import { createGroundPulseRegistry } from './groundPulse.js';
|
||||
|
||||
export const GROUND_KINDS = Object.freeze([
|
||||
'polygon',
|
||||
'disc',
|
||||
'cell',
|
||||
'corridor',
|
||||
]);
|
||||
export const GROUND_DRAPES = Object.freeze(['both', 'terrain', 'tiles']);
|
||||
const GROUND_KINDS = Object.freeze(['polygon', 'disc', 'cell', 'corridor']);
|
||||
const GROUND_DRAPES = Object.freeze(['both', 'terrain', 'tiles']);
|
||||
|
||||
const isFiniteNumber = (value) =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
@@ -382,20 +377,6 @@ export function pulseFactor({ periodMs, min, max }, seconds) {
|
||||
/** Half the soft edge of a dash, in cells (the route flow material's). */
|
||||
export const DASH_EDGE = 0.08;
|
||||
|
||||
/**
|
||||
* How lit a dash is (0..1) at `along` (0 at the corridor's start, 1 at its
|
||||
* end) and `seconds`: the cells scroll toward the end at `speed` a second.
|
||||
*/
|
||||
export function dashLit({ cells, duty, speed }, along, seconds) {
|
||||
const flow = along * cells - seconds * speed;
|
||||
const at = flow - Math.floor(flow);
|
||||
const t = Math.min(
|
||||
1,
|
||||
Math.max(0, (at - (duty - DASH_EDGE)) / (2 * DASH_EDGE)),
|
||||
);
|
||||
return 1 - t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/** A disc's boundary as a ring of `segments` points (degrees, flat). */
|
||||
export function discRing({ center, radiusM }, segments = 64) {
|
||||
const origin = geo.cartesian3.fromDegrees(center[0], center[1]);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
cellRing,
|
||||
createGroundPicks,
|
||||
createGroundRegistry,
|
||||
dashLit,
|
||||
discRing,
|
||||
normalizeGroundItem,
|
||||
normalizeGroundSetOptions,
|
||||
@@ -288,18 +287,11 @@ test("a pixel corridor's glow, pulse and dash are checked and defaulted", () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('a pulse swings between its min and max; a dash scrolls toward the end', () => {
|
||||
test('a pulse swings between its min and max', () => {
|
||||
const pulse = { periodMs: 2000, min: 0.2, max: 0.8 };
|
||||
assert.ok(Math.abs(pulseFactor(pulse, 0) - 0.5) < 1e-9);
|
||||
assert.ok(Math.abs(pulseFactor(pulse, 0.5) - 0.8) < 1e-9);
|
||||
assert.ok(Math.abs(pulseFactor(pulse, 1.5) - 0.2) < 1e-9);
|
||||
const dash = { cells: 10, duty: 0.5, speed: 1 };
|
||||
// Lit for the first half of each cell, dark for the second, soft edges.
|
||||
assert.equal(dashLit(dash, 0.02, 0), 1);
|
||||
assert.equal(dashLit(dash, 0.08, 0), 0);
|
||||
assert.ok(Math.abs(dashLit(dash, 0.05, 0) - 0.5) < 1e-9);
|
||||
// A second later the pattern has moved one cell toward the end.
|
||||
assert.equal(dashLit(dash, 0.12, 1), dashLit(dash, 0.02, 0));
|
||||
});
|
||||
|
||||
test('the registry knows when a shown set animates', () => {
|
||||
|
||||
+4
-17
@@ -8,16 +8,11 @@
|
||||
// that mosaic their own tiles (Navara). Rectangles are degrees; colours are
|
||||
// the image's own. No engine imports.
|
||||
|
||||
export const IMAGERY_SOURCE_KINDS = Object.freeze(['tiles', 'image']);
|
||||
export const IMAGERY_SPLITS = Object.freeze(['none', 'left', 'right']);
|
||||
const IMAGERY_SOURCE_KINDS = Object.freeze(['tiles', 'image']);
|
||||
const IMAGERY_SPLITS = Object.freeze(['none', 'left', 'right']);
|
||||
/** How a tiles source cuts the globe: Web Mercator XYZ, or geographic
|
||||
* (EPSG:4326, 2×1 tiles at level 0, Cesium's GeographicTilingScheme). */
|
||||
export const IMAGERY_TILE_SCHEMES = Object.freeze([
|
||||
'webMercator',
|
||||
'geographic',
|
||||
]);
|
||||
/** Where imagery drapes now (GlobeImagery.surface). */
|
||||
export const IMAGERY_SURFACES = Object.freeze(['globe', 'tiles', 'none']);
|
||||
const IMAGERY_TILE_SCHEMES = Object.freeze(['webMercator', 'geographic']);
|
||||
/** The latitude Web Mercator tiles stop at. */
|
||||
export const WEB_MERCATOR_LIMIT = 85.05112878;
|
||||
|
||||
@@ -496,7 +491,6 @@ export const mercatorY = (lat) => {
|
||||
};
|
||||
|
||||
/** Geographic tiles: x across 2^(z+1) columns, y down 2^z rows from 90°. */
|
||||
export const geographicX = (lon) => (lon + 180) / 360;
|
||||
export const geographicY = (lat) =>
|
||||
(90 - Math.max(-90, Math.min(90, lat))) / 180;
|
||||
|
||||
@@ -521,7 +515,7 @@ export function imageryTilePlan(box, rectangle, level, scheme = 'webMercator') {
|
||||
};
|
||||
}
|
||||
if (area.west >= area.east || area.south >= area.north) return null;
|
||||
const toX = geographic ? geographicX : mercatorX;
|
||||
const toX = mercatorX;
|
||||
const toY = geographic ? geographicY : mercatorY;
|
||||
for (let z = Math.max(0, Math.floor(level)); z >= 0; z--) {
|
||||
const columns = geographic ? 2 ** (z + 1) : 2 ** z;
|
||||
@@ -600,7 +594,6 @@ export function imageryTileLevel({
|
||||
*/
|
||||
export function createImageryTileQueue({ load, limit = 6 }) {
|
||||
let active = 0,
|
||||
peak = 0,
|
||||
destroyed = false;
|
||||
const pending = [],
|
||||
controllers = new Set();
|
||||
@@ -612,7 +605,6 @@ export function createImageryTileQueue({ load, limit = 6 }) {
|
||||
controllers.add(controller);
|
||||
task.controllers.add(controller);
|
||||
active++;
|
||||
peak = Math.max(peak, active);
|
||||
Promise.resolve()
|
||||
.then(() => load(task.url, controller.signal))
|
||||
.then((image) => {
|
||||
@@ -646,11 +638,6 @@ export function createImageryTileQueue({ load, limit = 6 }) {
|
||||
for (let i = pending.length - 1; i >= 0; i--)
|
||||
if (!pending[i].live()) pending.splice(i, 1);
|
||||
},
|
||||
diagnostics: () => ({
|
||||
active,
|
||||
queued: pending.filter((t) => t.live()).length,
|
||||
peak,
|
||||
}),
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
pending.length = 0;
|
||||
|
||||
@@ -430,13 +430,13 @@ test('six admissions include slow aborted decodes; stale images close without dr
|
||||
queue.add(Array.from({ length: 10 }, (_, i) => task(i)));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(pending.length, 6);
|
||||
assert.equal(queue.diagnostics().active, 6);
|
||||
assert.equal(pending.length, 6);
|
||||
live = false;
|
||||
for (const c of controllers) c.abort();
|
||||
queue.prune();
|
||||
let replacement = true;
|
||||
queue.add([{ ...task(10), live: () => replacement }]);
|
||||
assert.equal(queue.diagnostics().active, 6);
|
||||
assert.equal(pending.length, 6);
|
||||
for (const p of pending) p.resolve({ close: () => closed.push(p.url) });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(pending.length, 7);
|
||||
@@ -449,5 +449,6 @@ test('six admissions include slow aborted decodes; stale images close without dr
|
||||
assert.deepEqual(drawn, []);
|
||||
assert.deepEqual(failed, []);
|
||||
assert.equal(closed.length, 7);
|
||||
assert.deepEqual(queue.diagnostics(), { active: 0, queued: 0, peak: 6 });
|
||||
assert.equal(controllers.size, 0);
|
||||
assert.equal(pending.length, 7, 'stale queued tasks never load');
|
||||
});
|
||||
@@ -31,51 +31,10 @@ import { createGroundPicks } from '../ground.js';
|
||||
import { navaraFollowGestures } from './followGestures.js';
|
||||
import { createNavaraMaps } from './maps.js';
|
||||
import { createNavaraSurface } from './surface.js';
|
||||
import { createGroundFloor, GROUND_FLOOR } from './groundFloor.js';
|
||||
import { createGroundFloor } from './groundFloor.js';
|
||||
|
||||
const { cartesian3, math } = geo;
|
||||
|
||||
// engine.movers without the view's three runtime (Node tests of the engine).
|
||||
const NO_MOVERS = Object.freeze({
|
||||
createSet() {
|
||||
throw new Error("Navara movers need the view's three runtime (movers/)");
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const NO_LINES = Object.freeze({
|
||||
createSegments() {
|
||||
throw new Error("Navara lines need the view's three runtime (lines/)");
|
||||
},
|
||||
createLine() {
|
||||
throw new Error("Navara lines need the view's three runtime (lines/)");
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const NO_MESHES = Object.freeze({
|
||||
createMesh() {
|
||||
throw new Error('Navara meshes need the view runtime');
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const NO_GROUND = Object.freeze({
|
||||
createPulseField() {
|
||||
throw new Error('Navara ground needs the view runtime');
|
||||
},
|
||||
createSet() {
|
||||
throw new Error("Navara ground needs the view's three runtime (ground/)");
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const NO_IMAGERY = Object.freeze({
|
||||
createLayer() {
|
||||
throw new Error("Navara imagery needs the view's three runtime (imagery/)");
|
||||
},
|
||||
list: () => [],
|
||||
setSplitPosition() {},
|
||||
splitPosition: () => 0.5,
|
||||
surface: () => 'globe',
|
||||
});
|
||||
|
||||
// Navara's drawn camera (view.camera.raw, a three PerspectiveCamera in
|
||||
// Earth-fixed metres): matrixWorld columns are right, up, back, position.
|
||||
function readCamera(raw) {
|
||||
@@ -94,7 +53,7 @@ function readCamera(raw) {
|
||||
* @param {object} view An initialised Navara ThreeView.
|
||||
* @param {{
|
||||
* hooks: { tileRoots: Function, isIdle: Function, retainTileGeometry: Function, dropOrphanFeatureChanges?: Function },
|
||||
* imagery?: (parts: { camera: object, size: Function, credits: object, photorealActive: () => boolean }) => { imagery: object, diagnostics: Function, destroy: Function },
|
||||
* imagery: (parts: { camera: object, size: Function, credits: object, photorealActive: () => boolean }) => { imagery: object, destroy: Function },
|
||||
* container?: HTMLElement,
|
||||
* fetch?: typeof fetch,
|
||||
* clock?: object,
|
||||
@@ -117,11 +76,11 @@ function readCamera(raw) {
|
||||
* capability), destroyed with the engine;
|
||||
* `appClock` (the engine clock's time) defaults to the page's app clock;
|
||||
* `movers` is engine.movers on the view's three runtime (movers/index.js;
|
||||
* without it createSet throws), `lines` engine.lines (lines/index.js;
|
||||
* likewise), `ground` engine.ground (ground/index.js; likewise), all
|
||||
* required), `lines` engine.lines (lines/index.js), `ground` engine.ground
|
||||
* (ground/index.js), all
|
||||
* destroyed with the engine; `imagery` makes engine.imagery from the
|
||||
* engine's camera, size, credits and photoreal state (imagery/index.js;
|
||||
* without it createLayer throws), destroyed with the engine;
|
||||
* required), destroyed with the engine;
|
||||
* `fetch` and `clock` (the camera model's timers) are test seams.
|
||||
* @returns {import('../contract.js').GlobeEngine}
|
||||
*/
|
||||
@@ -133,11 +92,11 @@ export function createNavaraEngine(
|
||||
fetch,
|
||||
clock,
|
||||
appClock,
|
||||
movers: moversPort = null,
|
||||
lines: linesPort = null,
|
||||
ground: groundPort = null,
|
||||
imagery: imageryPort = null,
|
||||
meshes: meshesPort = null,
|
||||
movers: moversPort,
|
||||
lines: linesPort,
|
||||
ground: groundPort,
|
||||
imagery: imageryPort,
|
||||
meshes: meshesPort,
|
||||
sharpen: sharpenPort = null,
|
||||
styles: stylesPort = null,
|
||||
bloom: bloomPort = null,
|
||||
@@ -451,11 +410,11 @@ export function createNavaraEngine(
|
||||
credits: credits.credits,
|
||||
maps: maps.maps,
|
||||
clock: engineClock.clock,
|
||||
movers: moversPort?.movers ?? NO_MOVERS,
|
||||
lines: linesPort?.lines ?? NO_LINES,
|
||||
ground: groundPort?.ground ?? NO_GROUND,
|
||||
meshes: meshes?.meshes ?? NO_MESHES,
|
||||
imagery: imagery?.imagery ?? NO_IMAGERY,
|
||||
movers: moversPort.movers,
|
||||
lines: linesPort.lines,
|
||||
ground: groundPort.ground,
|
||||
meshes: meshes.meshes,
|
||||
imagery: imagery.imagery,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
@@ -494,13 +453,5 @@ export function createNavaraEngine(
|
||||
},
|
||||
// Navara only, not contract: what the ground pass holds (QA reads it).
|
||||
groundDiagnostics: () => groundPort?.diagnostics?.() ?? null,
|
||||
// Navara only, not contract: what each imagery layer holds.
|
||||
imageryDiagnostics: () => imagery?.diagnostics?.() ?? null,
|
||||
// Navara only, not contract: the ground the floor last sampled under
|
||||
// the camera and the margin it keeps above it (QA measures against it).
|
||||
groundFloor: {
|
||||
ground: () => groundFloor.ground(),
|
||||
margin: GROUND_FLOOR.MIN_ABOVE,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -100,9 +100,58 @@ const hooks = {
|
||||
retainTileGeometry() {},
|
||||
};
|
||||
|
||||
// Primitive ports for tests of engine orchestration.
|
||||
const STUB_MOVERS = Object.freeze({
|
||||
createSet() {
|
||||
throw new Error("Navara movers need the view's three runtime (movers/)");
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const STUB_LINES = Object.freeze({
|
||||
createSegments() {
|
||||
throw new Error("Navara lines need the view's three runtime (lines/)");
|
||||
},
|
||||
createLine() {
|
||||
throw new Error("Navara lines need the view's three runtime (lines/)");
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const STUB_MESHES = Object.freeze({
|
||||
createMesh() {
|
||||
throw new Error('Navara meshes need the view runtime');
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const STUB_GROUND = Object.freeze({
|
||||
createPulseField() {
|
||||
throw new Error('Navara ground needs the view runtime');
|
||||
},
|
||||
createSet() {
|
||||
throw new Error("Navara ground needs the view's three runtime (ground/)");
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const STUB_IMAGERY = Object.freeze({
|
||||
createLayer() {
|
||||
throw new Error("Navara imagery needs the view's three runtime (imagery/)");
|
||||
},
|
||||
list: () => [],
|
||||
setSplitPosition() {},
|
||||
splitPosition: () => 0.5,
|
||||
surface: () => 'globe',
|
||||
});
|
||||
|
||||
const ports = {
|
||||
movers: { movers: STUB_MOVERS, destroy() {} },
|
||||
lines: { lines: STUB_LINES, destroy() {} },
|
||||
ground: { ground: STUB_GROUND, destroy() {} },
|
||||
imagery: () => ({ imagery: STUB_IMAGERY, destroy() {} }),
|
||||
meshes: () => ({ meshes: STUB_MESHES, destroy() {} }),
|
||||
};
|
||||
|
||||
const setup = () => {
|
||||
const view = fakeView();
|
||||
const engine = createNavaraEngine(view, { hooks });
|
||||
const engine = createNavaraEngine(view, { ...ports, hooks });
|
||||
return { view, engine };
|
||||
};
|
||||
|
||||
@@ -135,7 +184,7 @@ test('the engine clock is the app clock, latched on the view preUpdate before fr
|
||||
let wall = 2_000_000;
|
||||
const appClock = createAppClock({ now: () => wall });
|
||||
const view = fakeView();
|
||||
const engine = createNavaraEngine(view, { hooks, appClock });
|
||||
const engine = createNavaraEngine(view, { ...ports, hooks, appClock });
|
||||
assert.equal(engine.clock.now(), 2_000_000);
|
||||
const seen = [];
|
||||
for (const name of ['preUpdate', 'preRender', 'postRender'])
|
||||
@@ -177,6 +226,7 @@ test('setSharpen hands changed states to the sharpen port and draws a frame', ()
|
||||
const shown = [];
|
||||
let destroyed = 0;
|
||||
const engine = createNavaraEngine(view, {
|
||||
...ports,
|
||||
hooks,
|
||||
sharpen: {
|
||||
set: (state) => shown.push(state),
|
||||
@@ -225,7 +275,7 @@ function stylesPort() {
|
||||
test('setStyle hands changed style states to the styles port and draws a frame', () => {
|
||||
const view = fakeView();
|
||||
const port = stylesPort();
|
||||
const engine = createNavaraEngine(view, { hooks, styles: port });
|
||||
const engine = createNavaraEngine(view, { ...ports, hooks, styles: port });
|
||||
assertGlobeEngine(engine);
|
||||
assert.equal(engineCapabilities(engine).visualStyles, true);
|
||||
assert.deepEqual(engine.render.visualStyles, ['retro', 'noir']);
|
||||
@@ -267,6 +317,7 @@ test('setBloom hands changed states to the bloom port and draws a frame', () =>
|
||||
const shown = [];
|
||||
let destroyed = 0;
|
||||
const engine = createNavaraEngine(view, {
|
||||
...ports,
|
||||
hooks,
|
||||
bloom: { set: (state) => shown.push(state), destroy: () => destroyed++ },
|
||||
});
|
||||
@@ -294,6 +345,7 @@ test('setCloudCover hands changed covers to the clouds port and draws a frame',
|
||||
const shown = [];
|
||||
let destroyed = 0;
|
||||
const engine = createNavaraEngine(view, {
|
||||
...ports,
|
||||
hooks,
|
||||
clouds: { set: (state) => shown.push(state), destroy: () => destroyed++ },
|
||||
});
|
||||
@@ -317,6 +369,7 @@ test('setContactSonar hands changes to the sonar port, asking a frame only outsi
|
||||
const shown = [];
|
||||
let destroyed = 0;
|
||||
const engine = createNavaraEngine(view, {
|
||||
...ports,
|
||||
hooks,
|
||||
sonar: { set: (state) => shown.push(state), destroy: () => destroyed++ },
|
||||
});
|
||||
@@ -591,10 +644,3 @@ test('the floor stands aside while the camera inputs are suspended (the cockpit
|
||||
near(heightOf(engine), 130, 1e-6, 'lifted once the inputs are back');
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('the ground floor reports its own ground and margin (diagnostics, not contract)', () => {
|
||||
const { engine } = setup();
|
||||
assert.equal(typeof engine.groundFloor.ground, 'function');
|
||||
assert.equal(engine.groundFloor.margin, 30);
|
||||
assert.equal(engine.groundFloor.ground(), undefined, 'nothing sampled yet');
|
||||
});
|
||||
@@ -33,12 +33,3 @@ export function withPassDisposal(Base) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// The base the layers' Navara effects extend (engine.LayerEffectDesc): per
|
||||
// frame updates on their own callback, and the raw pass disposed when the
|
||||
// effect is deleted. A layer re-creates its effect on every attach (an
|
||||
// engine switch), so an undisposed pass kept its material, its shader
|
||||
// program and the uniforms' data textures alive for the page's lifetime.
|
||||
export function layerEffectDesc(EffectDesc) {
|
||||
return withPassDisposal(withFrameUpdates(EffectDesc));
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
layerEffectDesc,
|
||||
withFrameUpdates,
|
||||
withPassDisposal,
|
||||
} from './frameDescriptor.js';
|
||||
@@ -81,46 +80,3 @@ test('a deleted effect disposes the pass the composer no longer holds', () => {
|
||||
assert.throws(() => new (withPassDisposal(Failing))().onDestroy(), /removal/);
|
||||
assert.deepEqual(events, ['dispose']);
|
||||
});
|
||||
|
||||
test('a layer effect re-created on every attach leaves no pass, material or frame callback behind', () => {
|
||||
const frames = new Set();
|
||||
const view = {
|
||||
on: (name, cb) => frames.add(cb),
|
||||
off: (name, cb) => frames.delete(cb),
|
||||
};
|
||||
const live = new Set();
|
||||
class EffectDesc {
|
||||
view = view;
|
||||
onCreate() {
|
||||
const pass = {
|
||||
material: {},
|
||||
dispose: () => live.delete(pass),
|
||||
};
|
||||
live.add(pass);
|
||||
this._instance = { rawPass: pass };
|
||||
}
|
||||
get raw() {
|
||||
return this._instance;
|
||||
}
|
||||
onDestroy() {
|
||||
// Navara's removePass: out of the composer, not disposed.
|
||||
this._instance = undefined;
|
||||
}
|
||||
}
|
||||
// As the earthquakes ground effect: disposes its own effect, then super.
|
||||
class LayerEffect extends layerEffectDesc(EffectDesc) {
|
||||
updateFrame() {}
|
||||
onDestroy() {
|
||||
this.effectDisposed = true;
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
for (let attach = 0; attach < 50; attach++) {
|
||||
const effect = new LayerEffect();
|
||||
effect.onCreate();
|
||||
effect.onDestroy();
|
||||
assert.equal(effect.effectDisposed, true);
|
||||
}
|
||||
assert.equal(live.size, 0, 'every pass of a deleted effect is disposed');
|
||||
assert.equal(frames.size, 0, 'no frame callback outlives its effect');
|
||||
});
|
||||
@@ -378,7 +378,7 @@ export function tileCorridors(
|
||||
* `reachOf(item)` is the item's [half width in pixels + 0.5, half width in
|
||||
* metres]; `reference` the set's least pixel reach.
|
||||
*/
|
||||
export function packTile(tile, pieces, items, frame, reachOf, reference) {
|
||||
export function packTile(tile, pieces, frame, reference) {
|
||||
const { project } = frame;
|
||||
// Its region in the plane, from its edges.
|
||||
let x0 = Infinity;
|
||||
|
||||
@@ -292,7 +292,7 @@ export function packSet(
|
||||
(tile.west + tile.east) / 2,
|
||||
(tile.south + tile.north) / 2,
|
||||
);
|
||||
const packed = packTile(tile, pieces, items, frame, reachOf, reference);
|
||||
const packed = packTile(tile, pieces, frame, reference);
|
||||
// Runs and bins relative to the tile's own: its style holds where
|
||||
// they start in the set's.
|
||||
const runStart = runs.length / 4;
|
||||
|
||||
@@ -381,14 +381,7 @@ export const textureBytes = (state) =>
|
||||
class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
|
||||
static key = GROUND_EFFECT_KEY;
|
||||
// Navara takes the first anchor that exists: the latest first.
|
||||
static insertAfter = [
|
||||
'gevFirmsGround',
|
||||
'gevEarthquakeGround',
|
||||
'gevObservedWeather',
|
||||
'gevNepalEvent',
|
||||
'gevImagery',
|
||||
'toneMapping',
|
||||
];
|
||||
static insertAfter = ['gevImagery', 'toneMapping'];
|
||||
static insertBefore = [
|
||||
'gevPreSharpenOverlay',
|
||||
'gevSharpen',
|
||||
|
||||
@@ -85,13 +85,7 @@ const pulseEffect = defineGroundEffect({
|
||||
key: KEY,
|
||||
name: 'GevGroundPulses',
|
||||
fragmentShader,
|
||||
insertAfter: [
|
||||
'gevGround',
|
||||
'gevObservedWeather',
|
||||
'gevNepalEvent',
|
||||
'gevImagery',
|
||||
'toneMapping',
|
||||
],
|
||||
insertAfter: ['gevGround', 'gevImagery', 'toneMapping'],
|
||||
insertBefore: [
|
||||
'gevPreSharpenOverlay',
|
||||
'gevSharpen',
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
// Public depth inputs shared by actual terrain/tiles classifiers.
|
||||
import {
|
||||
DataTexture,
|
||||
FloatType,
|
||||
RGBAFormat,
|
||||
NearestFilter,
|
||||
Matrix4,
|
||||
Uniform,
|
||||
RGBADepthPacking,
|
||||
} from 'three';
|
||||
export function createGroundTexture(data, width, height) {
|
||||
const texture = new DataTexture(data, width, height, RGBAFormat, FloatType);
|
||||
texture.minFilter = NearestFilter;
|
||||
texture.magFilter = NearestFilter;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
export function createGroundStorage(texels) {
|
||||
const width = Math.min(1024, Math.max(1, texels));
|
||||
const height = Math.max(1, Math.ceil(texels / width));
|
||||
const data = new Float32Array(width * height * 4);
|
||||
return {
|
||||
data,
|
||||
width,
|
||||
height,
|
||||
texture: createGroundTexture(data, width, height),
|
||||
};
|
||||
}
|
||||
import { Matrix4, Uniform, RGBADepthPacking } from 'three';
|
||||
export function groundSurfaceUniforms() {
|
||||
return [
|
||||
['tAllDepth', new Uniform(null)],
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
// A prototype of engine.ground on Navara's own vector layers, kept to
|
||||
// measure against the screen pass (pass.js) and not used by the engine:
|
||||
// each set's items grouped by style into one GeoJSON source and one
|
||||
// clamped vector layer per group (discs and cells as polygons, pixel
|
||||
// corridors as polylines; metre corridors are not drawn). `hide` picks how a
|
||||
// hidden set goes: 'delete' (its layers deleted, rebuilt when shown) or
|
||||
// 'update' (layer.update with show false).
|
||||
|
||||
import { areaRings, createGroundRegistry } from '../../ground.js';
|
||||
|
||||
const toHex = (rgba) =>
|
||||
(Math.round(rgba[0] * 255) << 16) |
|
||||
(Math.round(rgba[1] * 255) << 8) |
|
||||
Math.round(rgba[2] * 255);
|
||||
|
||||
function ringCoordinates(flat) {
|
||||
const ring = [];
|
||||
for (let index = 0; index < flat.length; index += 2)
|
||||
ring.push([flat[index], flat[index + 1]]);
|
||||
ring.push(ring[0]);
|
||||
return ring;
|
||||
}
|
||||
|
||||
// Features grouped by their layer style: Map<key, { style, features }>.
|
||||
export function vectorGroups(kind, items) {
|
||||
const groups = new Map();
|
||||
const add = (key, style, feature) => {
|
||||
if (!groups.has(key)) groups.set(key, { style, features: [] });
|
||||
groups.get(key).features.push(feature);
|
||||
};
|
||||
items.forEach((item, index) => {
|
||||
if (kind === 'corridor') {
|
||||
if (!item.widthPx) return;
|
||||
const coordinates = [];
|
||||
for (let at = 0; at < item.path.length; at += 2)
|
||||
coordinates.push([item.path[at], item.path[at + 1]]);
|
||||
add(
|
||||
`line:${item.fill.join()}:${item.widthPx}`,
|
||||
{
|
||||
polyline: {
|
||||
color: toHex(item.fill),
|
||||
width: item.widthPx,
|
||||
opacity: item.fill[3],
|
||||
transparent: item.fill[3] < 1,
|
||||
clampToGround: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
id: index,
|
||||
properties: {},
|
||||
geometry: { type: 'LineString', coordinates },
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rings = areaRings(kind, item).map(ringCoordinates);
|
||||
const fill = item.fill ?? [0, 0, 0, 0];
|
||||
const outline = item.outline;
|
||||
add(
|
||||
`area:${fill.join()}:${outline ? `${outline.color.join()}:${outline.widthPx}` : ''}`,
|
||||
{
|
||||
polygon: {
|
||||
color: toHex(fill),
|
||||
opacity: fill[3],
|
||||
transparent: true,
|
||||
clampToGround: true,
|
||||
...(outline
|
||||
? {
|
||||
outline: true,
|
||||
outlineShow: true,
|
||||
outlineColor: toHex(outline.color),
|
||||
outlineWidth: outline.widthPx,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
id: index,
|
||||
properties: {},
|
||||
geometry: { type: 'Polygon', coordinates: rings },
|
||||
},
|
||||
);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} view an initialised Navara ThreeView
|
||||
* @param {{ hide?: 'delete'|'update' }} [options]
|
||||
*/
|
||||
export function createNavaraVectorGround(view, { hide = 'delete' } = {}) {
|
||||
const registry = createGroundRegistry((options) => {
|
||||
let groups = new Map();
|
||||
let batches = [];
|
||||
let shown = true;
|
||||
const release = () => {
|
||||
for (const { layer, source } of batches) {
|
||||
layer.delete();
|
||||
source.delete();
|
||||
}
|
||||
batches = [];
|
||||
};
|
||||
const build = () => {
|
||||
release();
|
||||
for (const { style, features } of groups.values()) {
|
||||
const source = view.addSource({
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features },
|
||||
});
|
||||
const config = { type: 'vector', source, ...style };
|
||||
batches.push({ source, config, layer: view.addLayer(config) });
|
||||
}
|
||||
};
|
||||
return {
|
||||
commit(items) {
|
||||
groups = vectorGroups(options.kind, items);
|
||||
if (shown || hide === 'update') build();
|
||||
if (!shown && hide === 'update')
|
||||
for (const batch of batches)
|
||||
batch.layer.update({ ...batch.config, show: false });
|
||||
view.forceUpdate();
|
||||
},
|
||||
show(visible) {
|
||||
if (visible === shown) return;
|
||||
shown = visible;
|
||||
if (hide === 'delete') {
|
||||
if (visible) build();
|
||||
else release();
|
||||
} else
|
||||
for (const batch of batches)
|
||||
batch.layer.update({ ...batch.config, show: visible });
|
||||
view.forceUpdate();
|
||||
},
|
||||
destroy() {
|
||||
release();
|
||||
view.forceUpdate();
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
ground: registry.ground,
|
||||
pickables: () => [...registry.live.values()],
|
||||
destroy: () => registry.destroyAll(),
|
||||
};
|
||||
}
|
||||
@@ -107,7 +107,7 @@ const unwrapped = ({ west, south, east, north }) => [
|
||||
* replace); `load` (a tile or an image URL to a decoded image) and
|
||||
* `createCanvas` are test seams.
|
||||
* @returns {{ imagery: import('../../contract.js').GlobeImagery,
|
||||
* diagnostics: () => object, destroy: () => void }}
|
||||
* destroy: () => void }}
|
||||
*/
|
||||
export function createNavaraImagery(
|
||||
view,
|
||||
@@ -323,12 +323,6 @@ export function createNavaraImagery(
|
||||
detail?.held.texture.dispose();
|
||||
detail = null;
|
||||
},
|
||||
diagnostics: () => ({
|
||||
kind: 'image',
|
||||
loaded: drawn !== null,
|
||||
staging: staging !== null,
|
||||
detail: detail !== null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -457,17 +451,6 @@ export function createNavaraImagery(
|
||||
queue.destroy();
|
||||
disposeAtlas();
|
||||
},
|
||||
diagnostics: () => ({
|
||||
...queue.diagnostics(),
|
||||
atlas: atlas
|
||||
? {
|
||||
level: atlas.plan.z,
|
||||
tiles: atlas.plan.width * atlas.plan.height,
|
||||
loaded: atlas.loaded,
|
||||
failed: atlas.failed,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -551,11 +534,6 @@ export function createNavaraImagery(
|
||||
tiler?.dispose();
|
||||
tiler = null;
|
||||
},
|
||||
diagnostics: () => ({
|
||||
kind: 'tiles',
|
||||
...(tiler?.diagnostics() ?? {}),
|
||||
staging: staged ? staged.tiler.diagnostics() : null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -659,16 +637,6 @@ export function createNavaraImagery(
|
||||
|
||||
return {
|
||||
imagery: registry.imagery,
|
||||
/** Not contract: what each layer holds (QA reads it). */
|
||||
diagnostics: () =>
|
||||
Object.fromEntries(
|
||||
registry
|
||||
.ordered()
|
||||
.map((state) => [
|
||||
state.options.id,
|
||||
entries.get(state)?.drawing.diagnostics(),
|
||||
]),
|
||||
),
|
||||
destroy() {
|
||||
offCamera();
|
||||
registry.destroyAll();
|
||||
|
||||
@@ -18,7 +18,7 @@ function fixture() {
|
||||
const view = {
|
||||
forceUpdate: () => frames++,
|
||||
};
|
||||
const { imagery, diagnostics, destroy } = createNavaraImagery(view, {
|
||||
const { imagery, destroy } = createNavaraImagery(view, {
|
||||
camera: {
|
||||
changed,
|
||||
pose: () => ({
|
||||
@@ -53,7 +53,6 @@ function fixture() {
|
||||
});
|
||||
return {
|
||||
imagery,
|
||||
diagnostics,
|
||||
destroy,
|
||||
canvases,
|
||||
draws,
|
||||
@@ -80,10 +79,10 @@ test('a tiles layer mosaics the visible tiles into one atlas; a new source drops
|
||||
const f = fixture();
|
||||
const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
|
||||
await tick();
|
||||
const first = f.diagnostics().a;
|
||||
assert.equal(first.atlas.level, 12);
|
||||
assert.ok(first.atlas.tiles >= 1 && first.atlas.tiles <= 64);
|
||||
assert.equal(f.jobs.length, Math.min(6, first.atlas.tiles), 'six at a time');
|
||||
const canvas = f.canvases[0];
|
||||
const count = (canvas.width / 256) * (canvas.height / 256);
|
||||
assert.ok(count >= 1 && count <= 64);
|
||||
assert.equal(f.jobs.length, Math.min(6, count), 'six at a time');
|
||||
assert.match(
|
||||
f.jobs[0].url,
|
||||
/^https:\/\/gibs-[abc]\.example\/12\/\d+\/\d+\.png$/,
|
||||
@@ -189,13 +188,13 @@ test('a hidden tiles layer drops its atlas; shown again it builds one at the vie
|
||||
const f = fixture();
|
||||
const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
|
||||
layer.show(false);
|
||||
assert.equal(f.diagnostics().a.atlas, null);
|
||||
assert.equal(f.last().slots.length, 0);
|
||||
assert.equal(f.credits.get('NASA GIBS'), 0, 'its credit goes too');
|
||||
f.changed.raiseEvent();
|
||||
assert.equal(f.diagnostics().a.atlas, null);
|
||||
assert.equal(f.last().slots.length, 0);
|
||||
layer.show(true);
|
||||
await tick();
|
||||
assert.ok(f.diagnostics().a.atlas);
|
||||
assert.ok(f.last().slots[0].texture.image.width > 0);
|
||||
assert.equal(f.last().slots.length, 1);
|
||||
});
|
||||
|
||||
@@ -215,8 +214,6 @@ test('geographic tiles draw in degrees through the source’s transform', async
|
||||
}),
|
||||
});
|
||||
await tick();
|
||||
const { atlas } = f.diagnostics().radar;
|
||||
assert.equal(atlas.level, 6, 'the source’s top level');
|
||||
const [slot] = f.last().slots;
|
||||
assert.equal(slot.look[2], 0, 'sampled in degrees');
|
||||
const [west, south, east, north] = slot.bounds;
|
||||
@@ -242,7 +239,6 @@ test('a staged tiles source builds its own atlas under the drawn one and takes i
|
||||
);
|
||||
await tick();
|
||||
assert.equal(f.last().slots[0].texture, drawn, 'the drawn atlas stays');
|
||||
assert.ok(f.diagnostics().a.staging, 'the staged atlas loads');
|
||||
const next = f.jobs.slice(firstJobs);
|
||||
assert.ok(next.length > 0 && next.every((job) => /next/.test(job.url)));
|
||||
let done;
|
||||
|
||||
@@ -21,7 +21,7 @@ import { DataTexture, Matrix4, RGBADepthPacking, Uniform } from 'three';
|
||||
import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js';
|
||||
import { MAX_IMAGERY_SLOTS } from './index.js';
|
||||
|
||||
export const IMAGERY_EFFECT_KEY = 'gevImagery';
|
||||
const IMAGERY_EFFECT_KEY = 'gevImagery';
|
||||
|
||||
const SLOTS = Array.from({ length: MAX_IMAGERY_SLOTS }, (_, index) => index);
|
||||
|
||||
@@ -179,9 +179,6 @@ class ImageryEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
|
||||
// Navara takes the first anchor that exists: right after tone mapping.
|
||||
static insertAfter = ['toneMapping'];
|
||||
static insertBefore = [
|
||||
'gevNepalEvent',
|
||||
'gevObservedWeather',
|
||||
'gevEarthquakeGround',
|
||||
'gevGroundPulses',
|
||||
'gevGround',
|
||||
'gevPreSharpenOverlay',
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
// the product way (viewOptions.js) and the GlobeEngine on it. Load it with a
|
||||
// dynamic import() so Navara stays out of the Cesium-mode bundle.
|
||||
|
||||
import { layerEffectDesc, withFrameUpdates } from './frameDescriptor.js';
|
||||
import ThreeView, {
|
||||
Color as NavaraColor,
|
||||
MeshDesc,
|
||||
EffectDesc,
|
||||
Effect as NavaraEffect,
|
||||
DrapedMesh,
|
||||
setupMaterialForDrape,
|
||||
} from '@navaramap/three';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { DefaultPlugin } from '@navaramap/three-default-plugin';
|
||||
@@ -124,7 +121,7 @@ function groundInView(engine) {
|
||||
* `?look=` (cinematic by default); `hd` draws at the device pixel ratio
|
||||
* with MSAA (Navara HD), else at CSS pixels without it. The sun follows the engine clock (the
|
||||
* page's app clock): the look reads its frame time.
|
||||
* @returns {Promise<{ view: object, engine: import('../contract.js').GlobeEngine, destroy: () => void }>}
|
||||
* @returns {Promise<{ setRenderLoop: (enabled: boolean) => void, engine: import('../contract.js').GlobeEngine, destroy: () => void }>}
|
||||
*/
|
||||
export async function createNavaraGlobe({ container, search, hd = false }) {
|
||||
if (!container) throw new TypeError('createNavaraGlobe needs a container');
|
||||
@@ -272,10 +269,6 @@ export async function createNavaraGlobe({ container, search, hd = false }) {
|
||||
view,
|
||||
MeshDesc,
|
||||
NavaraColor,
|
||||
EffectDesc: layerEffectDesc(EffectDesc),
|
||||
NavaraEffect,
|
||||
DrapedMesh,
|
||||
setupMaterialForDrape,
|
||||
alignDaylightToRestoredView() {
|
||||
look.resetDaylight();
|
||||
engine.render.request('restored-daylight');
|
||||
@@ -283,20 +276,7 @@ export async function createNavaraGlobe({ container, search, hd = false }) {
|
||||
}),
|
||||
});
|
||||
return {
|
||||
view,
|
||||
engine,
|
||||
NavaraColor,
|
||||
MeshDesc,
|
||||
EffectDesc,
|
||||
LayerEffectDesc: layerEffectDesc(EffectDesc),
|
||||
NavaraEffect,
|
||||
DrapedMesh,
|
||||
setupMaterialForDrape,
|
||||
// Startup can fly from the engine's placeholder camera into a shared view.
|
||||
alignDaylightToRestoredView() {
|
||||
look.resetDaylight();
|
||||
engine.render.request('restored-daylight');
|
||||
},
|
||||
setRenderLoop: (enabled) => {
|
||||
parked = !enabled;
|
||||
setRenderLoopEnabled(view, enabled);
|
||||
|
||||
@@ -34,7 +34,5 @@ export function createBatchIdPool() {
|
||||
released.add(wrapper);
|
||||
for (const id of wrapper.batchIds) if (id > 0) free.push(id);
|
||||
},
|
||||
/** The ids waiting for a wrapper. */
|
||||
size: () => free.length,
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,6 @@ test('a rebuilt set takes the ids of the set it replaces', () => {
|
||||
set = wrapper(pool.source(ctx), 1000);
|
||||
}
|
||||
assert.equal(ctx.issued(), 1000);
|
||||
assert.equal(pool.size(), 0);
|
||||
assert.equal(new Set(set.batchIds).size, 1000);
|
||||
});
|
||||
|
||||
@@ -35,7 +34,6 @@ test('live sets never share an id, and a set gives its ids back once', () => {
|
||||
const a = wrapper(pool.source(ctx), 3);
|
||||
pool.release(a);
|
||||
pool.release(a);
|
||||
assert.equal(pool.size(), 3);
|
||||
const b = wrapper(pool.source(ctx), 2);
|
||||
const c = wrapper(pool.source(ctx), 2);
|
||||
const ids = [...b.batchIds, ...c.batchIds];
|
||||
@@ -47,7 +45,6 @@ test('ids Navara could not give (0) are not pooled', () => {
|
||||
const pool = createBatchIdPool();
|
||||
pool.release({ batchIds: [0, 0, 7] });
|
||||
pool.release(null);
|
||||
assert.equal(pool.size(), 1);
|
||||
const none = { genGlobalBatchId: () => undefined };
|
||||
assert.equal(pool.source(none).genGlobalBatchId(), 7);
|
||||
assert.equal(pool.source(none).genGlobalBatchId(), undefined);
|
||||
|
||||
+3
-11
@@ -22,19 +22,11 @@ async function declared(key, path, className) {
|
||||
};
|
||||
}
|
||||
const specs = [
|
||||
await declared(
|
||||
'gevImagery',
|
||||
'../../../globe/navara/imagery/pass.js',
|
||||
'ImageryEffectDesc',
|
||||
),
|
||||
await declared(
|
||||
'gevGround',
|
||||
'../../../globe/navara/ground/pass.js',
|
||||
'GroundEffectDesc',
|
||||
),
|
||||
await declared('gevImagery', './imagery/pass.js', 'ImageryEffectDesc'),
|
||||
await declared('gevGround', './ground/pass.js', 'GroundEffectDesc'),
|
||||
await declared(
|
||||
'gevPreSharpenOverlay',
|
||||
'../../../globe/navara/overlay.js',
|
||||
'./overlay.js',
|
||||
'GevPreSharpenOverlayEffectDesc',
|
||||
),
|
||||
];
|
||||
@@ -8,8 +8,6 @@
|
||||
|
||||
import { createCpuPicker } from '../cpuPicks.js';
|
||||
|
||||
export { distanceToSegment } from '../cpuPicks.js';
|
||||
|
||||
const CLICK_TIMEOUT_MS = 250;
|
||||
// A GPU answer this recent belongs to the click being delivered.
|
||||
const EARLY_ANSWER_MS = 100;
|
||||
|
||||
@@ -2,7 +2,8 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createMoverStore, normalizeMoverSetOptions } from '../movers.js';
|
||||
import { normalizeLineOptions } from '../lines.js';
|
||||
import { createNavaraPicking, distanceToSegment } from './picking.js';
|
||||
import { createNavaraPicking } from './picking.js';
|
||||
import { distanceToSegment } from '../cpuPicks.js';
|
||||
|
||||
// A projector that maps x → screen x and y → screen y, z is the depth (the
|
||||
// distance from the camera at the origin along +z).
|
||||
|
||||
@@ -46,7 +46,7 @@ export function surfaceRay(place) {
|
||||
}
|
||||
|
||||
/** Height above the ellipsoid of a hit `distance` metres down a surfaceRay. */
|
||||
export const heightAlongRay = (ray, distance) =>
|
||||
export const heightAlongRay = (distance) =>
|
||||
Number.isFinite(distance) ? RAY_START_HEIGHT - distance : undefined;
|
||||
|
||||
const worldRay = new Ray();
|
||||
@@ -279,7 +279,7 @@ export function createNavaraSurface(view, hooks) {
|
||||
shownRoots.length = 0;
|
||||
if (!Number.isFinite(hit.distance))
|
||||
hit = finestHiddenHit(roots, excluded, ray);
|
||||
const height = heightAlongRay(ray, hit.distance);
|
||||
const height = heightAlongRay(hit.distance);
|
||||
if (height === undefined) return terrainDetail(place);
|
||||
return { height, source: 'tiles', tileSizeM: tileSizeOf(hit.root) };
|
||||
};
|
||||
|
||||
@@ -84,8 +84,8 @@ test('surfaceRay runs down the ellipsoid normal from 12 km', () => {
|
||||
cartesian3.create(),
|
||||
);
|
||||
near(cartographic.fromCartesian(below, WGS84).height, 0, 1e-6, 'ground');
|
||||
assert.equal(heightAlongRay(ray, 11950), 50);
|
||||
assert.equal(heightAlongRay(ray, null), undefined);
|
||||
assert.equal(heightAlongRay(11950), 50);
|
||||
assert.equal(heightAlongRay(null), undefined);
|
||||
});
|
||||
|
||||
test('sampleHeight hits the top of the loaded tiles and keeps tile geometry', () => {
|
||||
|
||||
@@ -249,7 +249,6 @@ export class IntelHUD {
|
||||
this.basemapContext = basemapContext;
|
||||
this.placeSearch = placeSearch;
|
||||
this.engine = engine;
|
||||
this.viewer = viewer;
|
||||
this._visible = false;
|
||||
this._autoMode = true; // auto show/hide based on style
|
||||
this._currentStyle = 'normal';
|
||||
@@ -1236,7 +1235,6 @@ export class IntelHUD {
|
||||
this._removeCameraMoveStart = null;
|
||||
this._summaryRequest?.abort();
|
||||
this.engine = engine ?? null;
|
||||
this.viewer = viewer;
|
||||
if (!this.engine) return;
|
||||
this._removeCameraMoveStart =
|
||||
this.engine.camera.moveStart?.addEventListener(this._onCameraMoveStart);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// engine.camera.follow (the follow camera), engine.projector and
|
||||
// engine.camera.pose() (the view the logic reads). The engine draws.
|
||||
//
|
||||
// Handles keep the Cesium field vocabulary the logic writes (contract.js) as
|
||||
// Handles expose the fields the logic writes (contract.js) as
|
||||
// setters that write the engine's instance stores; the writes of a frame are
|
||||
// committed once, at the end of the frame listener that made them (or in a
|
||||
// microtask for writes outside a frame), so a fleet tick draws in its frame.
|
||||
@@ -23,13 +23,9 @@ import {
|
||||
drawAircraftIconAtlas,
|
||||
} from '../../../data/aircraftIcons.js';
|
||||
import { createEngineLayerView } from '../../shared/render/engineView.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { Rgba, rgbaFromCss } from '../../shared/color.js';
|
||||
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
|
||||
import {
|
||||
createMoverBank,
|
||||
StoredColor,
|
||||
writeFields,
|
||||
} from '../../shared/render/moverBank.js';
|
||||
import { createMoverBank, writeFields } from '../../shared/render/moverBank.js';
|
||||
|
||||
const BASE_SIZE_PX = 20;
|
||||
|
||||
@@ -41,7 +37,7 @@ const BASE_SIZE_PX = 20;
|
||||
*/
|
||||
const GLTF_AXIS = [0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1];
|
||||
// The trail's alpha where it passes the depth test and where the scene hides
|
||||
// it (as data/trailRenderer.js draws track histories), and the live head's.
|
||||
// it, and the live head's.
|
||||
const TRAIL_ALPHA = 0.85;
|
||||
const TRAIL_OCCLUDED_ALPHA = 0.4;
|
||||
const HEAD_ALPHA = 0.9;
|
||||
@@ -245,7 +241,12 @@ export function createAircraftDisplay(
|
||||
this._scale = spec.scale ?? 1;
|
||||
this._width = spec.width ?? BASE_SIZE_PX;
|
||||
this._height = spec.height ?? BASE_SIZE_PX;
|
||||
this._color = new StoredColor(spec.color);
|
||||
this._color = new Rgba(
|
||||
spec.color?.red ?? 1,
|
||||
spec.color?.green ?? 1,
|
||||
spec.color?.blue ?? 1,
|
||||
spec.color?.alpha ?? 1,
|
||||
);
|
||||
this._image = spec.image;
|
||||
this.cell = iconCells.get(spec.image) ?? iconCells.get(defaultIcon);
|
||||
this._scaleByDistance = spec.scaleByDistance
|
||||
@@ -354,7 +355,12 @@ export function createAircraftDisplay(
|
||||
(value?.alpha ?? 1) === this._color.alpha
|
||||
)
|
||||
return;
|
||||
this._color = new StoredColor(value);
|
||||
this._color = new Rgba(
|
||||
value?.red ?? 1,
|
||||
value?.green ?? 1,
|
||||
value?.blue ?? 1,
|
||||
value?.alpha ?? 1,
|
||||
);
|
||||
rgbaOf(this._color);
|
||||
this.write(spriteColorField);
|
||||
}
|
||||
@@ -493,7 +499,12 @@ export function createAircraftDisplay(
|
||||
this.placed = false;
|
||||
this._show = true;
|
||||
this._scale = spec.scale ?? 1;
|
||||
this._color = new StoredColor(spec.color);
|
||||
this._color = new Rgba(
|
||||
spec.color?.red ?? 1,
|
||||
spec.color?.green ?? 1,
|
||||
spec.color?.blue ?? 1,
|
||||
spec.color?.alpha ?? 1,
|
||||
);
|
||||
this._id = spec.id;
|
||||
this.modelMatrix = geo.matrix4.create();
|
||||
this.translation = geo.cartesian3.create();
|
||||
@@ -562,7 +573,12 @@ export function createAircraftDisplay(
|
||||
}
|
||||
|
||||
set color(value) {
|
||||
this._color = new StoredColor(value);
|
||||
this._color = new Rgba(
|
||||
value?.red ?? 1,
|
||||
value?.green ?? 1,
|
||||
value?.blue ?? 1,
|
||||
value?.alpha ?? 1,
|
||||
);
|
||||
this.gevBank?.update(this, { color: rgbaOf(this._color) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,12 +545,11 @@ export function createAircraftTracking({
|
||||
}
|
||||
flightState._trail?.setVisible(!flightState._cockpitContactMode);
|
||||
// Live head segment: last DISPLAYED body point → current dead-reckoned icon, updated
|
||||
// every frame via 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.
|
||||
// every frame through the display callback. Keeps the head glued
|
||||
// to the 12 Hz icon while the trail body updates less often.
|
||||
if (!flightState._trailHeadEntity && flightState._renderer) {
|
||||
flightState._trailHeadEntity = flightState._renderer.trails.createHead({
|
||||
// 'gev-trail' namespace: claimed by trailRenderer's pick
|
||||
// 'gev-trail' namespace: claimed by the display's pick
|
||||
// owner so a click on the head segment never reads as empty space.
|
||||
id: `gev-trail:${trailHeadIdPrefix}-head-${++flightState._trailHeadSeq}`,
|
||||
show: !flightState._cockpitContactMode,
|
||||
@@ -812,10 +811,8 @@ export function createAircraftTracking({
|
||||
flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick
|
||||
|
||||
// Stop tracking and remove the entity. skipViewerUntrack: when ANOTHER layer just grabbed the
|
||||
// follow-camera, we tear down our own state but must NOT clear viewer.trackedEntity (the new owner
|
||||
// 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.
|
||||
// follow camera, tear down only our own state and keep the new owner.
|
||||
// Releasing our follow keeps the camera pose so the user can orbit or zoom.
|
||||
if (flightState._renderer && !skipViewerUntrack) {
|
||||
flightState._renderer.follow.release();
|
||||
}
|
||||
@@ -869,12 +866,12 @@ export function createAircraftTracking({
|
||||
* Begin tracking a specific aircraft by ICAO24 address.
|
||||
*
|
||||
* Clears any existing tracked flight, hides its billboard, and creates a
|
||||
* new Entity with:
|
||||
* - A CallbackProperty position driven by dead-reckoning (_deadReckon).
|
||||
* - A CallbackProperty rotation from the aircraft's course.
|
||||
* tracked display handle with:
|
||||
* - A position callback driven by dead-reckoning (_deadReckon).
|
||||
* - A rotation callback from the aircraft's course.
|
||||
* - An explicit host presentation model (the layer's tracked card).
|
||||
*
|
||||
* The viewer's trackedEntity is set to this entity so the camera follows it.
|
||||
* The display starts the engine follow for this handle.
|
||||
*
|
||||
* @param {string} icao24 - ICAO 24-bit transponder address to track.
|
||||
*/
|
||||
@@ -980,10 +977,8 @@ export function createAircraftTracking({
|
||||
trackedStyle.labelColor,
|
||||
);
|
||||
|
||||
// A billboard has a ~zero bounding sphere, so Cesium's default follow distance is
|
||||
// far too tight (the user had to scroll out to read the plane). Give the entity a
|
||||
// calibrated viewFrom — behind + above, distance scaled to altitude — for a readable
|
||||
// initial frame with surrounding context. (ENU: east=+X, north=+Y, up=+Z.)
|
||||
// Frame the aircraft behind and above, with distance scaled to altitude.
|
||||
// The offset uses ENU: east=+X, north=+Y, up=+Z.
|
||||
const followRange = Math.min(
|
||||
Math.max((followAltitudeM(info) || 1500) * 1.1 + 2500, 3000),
|
||||
30000,
|
||||
@@ -994,10 +989,6 @@ export function createAircraftTracking({
|
||||
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
|
||||
// often fires mid-fly_to_location) would follow the plane WITHOUT centering/framing it
|
||||
// the way a click (idle camera) does.
|
||||
// Expose the camera's already-settled position to cross-module HUD consumers (the tracked-target
|
||||
// readout) so they draw at the SAME spot the camera framed, without recomputing the dead-reckon in
|
||||
// postRender (which would jitter the label against the now-stable plane).
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { horizonVisible } from '../../globe/cameraMath.js';
|
||||
import {
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
HIDDEN_CONTACT_DR_INTERVAL_MS,
|
||||
@@ -93,7 +93,10 @@ function fleetFixture() {
|
||||
view: {
|
||||
camera,
|
||||
scene: {},
|
||||
horizon: () => horizonOccluder(camera),
|
||||
horizon: () => ({
|
||||
isPointVisible: (position) =>
|
||||
horizonVisible(camera.positionWC, position),
|
||||
}),
|
||||
frustumTester: () => () => true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -935,7 +935,7 @@ export function createQueries({
|
||||
view.holdFrames();
|
||||
setFocusEvidenceNowMs(startMs);
|
||||
flightState._lastFleetTickMs = startMs - FLEET_DR_INTERVAL_MS;
|
||||
// Cross a browser task boundary, then close Cesium's pending-loop
|
||||
// Cross a browser task boundary, then close the renderer's pending-loop
|
||||
// latch. Any already-queued callback still observes the false gate,
|
||||
// while manual evidence renders can begin without waiting on VSYNC.
|
||||
return new Promise((resolve) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CYAN_TRANSPARENT, MODEL_HEADING_OFFSET_DEG } from './policy.js';
|
||||
import { CYAN_TRANSPARENT } from './policy.js';
|
||||
import { CYAN } from '../shared/color.js';
|
||||
|
||||
export function createTesting({
|
||||
@@ -46,9 +46,8 @@ export function createTesting({
|
||||
* @param {object} state.meta
|
||||
* @param {object} state.billboard
|
||||
* @param {object} state.billboardCollection
|
||||
* @param {object} state.viewer
|
||||
* @param {(options: object) => object} state.renderer The test's renderer
|
||||
* over the viewer and collections (testSupport/aircraftViewerRenderer).
|
||||
* with the view and collection fixtures for this test.
|
||||
* @param {Array<Object>} [state.history=[]]
|
||||
* @param {boolean} [state.tracked=true]
|
||||
* @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed
|
||||
@@ -61,19 +60,14 @@ export function createTesting({
|
||||
meta,
|
||||
billboard,
|
||||
billboardCollection,
|
||||
viewer,
|
||||
history = [],
|
||||
tracked = true,
|
||||
models = [],
|
||||
modelCollection = null,
|
||||
renderer: createRenderer,
|
||||
}) {
|
||||
// The test's renderer over its viewer and (fake) collections.
|
||||
// The test's renderer over its fleet collection.
|
||||
flightState._renderer = createRenderer({
|
||||
viewer,
|
||||
billboardCollection,
|
||||
modelCollection,
|
||||
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
|
||||
});
|
||||
flightState.enabled = flightState._renderer.fleet.visible();
|
||||
flightState._billboards = new Map([[icao24, billboard]]);
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { createMilitaryFlightLayer } from './index.js';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createTracking } from './tracking.js';
|
||||
import { aircraftViewerRenderer } from '../../testSupport/aircraftViewerRenderer.mjs';
|
||||
import { recordingAircraftRenderer } from '../../testSupport/aircraftRecordingRenderer.mjs';
|
||||
|
||||
function services() {
|
||||
const names = [
|
||||
@@ -183,7 +183,13 @@ async function firstPlacement(appClockLagMs) {
|
||||
});
|
||||
const added = [];
|
||||
layer.testing._setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) =>
|
||||
recordingAircraftRenderer(null, {
|
||||
fleet: {
|
||||
...billboardCollection,
|
||||
shown: billboardCollection?.show !== false,
|
||||
},
|
||||
}),
|
||||
icao24: 'seed00',
|
||||
entity: null,
|
||||
meta: { rawLat: 0, rawLon: 0, onGround: false },
|
||||
@@ -287,7 +293,13 @@ test('a vanished military contact fades while position-less rows keep theirs', a
|
||||
});
|
||||
const viewer = { camera: { positionCartographic: null }, scene: {} };
|
||||
layer.testing._setTrackedMilitaryRefreshStateForTest({
|
||||
renderer: aircraftViewerRenderer,
|
||||
renderer: ({ billboardCollection }) =>
|
||||
recordingAircraftRenderer(null, {
|
||||
fleet: {
|
||||
...billboardCollection,
|
||||
shown: billboardCollection?.show !== false,
|
||||
},
|
||||
}),
|
||||
icao24: 'seed00',
|
||||
entity: null,
|
||||
meta: { rawLat: 0, rawLon: 0, onGround: false },
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
AMBER_TRANSPARENT,
|
||||
MODEL_HEADING_OFFSET_DEG,
|
||||
TRACKED_ICON_COLOR,
|
||||
} from './policy.js';
|
||||
import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR } from './policy.js';
|
||||
|
||||
export function createTesting({
|
||||
flightState,
|
||||
@@ -21,9 +17,8 @@ export function createTesting({
|
||||
* @param {object} state.meta
|
||||
* @param {object} state.billboard
|
||||
* @param {object} state.billboardCollection
|
||||
* @param {object} state.viewer
|
||||
* @param {(options: object) => object} state.renderer The test's renderer
|
||||
* over the viewer and collections (testSupport/aircraftViewerRenderer).
|
||||
* with the view and collection fixtures for this test.
|
||||
* @param {Array<Object>} [state.history=[]]
|
||||
* @param {boolean} [state.tracked=true]
|
||||
* @param {Iterable<[string, object]>} [state.models=[]] - Fleet 3D models keyed
|
||||
@@ -36,20 +31,15 @@ export function createTesting({
|
||||
meta,
|
||||
billboard,
|
||||
billboardCollection,
|
||||
viewer,
|
||||
history = [],
|
||||
tracked = true,
|
||||
models = [],
|
||||
modelCollection = null,
|
||||
renderer: createRenderer,
|
||||
}) {
|
||||
flightState.enabled = billboardCollection?.show !== false;
|
||||
// The test's renderer over its viewer and (fake) collections.
|
||||
// The test's renderer over its fleet collection.
|
||||
flightState._renderer = createRenderer({
|
||||
viewer,
|
||||
billboardCollection,
|
||||
modelCollection,
|
||||
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
|
||||
});
|
||||
flightState._billboards = new Map([[icao24, billboard]]);
|
||||
flightState._cullPositions.clear();
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// A geographic ring is local, including across 180°. Put the click and ring in
|
||||
// the same longitude interval before casting the ray.
|
||||
export function containsGroundPoint(ring, lon, lat) {
|
||||
const origin = ring[0][0];
|
||||
const local = (x) => x - origin - 360 * Math.round((x - origin) / 360);
|
||||
const x = local(lon);
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [ax, ay] = ring[j],
|
||||
[bx, by] = ring[i];
|
||||
if (ay > lat === by > lat) continue;
|
||||
const a = local(ax),
|
||||
b = local(bx);
|
||||
if (x < a + ((b - a) * (lat - ay)) / (by - ay)) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
@@ -112,17 +112,3 @@ export function createMoverBank(engine, { options, write, onCommit }) {
|
||||
};
|
||||
return bank;
|
||||
}
|
||||
|
||||
/** Plain colour copy (the stored value a handle's colour getter returns). */
|
||||
export class StoredColor {
|
||||
constructor(color) {
|
||||
this.red = color?.red ?? 1;
|
||||
this.green = color?.green ?? 1;
|
||||
this.blue = color?.blue ?? 1;
|
||||
this.alpha = color?.alpha ?? 1;
|
||||
}
|
||||
|
||||
withAlpha(alpha) {
|
||||
return new StoredColor({ ...this, alpha });
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { distanceToSegment } from '../../../globe/cpuPicks.js';
|
||||
import { containsGroundPoint } from './groundRing.js';
|
||||
|
||||
function groundScreen(engine, [lon, lat]) {
|
||||
const point = geo.cartographic.fromDegrees(lon, lat);
|
||||
const height = engine.surface.terrainHeight(point) ?? 0;
|
||||
const position = geo.cartesian3.fromDegrees(lon, lat, height);
|
||||
return (
|
||||
engine.projector.horizonVisible(position) &&
|
||||
engine.projector.worldToWindow(position, {})
|
||||
);
|
||||
}
|
||||
|
||||
// The GPU may return the preceding gesture's vector feature. Resolve against
|
||||
// the current gesture and let the engine arbitrate the result with sibling layers.
|
||||
export function bindGroundShapePicks(engine, owner, shapes) {
|
||||
const regions = [];
|
||||
for (const { id, geometry, style } of shapes) {
|
||||
const coords =
|
||||
geometry.type === 'point'
|
||||
? [geometry.position]
|
||||
: geometry.type === 'line'
|
||||
? geometry.positions
|
||||
: geometry.outer;
|
||||
const west = coords[0][0];
|
||||
const local = (lon) => lon - west - 360 * Math.round((lon - west) / 360);
|
||||
let south = 90,
|
||||
north = -90,
|
||||
min = Infinity,
|
||||
max = -Infinity;
|
||||
for (const [lon, lat] of coords) {
|
||||
south = Math.min(south, lat);
|
||||
north = Math.max(north, lat);
|
||||
min = Math.min(min, local(lon));
|
||||
max = Math.max(max, local(lon));
|
||||
}
|
||||
regions.push({
|
||||
geometry,
|
||||
style,
|
||||
coords,
|
||||
south,
|
||||
north,
|
||||
min,
|
||||
max,
|
||||
local,
|
||||
id,
|
||||
});
|
||||
}
|
||||
const pick = (at) => {
|
||||
const world = engine.picker.pickGlobe(at);
|
||||
if (!world) return [];
|
||||
const carto = geo.cartographic.fromCartesian(world);
|
||||
const lon = geo.math.toDegrees(carto.longitude),
|
||||
lat = geo.math.toDegrees(carto.latitude);
|
||||
const depth = geo.cartesian3.distance(world, engine.camera.livePosition);
|
||||
const pose = engine.camera.pose();
|
||||
// Conservative geographic margin for a displaced terrain pixel/marker at this zoom.
|
||||
const margin =
|
||||
(2 * Math.tan(pose.fovy / 2) * depth * 32) /
|
||||
Math.max(1, engine.host.size().height) /
|
||||
110000;
|
||||
const lonMargin = margin / Math.max(0.00001, Math.cos(carto.latitude));
|
||||
const hits = [];
|
||||
for (const region of regions) {
|
||||
const x = region.local(lon);
|
||||
if (
|
||||
lat < region.south - margin ||
|
||||
lat > region.north + margin ||
|
||||
(region.max - region.min < 180 &&
|
||||
(x < region.min - lonMargin || x > region.max + lonMargin))
|
||||
)
|
||||
continue;
|
||||
const { geometry, style, coords } = region;
|
||||
let hit = false;
|
||||
if (geometry.type === 'point') {
|
||||
const screen = groundScreen(engine, geometry.position);
|
||||
const size = style.pointRadius ?? 7;
|
||||
hit =
|
||||
screen &&
|
||||
Math.abs(at.x - screen.x) <= size &&
|
||||
Math.abs(at.y - screen.y) <= size;
|
||||
} else {
|
||||
if (geometry.type === 'polygon' && style.fill !== false)
|
||||
hit =
|
||||
containsGroundPoint(coords, lon, lat) &&
|
||||
!geometry.holes.some((ring) => containsGroundPoint(ring, lon, lat));
|
||||
if (!hit && (geometry.type === 'line' || style.outline !== false)) {
|
||||
let last =
|
||||
geometry.type === 'polygon'
|
||||
? groundScreen(engine, coords.at(-1))
|
||||
: null;
|
||||
for (const coord of coords) {
|
||||
const next = groundScreen(engine, coord);
|
||||
if (
|
||||
last &&
|
||||
next &&
|
||||
distanceToSegment(at, last, next) <=
|
||||
Math.max(2, Math.min(8, style.lineWidth ?? 2) / 2)
|
||||
) {
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
last = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hit)
|
||||
hits.push({
|
||||
onTop: 0,
|
||||
order: geometry.type === 'point' ? 1 : 0,
|
||||
depth,
|
||||
result: { id: region.id, owner },
|
||||
});
|
||||
}
|
||||
return hits;
|
||||
};
|
||||
return engine.picker.registerMeshPicker(pick);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// The mover bank under the name the Navara renderers import it by.
|
||||
export { createMoverBank, StoredColor, writeFields } from './moverBank.js';
|
||||
@@ -1,65 +0,0 @@
|
||||
/** GeoJSON features grouped by paint. Swap complete batches so a failed
|
||||
* source or layer allocation cannot wipe the last rendered result.
|
||||
*
|
||||
* Hidden means no layers at all: Navara 0.2.1 sometimes drops the first
|
||||
* `layer.update({ show: false })` after a layer is added (a zone turned off
|
||||
* right after an engine switch stayed drawn), while a deleted layer always
|
||||
* goes. Showing again rebuilds the batches from the last groups. */
|
||||
export function createNavaraPolygonDisplay(engine, view, style) {
|
||||
let visible = false;
|
||||
let groups = new Map();
|
||||
let batches = [];
|
||||
function release(items) {
|
||||
for (const { layer, source } of items) {
|
||||
layer?.delete();
|
||||
source.delete();
|
||||
}
|
||||
}
|
||||
function build(next) {
|
||||
const built = [];
|
||||
try {
|
||||
for (const [css, features] of next) {
|
||||
const color = Number.parseInt(css.slice(1), 16);
|
||||
const source = view.addSource({
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features },
|
||||
});
|
||||
const batch = { source };
|
||||
built.push(batch);
|
||||
batch.layer = view.addLayer({
|
||||
type: 'vector',
|
||||
source,
|
||||
...style(color),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
release(built);
|
||||
throw error;
|
||||
}
|
||||
release(batches);
|
||||
batches = built;
|
||||
}
|
||||
return {
|
||||
setVisible(show) {
|
||||
if (visible === show) return;
|
||||
if (show) build(groups);
|
||||
else {
|
||||
release(batches);
|
||||
batches = [];
|
||||
}
|
||||
visible = show;
|
||||
engine.render.request('polygon-visibility');
|
||||
},
|
||||
replace(next) {
|
||||
if (visible) build(next);
|
||||
groups = next;
|
||||
engine.render.request('polygon-data');
|
||||
},
|
||||
destroy() {
|
||||
release(batches);
|
||||
batches = [];
|
||||
groups = new Map();
|
||||
engine.render.request('polygon-destroy');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNavaraPolygonDisplay } from './navaraPolygons.js';
|
||||
|
||||
function fakeView({ failOn = null } = {}) {
|
||||
const live = new Set();
|
||||
let added = 0;
|
||||
const handle = (kind, description) => {
|
||||
const item = {
|
||||
kind,
|
||||
description,
|
||||
delete: () => live.delete(item),
|
||||
update: () => assert.fail('the display never updates a layer in place'),
|
||||
};
|
||||
live.add(item);
|
||||
return item;
|
||||
};
|
||||
return {
|
||||
live,
|
||||
layers: () => [...live].filter((item) => item.kind === 'layer'),
|
||||
addSource: (description) => handle('source', description),
|
||||
addLayer: (description) => {
|
||||
if (++added === failOn) throw new Error('layer allocation failed');
|
||||
return handle('layer', description);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const engine = { render: { request() {} } };
|
||||
const groups = (...colors) =>
|
||||
new Map(colors.map((css) => [css, [{ type: 'Feature', id: css }]]));
|
||||
|
||||
test('a hidden display holds no Navara layers, and showing it rebuilds them', () => {
|
||||
const view = fakeView();
|
||||
const display = createNavaraPolygonDisplay(engine, view, (color) => ({
|
||||
polygon: { color },
|
||||
}));
|
||||
display.replace(groups('#ff0000', '#00ff00'));
|
||||
assert.equal(view.live.size, 0, 'nothing is built while hidden');
|
||||
display.setVisible(true);
|
||||
assert.deepEqual(
|
||||
view.layers().map((layer) => layer.description.polygon.color),
|
||||
[0xff0000, 0x00ff00],
|
||||
);
|
||||
// Navara 0.2.1 may drop a show:false update on a new layer: hiding deletes.
|
||||
display.setVisible(false);
|
||||
assert.equal(view.live.size, 0);
|
||||
display.setVisible(true);
|
||||
assert.equal(view.layers().length, 2);
|
||||
display.destroy();
|
||||
assert.equal(view.live.size, 0);
|
||||
});
|
||||
|
||||
test('a visible display swaps whole batches and keeps the last on a failure', () => {
|
||||
const view = fakeView({ failOn: 3 });
|
||||
const display = createNavaraPolygonDisplay(engine, view, (color) => ({
|
||||
polygon: { color },
|
||||
}));
|
||||
display.setVisible(true);
|
||||
display.replace(groups('#ff0000'));
|
||||
display.replace(groups('#0000ff'));
|
||||
assert.deepEqual(
|
||||
view.layers().map((layer) => layer.description.polygon.color),
|
||||
[0x0000ff],
|
||||
);
|
||||
assert.throws(
|
||||
() => display.replace(groups('#00ff00')),
|
||||
/layer allocation failed/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
view.layers().map((layer) => layer.description.polygon.color),
|
||||
[0x0000ff],
|
||||
);
|
||||
assert.equal(view.live.size, 2, 'the failed batch released its source');
|
||||
// The failed rows were not adopted: hiding and showing redraws the last.
|
||||
display.setVisible(false);
|
||||
display.setVisible(true);
|
||||
assert.deepEqual(
|
||||
view.layers().map((layer) => layer.description.polygon.color),
|
||||
[0x0000ff],
|
||||
);
|
||||
});
|
||||
@@ -2,9 +2,21 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createVesselLayer } from './index.js';
|
||||
import { createVesselState } from './state.js';
|
||||
import { vesselViewerRenderer } from '../../testSupport/vesselViewerRenderer.mjs';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
import { createVesselsDisplay, vesselAtlasLayout } from './render/display.js';
|
||||
|
||||
const noop = () => {};
|
||||
function vesselDisplay() {
|
||||
const layout = vesselAtlasLayout();
|
||||
const renderer = createVesselsDisplay(createFakeEngine(), {
|
||||
drawAtlas: async () => ({
|
||||
canvas: { width: layout.width, height: layout.height },
|
||||
cells: layout.cells,
|
||||
}),
|
||||
});
|
||||
renderer.fleet.attach({ show: true });
|
||||
return renderer;
|
||||
}
|
||||
function services() {
|
||||
return {
|
||||
context: {
|
||||
@@ -28,7 +40,7 @@ function services() {
|
||||
},
|
||||
worldFocus: {},
|
||||
renderer: {
|
||||
create: ({ viewer }) => vesselViewerRenderer({ viewer }),
|
||||
create: () => vesselDisplay(),
|
||||
},
|
||||
render: {
|
||||
holdContinuousRender: noop,
|
||||
@@ -42,14 +54,20 @@ function setup(source, options = {}) {
|
||||
const primitives = new Set();
|
||||
layer.testing._setVesselStateForTest({
|
||||
viewer: {},
|
||||
renderer: vesselViewerRenderer,
|
||||
billboardCollection: {
|
||||
add(options) {
|
||||
const primitive = { ...options };
|
||||
primitives.add(primitive);
|
||||
return primitive;
|
||||
},
|
||||
remove: (primitive) => primitives.delete(primitive),
|
||||
renderer: () => {
|
||||
const display = vesselDisplay();
|
||||
const add = display.fleet.add;
|
||||
const remove = display.fleet.remove;
|
||||
display.fleet.add = (spec) => {
|
||||
const handle = add(spec);
|
||||
primitives.add(handle);
|
||||
return handle;
|
||||
};
|
||||
display.fleet.remove = (handle) => {
|
||||
primitives.delete(handle);
|
||||
remove(handle);
|
||||
};
|
||||
return display;
|
||||
},
|
||||
});
|
||||
let now = 1000;
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
// writes outside a frame).
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createMoverBank, StoredColor } from '../../shared/render/moverBank.js';
|
||||
import { createMoverBank } from '../../shared/render/moverBank.js';
|
||||
import { createEngineLayerView } from '../../shared/render/engineView.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { Rgba, rgbaFromCss } from '../../shared/color.js';
|
||||
import {
|
||||
decodeImages,
|
||||
imageAtlasLayout,
|
||||
@@ -208,7 +208,7 @@ export function createVesselsDisplay(
|
||||
storedRotation: spec.rotation ?? 0,
|
||||
storedScale: spec.scale ?? 1,
|
||||
storedImage: spec.image,
|
||||
storedColor: new StoredColor(),
|
||||
storedColor: new Rgba(),
|
||||
// The chevron artwork's size, before the scale.
|
||||
width: VESSEL_ICON_PX,
|
||||
height: VESSEL_ICON_PX,
|
||||
@@ -255,7 +255,12 @@ export function createVesselsDisplay(
|
||||
return handle.storedColor;
|
||||
},
|
||||
set color(value) {
|
||||
handle.storedColor = new StoredColor(value);
|
||||
handle.storedColor = new Rgba(
|
||||
value?.red ?? 1,
|
||||
value?.green ?? 1,
|
||||
value?.blue ?? 1,
|
||||
value?.alpha ?? 1,
|
||||
);
|
||||
scratchRgba[0] = handle.storedColor.red;
|
||||
scratchRgba[1] = handle.storedColor.green;
|
||||
scratchRgba[2] = handle.storedColor.blue;
|
||||
|
||||
@@ -49,8 +49,7 @@ export function createTesting({
|
||||
.map((record) => [record.mmsi, record]),
|
||||
);
|
||||
state.selectedRecord = options.selectedRecord || null;
|
||||
// The test's renderer over its viewer and collection
|
||||
// (testSupport/vesselViewerRenderer), else the layer's for `engine`.
|
||||
// The test's renderer, else the layer's display for `engine`.
|
||||
state.renderer =
|
||||
typeof options.renderer === 'function'
|
||||
? options.renderer({
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import * as geo from '../geo/index.js';
|
||||
|
||||
/** A renderer of plain records, for the logic alone. */
|
||||
export function recordingAircraftRenderer(engine) {
|
||||
export function recordingAircraftRenderer(engine, ports = {}) {
|
||||
const calls = [];
|
||||
const sprites = new Set();
|
||||
const camera = {
|
||||
@@ -118,5 +118,7 @@ export function recordingAircraftRenderer(engine) {
|
||||
surfaceExclusions: () => [],
|
||||
destroy: () => calls.push(['destroy']),
|
||||
};
|
||||
for (const [name, values] of Object.entries(ports))
|
||||
Object.assign(renderer[name], values);
|
||||
return renderer;
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// An aircraft renderer (src/layers/aircraft/render/contract.js) over a stub
|
||||
// viewer and the test's own collections, for testing an aircraft layer's
|
||||
// logic against Cesium-shaped fakes: sprites are what the test's
|
||||
// billboardCollection.add returns, models load through
|
||||
// Cesium.Model.fromGltfAsync (which tests stub to control readiness and
|
||||
// failures) into its modelCollection, the tracked aircraft is an Entity in
|
||||
// viewer.entities followed through viewer.trackedEntity (with Cesium's one
|
||||
// exact tracked frame), trails are entity polylines. Not the application's display (render/display.js): that one is
|
||||
// tested in display.test.mjs.
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { createTrail } from '../data/trailRenderer.js';
|
||||
import { horizonOccluder } from '../data/iconOrientation.js';
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
import { applyTrackedCameraFrame } from '../globe/cesium/follow.js';
|
||||
|
||||
const cesiumColor = (color) =>
|
||||
color instanceof Cesium.Color
|
||||
? color
|
||||
: new Cesium.Color(color.red, color.green, color.blue, color.alpha);
|
||||
|
||||
const cesiumNearFar = (scalar) =>
|
||||
scalar instanceof Cesium.NearFarScalar
|
||||
? scalar
|
||||
: new Cesium.NearFarScalar(
|
||||
scalar.near,
|
||||
scalar.nearValue,
|
||||
scalar.far,
|
||||
scalar.farValue,
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {{ viewer: object, billboardCollection?: object,
|
||||
* modelCollection?: object, engine?: object, headingOffsetDeg?: number }} options
|
||||
* `engine`: what the layer's selection dispatcher binds to (default: the
|
||||
* engine registered for the viewer, if any).
|
||||
*/
|
||||
export function aircraftViewerRenderer({
|
||||
viewer,
|
||||
billboardCollection = null,
|
||||
modelCollection = null,
|
||||
engine = null,
|
||||
headingOffsetDeg = 0,
|
||||
}) {
|
||||
const billboards = billboardCollection;
|
||||
const models = modelCollection;
|
||||
const scratchHpr = new Cesium.HeadingPitchRoll(0, 0, 0);
|
||||
const scratchSphere = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 1.0);
|
||||
let cullingVolume = null;
|
||||
const inFrustum = (position) => {
|
||||
Cesium.Cartesian3.clone(position, scratchSphere.center);
|
||||
return (
|
||||
cullingVolume.computeVisibility(scratchSphere) !==
|
||||
Cesium.Intersect.OUTSIDE
|
||||
);
|
||||
};
|
||||
const scratchSize = {};
|
||||
const load = (spec) =>
|
||||
Cesium.Model.fromGltfAsync({
|
||||
url: spec.url,
|
||||
asynchronous: false,
|
||||
minimumPixelSize: spec.minimumPixelSize,
|
||||
scale: spec.scale,
|
||||
color: spec.color,
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
colorBlendAmount: spec.blendAmount,
|
||||
id: spec.id,
|
||||
});
|
||||
|
||||
const view = {
|
||||
get camera() {
|
||||
return viewer?.camera;
|
||||
},
|
||||
get scene() {
|
||||
return viewer?.scene;
|
||||
},
|
||||
// The engine registered for the viewer (the surface samplers take an
|
||||
// engine).
|
||||
get surfaceOwner() {
|
||||
return engine ?? engineFromViewer(viewer);
|
||||
},
|
||||
get surfaceScene() {
|
||||
return engine ?? engineFromViewer(viewer);
|
||||
},
|
||||
frameNumber: () => viewer?.scene?.frameState?.frameNumber ?? -1,
|
||||
viewportHeightPx: () => viewer.scene.canvas.clientHeight,
|
||||
frustumTester() {
|
||||
const camera = viewer.camera;
|
||||
cullingVolume = camera.frustum.computeCullingVolume(
|
||||
camera.positionWC,
|
||||
camera.directionWC,
|
||||
camera.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,
|
||||
holdFrames() {},
|
||||
closeFrameLatch() {},
|
||||
releaseFrames() {},
|
||||
};
|
||||
|
||||
const renderer = {
|
||||
get engine() {
|
||||
return engine ?? engineFromViewer(viewer);
|
||||
},
|
||||
view,
|
||||
fleet: {
|
||||
attach() {},
|
||||
detach() {},
|
||||
attached: () => !!billboards,
|
||||
visible: () => !!billboards && billboards.show,
|
||||
setVisible(visible) {
|
||||
if (billboards) billboards.show = visible;
|
||||
},
|
||||
add: (spec) => billboards.add(spec),
|
||||
remove: (sprite) => billboards.remove(sprite),
|
||||
restoreOrder() {},
|
||||
restoreOrderOnEnable() {},
|
||||
},
|
||||
models: {
|
||||
live: () => !!models && !models.isDestroyed(),
|
||||
setVisible(visible) {
|
||||
if (models) models.show = visible;
|
||||
},
|
||||
loadFleet: load,
|
||||
loadTracked: load,
|
||||
preload: (url) =>
|
||||
Cesium.Model.fromGltfAsync({ url, asynchronous: false }),
|
||||
add: (model) => models.add(model),
|
||||
remove(model) {
|
||||
try {
|
||||
models?.remove(model);
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
},
|
||||
destroy(model) {
|
||||
try {
|
||||
model.destroy();
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
},
|
||||
setPose(model, position, headingDeg, offsetDeg = headingOffsetDeg) {
|
||||
scratchHpr.heading = Cesium.Math.toRadians(
|
||||
(headingDeg || 0) + offsetDeg,
|
||||
);
|
||||
return Cesium.Transforms.headingPitchRollToFixedFrame(
|
||||
position,
|
||||
scratchHpr,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
undefined,
|
||||
model.modelMatrix,
|
||||
);
|
||||
},
|
||||
},
|
||||
tracked: {
|
||||
create: (spec) =>
|
||||
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,
|
||||
),
|
||||
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);
|
||||
},
|
||||
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;
|
||||
},
|
||||
},
|
||||
follow: {
|
||||
// The one exact tracked frame, as Cesium's engine follow applies it.
|
||||
start(handle) {
|
||||
viewer.camera?.cancelFlight?.();
|
||||
viewer.trackedEntity = handle;
|
||||
return applyTrackedCameraFrame(viewer, handle, handle.viewFrom) || null;
|
||||
},
|
||||
refocus: (handle) => renderer.follow.start(handle),
|
||||
release() {
|
||||
if (viewer) viewer.trackedEntity = undefined;
|
||||
},
|
||||
isFollowing: (handle) => !!viewer && viewer.trackedEntity === handle,
|
||||
current: () => viewer?.trackedEntity,
|
||||
onChanged: (listener) =>
|
||||
viewer.trackedEntityChanged?.addEventListener(listener) ?? (() => {}),
|
||||
},
|
||||
trails: {
|
||||
create: ({ color, width }) => createTrail(viewer, { color, width }),
|
||||
createHead({ id, show, positions, color, width }) {
|
||||
if (!viewer?.entities) return null;
|
||||
return viewer.entities.add({
|
||||
id,
|
||||
show,
|
||||
polyline: {
|
||||
positions: new Cesium.CallbackProperty(() => positions(), false),
|
||||
width,
|
||||
material: Cesium.Color.fromCssColorString(color).withAlpha(0.9),
|
||||
},
|
||||
});
|
||||
},
|
||||
removeHead(head) {
|
||||
try {
|
||||
if (head) viewer?.entities?.remove(head);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
},
|
||||
},
|
||||
surfaceExclusions: ({ sprites = [], models: modelHandles = [] }) =>
|
||||
[...sprites, ...modelHandles].filter(Boolean),
|
||||
destroy() {},
|
||||
};
|
||||
return renderer;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// A vessel renderer (src/layers/vessels/render/contract.js) over a stub
|
||||
// viewer and the test's own billboard collection, for testing the vessel
|
||||
// layer's logic against Cesium-shaped fakes: chevrons are what the
|
||||
// collection's add returns (the record is their id), the camera reads are
|
||||
// the viewer's (Cesium's horizon occluder and window projection, which tests
|
||||
// may stub), "another layer took the camera" is viewer.trackedEntityChanged
|
||||
// and trails come from `createTrail` (none without it). Not the application's
|
||||
// display (render/display.js): that one is tested in display.test.mjs.
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/**
|
||||
* @param {{ viewer: object, billboardCollection?: object,
|
||||
* createTrail?: (viewer: object, options: object) => object }} options
|
||||
*/
|
||||
export function vesselViewerRenderer({
|
||||
viewer,
|
||||
billboardCollection = null,
|
||||
createTrail = null,
|
||||
}) {
|
||||
let collection = billboardCollection;
|
||||
|
||||
const view = {
|
||||
get camera() {
|
||||
return viewer?.camera;
|
||||
},
|
||||
get scene() {
|
||||
return viewer?.scene;
|
||||
},
|
||||
horizon() {
|
||||
const cameraPosition = viewer?.camera?.positionWC;
|
||||
if (!cameraPosition) return null;
|
||||
return new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
cameraPosition,
|
||||
);
|
||||
},
|
||||
worldToWindow: (position, result) =>
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
viewer.scene,
|
||||
position,
|
||||
result,
|
||||
),
|
||||
canvasSize() {
|
||||
const canvas = viewer?.scene?.canvas || viewer?.canvas;
|
||||
return {
|
||||
width: Number(canvas?.clientWidth) || 0,
|
||||
height: Number(canvas?.clientHeight) || 0,
|
||||
};
|
||||
},
|
||||
requestRender: () => viewer?.scene?.requestRender?.(),
|
||||
on(event, listener) {
|
||||
const source =
|
||||
event === 'preRender'
|
||||
? viewer?.scene?.preRender
|
||||
: event === 'moveEnd'
|
||||
? viewer?.camera?.moveEnd
|
||||
: null;
|
||||
return typeof source?.addEventListener === 'function'
|
||||
? source.addEventListener(listener)
|
||||
: null;
|
||||
},
|
||||
};
|
||||
|
||||
const fleet = {
|
||||
attached: () => Boolean(collection),
|
||||
attach({ show }) {
|
||||
if (collection || !viewer) return;
|
||||
collection = new Cesium.BillboardCollection();
|
||||
collection.show = show;
|
||||
viewer.scene.primitives.add(collection);
|
||||
},
|
||||
setVisible(show) {
|
||||
if (collection) collection.show = show;
|
||||
},
|
||||
add: ({ position, show, image, scale, rotation, id }) =>
|
||||
collection.add({ position, show, image, scale, rotation, id }),
|
||||
remove(handle) {
|
||||
collection?.remove(handle);
|
||||
},
|
||||
detach() {
|
||||
if (collection && viewer?.scene?.primitives)
|
||||
viewer.scene.primitives.remove(collection);
|
||||
collection = null;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
engine: null,
|
||||
view,
|
||||
fleet,
|
||||
// The billboard id is the record.
|
||||
pickedRecord(picked) {
|
||||
const rawId = picked?.id ?? picked?.primitive?.id;
|
||||
return rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi')
|
||||
? rawId
|
||||
: null;
|
||||
},
|
||||
follow: {
|
||||
onChanged: (listener) =>
|
||||
viewer.trackedEntityChanged.addEventListener(listener),
|
||||
active: () => Boolean(viewer?.trackedEntity),
|
||||
},
|
||||
trails: {
|
||||
create: ({ color, width }) =>
|
||||
typeof createTrail === 'function' && viewer
|
||||
? createTrail(viewer, { color, width })
|
||||
: null,
|
||||
},
|
||||
destroy() {},
|
||||
};
|
||||
}
|
||||
@@ -88,8 +88,8 @@ for (const [name, files, pattern] of [
|
||||
/cesium imports belong in src\/globe\/cesium\//,
|
||||
],
|
||||
[
|
||||
'W1 shared trailRenderer keeps SDK imports in the engine',
|
||||
{ 'src/data/trailRenderer.js': "import 'cesium';" },
|
||||
'Shared render modules keep SDK imports in the engine',
|
||||
{ 'src/layers/shared/render/moverBank.js': "import 'cesium';" },
|
||||
/cesium imports belong in src\/globe\/cesium\//,
|
||||
],
|
||||
[
|
||||
@@ -360,21 +360,18 @@ test('legacy renderer names grant no SDK import exemption', (t) => {
|
||||
assert.throws(() => checkImportDirections(root), /three imports belong/);
|
||||
});
|
||||
|
||||
test('remaining Navara layer helpers still load through the manifest or mode', (t) => {
|
||||
test('Navara flow helpers load dynamically through the manifest', (t) => {
|
||||
const { root, write } = fixture(t);
|
||||
write('src/layers/shared/render/navaraPolygons.js');
|
||||
write('src/globe/navara/windFlow.js');
|
||||
write(
|
||||
'src/app/layers/renderers.js',
|
||||
"export const load = () => import('../../layers/shared/render/navaraPolygons.js');",
|
||||
"export const load = () => import('../../globe/navara/windFlow.js');",
|
||||
);
|
||||
assert.doesNotThrow(() => checkImportDirections(root));
|
||||
write(
|
||||
'src/app/data.js',
|
||||
"import '../layers/shared/render/navaraPolygons.js';",
|
||||
);
|
||||
write('src/app/data.js', "import '../globe/navara/windFlow.js';");
|
||||
assert.throws(
|
||||
() => checkImportDirections(root),
|
||||
/A Navara layer renderer loads only through/,
|
||||
/The Navara engine loads only through/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -352,8 +352,7 @@ export class LocationNavigation {
|
||||
this._stopOrbit();
|
||||
this.cockpitView?.exit({ restoreTracking: false });
|
||||
try {
|
||||
// A layer an engine leaves out (military awareness on Navara) has no
|
||||
// controls: release the trackers it would release, below.
|
||||
// If awareness controls are unavailable, release the trackers directly.
|
||||
if (typeof militaryAwarenessLayer?.releaseCameraOwnership !== 'function')
|
||||
throw new Error('no military awareness controls');
|
||||
militaryAwarenessLayer.releaseCameraOwnership?.({ origin: 'tool' });
|
||||
|
||||
@@ -146,8 +146,7 @@ export class NavigationController {
|
||||
} = this.tracking;
|
||||
let contactSelected = false;
|
||||
try {
|
||||
// A layer an engine leaves out (military awareness on Navara) has no
|
||||
// controls: release the trackers it would release, below.
|
||||
// If awareness controls are unavailable, release the trackers directly.
|
||||
if (typeof militaryAwarenessLayer?.releaseCameraOwnership !== 'function')
|
||||
throw new Error('no military awareness controls');
|
||||
contactSelected = Boolean(
|
||||
|
||||
Reference in new issue
Block a user