Fly the remaining flights through the camera port, on any viewer

The embed view, the celestial-ring focus, the ground-guard lift, the
Bhote Koshi approach and orbit, launch selection and replay flights, and
the cockpit's view and takeover now drive the renderer's camera port.
The contract gains a sineInOut easing, pinned by a conformance trace.

A viewer with no renderer bound, such as one a consumer of a public
export brings, now gets a camera port too, as it already got an input
port: cameraOf builds one over the viewer's own Cesium camera and keeps
it for the viewer's life, until a renderer is bound. The Cesium adapter
provides the factory as it loads; code that reaches Cesium takes cameraOf
from the adapter so the factory comes with it, and findCamera answers
null where a missing camera is not an error (HUD, share link, world
focus, Recent Imagery's ZOOM IN). A test walks every package export and
fails one that can reach the bridge without loading the factory.

A destroyed viewer gets no camera and lets go of a built one, and the
application renderer stays bound until its viewer is destroyed.
This commit is contained in:
Sameh Khamis committed 2026-11-06 08:21:57 -08:00
1 parent e3baeec223
commit 0b1c5d0f23
44 files changed
+1167 -344

No files matched your search

+116
View File
@@ -848,11 +848,22 @@
"src/data/distanceFormat.js",
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
"src/geo/index.js",
"src/ui/cockpitBriefPages.js",
"src/ui/cockpitContextState.js",
"src/ui/cockpitInstrumentState.js",
"src/ui/cockpitRollingValue.js",
"src/ui/cockpitCamera.js",
"src/render/cesium/camera.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
"src/geo/celestial.js",
"src/geo/quaternion.js",
"src/geo/frames.js",
"src/geo/ellipsoid.js",
"src/geo/vector.js",
"src/render/cesium/internals.js",
"src/render/cesium/common.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
"src/ui/cockpitDisplayPortal.js",
@@ -968,7 +979,11 @@
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/ui/applicationShell.js",
"src/render/cesium/camera.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
"src/render/cesium/internals.js",
"src/render/cesium/common.js",
"src/ui/localePicker.js",
"src/ui/cyberSonarControls.js",
"src/ui/locationNavigation.js",
@@ -1711,10 +1726,12 @@
"src/layers/launches/camera.js",
"src/layers/launches/controls.js",
"src/layers/launches/index.js",
"src/render/cesium/camera.js",
"src/layers/launches/ingestion.js",
"src/layers/launches/launchPad.js",
"src/layers/launches/lifecycle.js",
"src/layers/launches/model.js",
"src/geo/index.js",
"src/layers/launches/orbitRendering.js",
"src/layers/launches/overlays.js",
"src/layers/launches/panel.js",
@@ -1722,6 +1739,14 @@
"src/layers/launches/policy.js",
"src/layers/launches/policyHelpers.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
"src/geo/celestial.js",
"src/geo/quaternion.js",
"src/geo/frames.js",
"src/geo/ellipsoid.js",
"src/geo/vector.js",
"src/render/cesium/internals.js",
"src/render/cesium/common.js",
"src/layers/launches/rendering.js",
"src/layers/launches/replay.js",
"src/layers/launches/selection.js",
@@ -1761,7 +1786,17 @@
"src/data/militaryInstallationData.js",
"src/data/installationNames.js",
"src/layers/installations/controls.js",
"src/render/cesium/camera.js",
"src/geo/index.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
"src/geo/celestial.js",
"src/geo/quaternion.js",
"src/geo/frames.js",
"src/geo/ellipsoid.js",
"src/geo/vector.js",
"src/render/cesium/internals.js",
"src/render/cesium/common.js",
"src/layers/installations/index.js",
"src/layers/installations/ingestion.js",
"src/layers/installations/lifecycle.js",
@@ -1825,11 +1860,21 @@
"src/cockpitMath.js",
"src/data/iconOrientation.js",
"src/data/militaryAwarenessEngine.js",
"src/geo/index.js",
"src/data/installationNames.js",
"src/layers/awareness/controls.js",
"src/layers/awareness/dependencies.js",
"src/layers/awareness/focus.js",
"src/render/cesium/camera.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
"src/geo/celestial.js",
"src/geo/quaternion.js",
"src/geo/frames.js",
"src/geo/ellipsoid.js",
"src/geo/vector.js",
"src/render/cesium/internals.js",
"src/render/cesium/common.js",
"src/layers/awareness/history.js",
"src/layers/awareness/index.js",
"src/layers/awareness/ingestion.js",
@@ -1987,16 +2032,26 @@
"src/layers/cctv/geometryQueue.js",
"src/layers/cctv/ground.js",
"src/layers/cctv/headingConfidence.js",
"src/geo/index.js",
"src/layers/cctv/health.js",
"src/layers/cctv/hover.js",
"src/layers/cctv/index.js",
"src/geo/frames.js",
"src/layers/cctv/ingestion.js",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/model.js",
"src/geoDistance.js",
"src/layers/cctv/modelSpecs.js",
"src/layers/cctv/navigation.js",
"src/render/cesium/camera.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
"src/geo/celestial.js",
"src/geo/quaternion.js",
"src/geo/ellipsoid.js",
"src/geo/vector.js",
"src/render/cesium/common.js",
"src/render/cesium/internals.js",
"src/layers/cctv/policy.js",
"src/layers/cctv/presentation.js",
"src/layers/cctv/projection.js",
@@ -2052,11 +2107,21 @@
],
"modules": [
"src/layers/streetLevel/index.js",
"src/geo/index.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/cameraFraming.js",
"src/render/cesium/camera.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
"src/geo/celestial.js",
"src/geo/quaternion.js",
"src/geo/frames.js",
"src/geo/ellipsoid.js",
"src/geo/vector.js",
"src/render/cesium/internals.js",
"src/render/cesium/common.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
@@ -2129,10 +2194,20 @@
"modules": [
"src/data/headingDegrees.js",
"src/layers/alpr/index.js",
"src/geo/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/render/cesium/camera.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
"src/geo/celestial.js",
"src/geo/quaternion.js",
"src/geo/frames.js",
"src/geo/ellipsoid.js",
"src/geo/vector.js",
"src/render/cesium/common.js",
"src/render/cesium/internals.js",
"src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js",
"src/layers/alpr/markerState.js",
@@ -2183,6 +2258,7 @@
"src/data/radioCountry.js",
"src/layers/radio/catalogModel.js",
"src/layers/radio/categories.js",
"src/geo/index.js",
"src/layers/radio/clustering.js",
"src/layers/radio/controls.js",
"src/layers/radio/index.js",
@@ -2192,7 +2268,15 @@
"src/layers/radio/lifecycle.js",
"src/layers/radio/model.js",
"src/layers/radio/navigation.js",
"src/render/cesium/camera.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
"src/geo/celestial.js",
"src/geo/quaternion.js",
"src/geo/frames.js",
"src/geo/ellipsoid.js",
"src/geo/vector.js",
"src/render/cesium/common.js",
"src/layers/radio/playback.js",
"src/layers/radio/policy.js",
"src/layers/radio/presentation.js",
@@ -2237,8 +2321,18 @@
"src/layers/submarineCables/geometry.js",
"src/layers/submarineCables/index.js",
"src/layers/submarineCables/ingestion.js",
"src/geo/index.js",
"src/layers/submarineCables/interaction.js",
"src/render/cesium/camera.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
"src/geo/celestial.js",
"src/geo/quaternion.js",
"src/geo/frames.js",
"src/geo/ellipsoid.js",
"src/geo/vector.js",
"src/render/cesium/internals.js",
"src/render/cesium/common.js",
"src/layers/submarineCables/pickState.js",
"src/data/entityRecords.js",
"src/layers/submarineCables/lifecycle.js",
@@ -3481,6 +3575,7 @@
"src/maps/imageryComparison.js",
"src/data/bhoteKoshiEmbeddedMedia.js",
"src/data/bhoteKoshiLocator.js",
"src/render/cesium/camera.js",
"src/data/bhoteKoshiIncidentPlaces.js",
"src/data/bhoteKoshiFloodPath.js",
"src/data/bhoteKoshiShotPaths.js",
@@ -3819,6 +3914,8 @@
"src/layers/vessels/tracking.js",
"src/locations.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
"src/render/cesium/common.js",
"src/cityPois.js",
"src/navigationPolicy.js",
"src/overlays/worldOverlay.js",
@@ -4121,7 +4218,16 @@
"src/keylessGeocoder.js",
"src/layers/firms/anchors.js",
"src/locations.js",
"src/render/cesium/camera.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
"src/geo/index.js",
"src/geo/celestial.js",
"src/geo/quaternion.js",
"src/geo/frames.js",
"src/geo/ellipsoid.js",
"src/geo/vector.js",
"src/render/cesium/common.js",
"src/cityPois.js",
"src/cameraGroundGuard.js",
"src/search/alpsGeocoder.js",
@@ -4713,6 +4819,15 @@
"src/data/headingDegrees.js",
"src/cyberSonar.js",
"src/celestialRing.js",
"src/render/cesium/camera.js",
"src/render/rendererOf.js",
"src/render/camera/events.js",
"src/geo/celestial.js",
"src/geo/quaternion.js",
"src/geo/frames.js",
"src/geo/ellipsoid.js",
"src/geo/vector.js",
"src/render/cesium/common.js",
"src/data/approxDistance.js",
"src/data/cameraSensitivity.js",
"src/data/contactPlayback.js",
@@ -4733,6 +4848,7 @@
"src/data/spriteOrder.js",
"src/data/terrainHeights.js",
"src/data/transit.js",
"src/geo/index.js",
"src/data/transitFeeds.js",
"src/data/transitIcons.js",
"src/data/transitPresetStyle.js",
+13 -14
View File
@@ -13,6 +13,7 @@
import * as Cesium from 'cesium';
import { announceNavigationAuthority } from '../navigationPolicy.js';
import { cameraOf } from '../render/cesium/camera.js';
import { annotationsFromParams, createView } from '../view/index.js';
const EMBED_VIEW_MESSAGE = 'gev:view';
@@ -147,21 +148,19 @@ const delay = (ms, signal) =>
function flyTo(viewer, camera) {
announceNavigationAuthority('embed-view');
return new Promise((resolve) => {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
camera.lon,
camera.lat,
camera.altitude_m,
),
orientation: {
heading: Cesium.Math.toRadians(camera.heading_deg),
pitch: Cesium.Math.toRadians(camera.pitch_deg),
roll: 0,
cameraOf(viewer).flyTo(
{ lon: camera.lon, lat: camera.lat, height: camera.altitude_m },
{
orientation: {
heading: Cesium.Math.toRadians(camera.heading_deg),
pitch: Cesium.Math.toRadians(camera.pitch_deg),
roll: 0,
},
durationS: FLIGHT_SECONDS,
onComplete: () => resolve(true),
onCancel: () => resolve(false),
},
duration: FLIGHT_SECONDS,
complete: () => resolve(true),
cancel: () => resolve(false),
});
);
});
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import assert from 'node:assert/strict';
import test from 'node:test';
import {
@@ -12,7 +13,7 @@ import { createView } from '../view/index.js';
const fakeViewer = () => {
const flights = [];
return {
return bindFakeCesiumCamera({
flights,
camera: {
flyTo(options) {
@@ -20,7 +21,7 @@ const fakeViewer = () => {
options.complete();
},
},
};
});
};
const fakeLayers = (enabled) => ({
getAll: () =>
+12 -9
View File
@@ -17,6 +17,7 @@
import * as Cesium from 'cesium';
import { ownCameraArrival } from './data/cameraArrival.js';
import { heightsFor } from './queries/heights.js';
import { cameraOf } from './render/cesium/camera.js';
import { cameraFlightActive } from './render/cesium/internals.js';
/** Minimum eye height above the rendered surface for a usable view. */
@@ -196,20 +197,22 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
);
if (deficit > 0) {
stop();
camera.flyTo({
destination: Cesium.Cartesian3.fromRadians(
cameraOf(viewer).flyTo(
Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
carto.height + deficit,
),
orientation: {
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
{
orientation: {
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
},
durationS: 0.9,
easing: 'cubicInOut',
},
duration: 0.9,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
});
);
try {
onLift?.(deficit);
} catch {
+91 -25
View File
@@ -3,6 +3,7 @@
// Arrival framing is computed before the destination's tiles exist, so it
// works from a predicted ground height. The guard measures the real surface
// after arrival and lifts the eye; these tests pin when it acts.
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
@@ -49,8 +50,12 @@ test('the clearance frames a subject without turning into an overflight', () =>
function fakeViewer({ cameraHeightM, surfaceM }) {
const flights = [];
const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM);
return {
const position = Cesium.Cartographic.fromDegrees(
-97.7603,
30.3141,
cameraHeightM,
);
return bindFakeCesiumCamera({
flights,
scene: {
canvas: null,
@@ -71,7 +76,7 @@ function fakeViewer({ cameraHeightM, surfaceM }) {
this._currentFlight = {};
},
},
};
});
}
test('the guard lifts a buried arrival once the surface answers', async () => {
@@ -111,7 +116,11 @@ test('the guard waits for streaming tiles and yields to a newer arrival', async
test('the guard leaves a clear view untouched', async () => {
const viewer = fakeViewer({ cameraHeightM: 400, surfaceM: 171 });
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1 });
guardCameraAboveGround(
viewer,
{ lat: 30.3125, lon: -97.765 },
{ intervalMs: 1 },
);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(viewer.flights.length, 0);
});
@@ -142,27 +151,58 @@ function handoffFixture(t) {
const originalFly = viewer.camera.flyTo;
const navigation = new NavigationController({
viewer,
tracking: Object.fromEntries(['flightsLayer', 'militaryFlightsLayer',
'satellitesLayer', 'aisLiveVesselsLayer', 'militaryAwarenessLayer',
'rocketLaunchesLayer'].map((key) => [key, {}])),
cancelOrientation() {}, clearLocation() {}, cancelShareSelection() {},
getDataManager() {}, interruptCameraMotion() {}, stopOrbit() {},
isCockpitActive: () => false, showToast() {},
tracking: Object.fromEntries(
[
'flightsLayer',
'militaryFlightsLayer',
'satellitesLayer',
'aisLiveVesselsLayer',
'militaryAwarenessLayer',
'rocketLaunchesLayer',
].map((key) => [key, {}]),
),
cancelOrientation() {},
clearLocation() {},
cancelShareSelection() {},
getDataManager() {},
interruptCameraMotion() {},
stopOrbit() {},
isCockpitActive: () => false,
showToast() {},
});
guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 });
return { viewer, navigation, originalFly };
}
for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
'flyTo', 'flyToBoundingSphere', 'setView', 'layer-disable', 'layer-destroy', 'app-stop']) {
for (const handoff of [
'Director',
'tracking',
'follow',
'keyboard',
'UI',
'flyTo',
'flyToBoundingSphere',
'setView',
'layer-disable',
'layer-destroy',
'app-stop',
]) {
test(`arrival guard relinquishes and removes hooks on ${handoff}`, async (t) => {
const { viewer, navigation, originalFly } = handoffFixture(t);
const methods = ['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight']
.map((name) => viewer.camera[name]);
const methods = [
'flyTo',
'flyToBoundingSphere',
'setView',
'lookAt',
'cancelFlight',
].map((name) => viewer.camera[name]);
if (handoff === 'Director') {
SceneDirector.prototype._claimCameraOwnership.call({
viewer, styleManager: { runImmediateNavigation: (noun, fn) =>
navigation._runExplicitNavigation(noun, fn) },
viewer,
styleManager: {
runImmediateNavigation: (noun, fn) =>
navigation._runExplicitNavigation(noun, fn),
},
});
} else if (handoff === 'tracking') {
viewer.trackedEntityChanged.raiseEvent({ id: 'contact' });
@@ -173,19 +213,31 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
} else if (handoff === 'app-stop') navigation.stop();
else if (handoff.startsWith('layer-')) {
const lifecycle = new LayerLifecycle(viewer);
lifecycle.register({ id: 'test', init() {}, enable() {}, disable() {}, destroy() {} });
lifecycle.register({
id: 'test',
init() {},
enable() {},
disable() {},
destroy() {},
});
await lifecycle.setEnabled('test', true);
if (handoff === 'layer-disable') await lifecycle.setEnabled('test', false);
if (handoff === 'layer-disable')
await lifecycle.setEnabled('test', false);
else await lifecycle.destroyLayer('test');
} else viewer.camera[handoff]({});
// A direct flyTo is itself one recorded flight; the guard adds none.
const priorFlights = viewer.flights.length;
t.mock.timers.tick(10000);
assert.equal(viewer.flights.length, priorFlights, 'the old probe cannot fly');
assert.equal(
viewer.flights.length,
priorFlights,
'the old probe cannot fly',
);
assert.equal(viewer.camera.flyTo, originalFly);
assert.deepEqual(
['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight']
.map((name) => viewer.camera[name]),
['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight'].map(
(name) => viewer.camera[name],
),
methods,
'the guard never replaces camera methods',
);
@@ -196,7 +248,11 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
test('the guard yields to a camera moved without any camera method', async () => {
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 5 });
guardCameraAboveGround(
viewer,
{ lat: 30.3125, lon: -97.765 },
{ intervalMs: 5 },
);
// A cockpit or follow frame writes the pose directly.
viewer.camera.directionWC = new Cesium.Cartesian3(1, 0, 0);
await new Promise((resolve) => setTimeout(resolve, 20));
@@ -205,20 +261,30 @@ test('the guard yields to a camera moved without any camera method', async () =>
test('Cesium lifting a buried eye straight up is not another owner', async () => {
const viewer = fakeViewer({ cameraHeightM: 104, surfaceM: 171 });
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 5 });
guardCameraAboveGround(
viewer,
{ lat: 30.3125, lon: -97.765 },
{ intervalMs: 5 },
);
// Terrain collision raises the eye to just above the loaded ground.
const raised = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 172);
viewer.camera.positionCartographic = raised;
viewer.camera.positionWC = Cesium.Cartographic.toCartesian(raised);
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(viewer.flights.length, 1, 'the guard still lifts the eye');
const lifted = Cesium.Cartographic.fromCartesian(viewer.flights[0].destination);
const lifted = Cesium.Cartographic.fromCartesian(
viewer.flights[0].destination,
);
assert.ok(Math.abs(lifted.height - (171 + MIN_EYE_CLEARANCE_M)) < 0.5);
});
test('another owner lowering the eye is not corrected', async () => {
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 5 });
guardCameraAboveGround(
viewer,
{ lat: 30.3125, lon: -97.765 },
{ intervalMs: 5 },
);
const lowered = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 150);
viewer.camera.positionCartographic = lowered;
viewer.camera.positionWC = Cesium.Cartographic.toCartesian(lowered);
+320 -146
View File
@@ -1,7 +1,13 @@
import { LayerBindings } from './ui/layerBindings.js';
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import {
readShellSource,
shellMethod,
} from './testSupport/readShellSource.mjs';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
import {
enter as cockpitEnter,
navigateContext,
} from './ui/cockpitTrackingController.js';
import { CockpitViewController } from './ui/cockpitController.js';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -11,11 +17,24 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ui = readShellSource();
const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'));
const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'));
const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
const cameraVerbs = fs.readFileSync(path.join(ROOT, 'src', 'cameraVerbs.js'), 'utf8');
const cockpitTracking = fs.readFileSync(path.join(ROOT, 'src', 'cockpitTracking.js'), 'utf8');
const firms = readLayerSource(
path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'),
);
const vessels = readLayerSource(
path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'),
);
const voice = fs.readFileSync(
path.join(ROOT, 'src', 'voice', 'gevActions.js'),
'utf8',
);
const cameraVerbs = fs.readFileSync(
path.join(ROOT, 'src', 'cameraVerbs.js'),
'utf8',
);
const cockpitTracking = fs.readFileSync(
path.join(ROOT, 'src', 'cockpitTracking.js'),
'utf8',
);
function body(source, pattern, label) {
const name = pattern.source.match(/^(\w+)/)?.[1];
@@ -31,8 +50,14 @@ function body(source, pattern, label) {
function ordered(source, needles, label) {
let previous = -1;
for (const needle of needles) {
const match = needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null;
const index = needle instanceof RegExp ? (match ? previous + 1 + match.index : -1) : source.indexOf(needle, previous + 1);
const match =
needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null;
const index =
needle instanceof RegExp
? match
? previous + 1 + match.index
: -1
: source.indexOf(needle, previous + 1);
assert.ok(index >= 0, `${label}: missing ${needle}`);
assert.ok(index > previous, `${label}: ${needle} is out of order`);
previous = index;
@@ -41,12 +66,16 @@ function ordered(source, needles, label) {
test('Cockpit takeover invalidates deferred work before camera cancellation', () => {
const enter = cockpitEnter.toString();
ordered(enter, [
'if (!info || !entity?.position) return false;',
'this.onCameraTakeover?.();',
'this.viewer.camera.cancelFlight();',
'this.viewer.trackedEntity = undefined;',
], 'Cockpit takeover');
ordered(
enter,
[
'if (!info || !entity?.position) return false;',
'this.onCameraTakeover?.();',
'cameraOf(this.viewer).cancelFlight();',
'this.viewer.trackedEntity = undefined;',
],
'Cockpit takeover',
);
assert.match(
ui,
/onCameraTakeover: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\),/,
@@ -80,11 +109,23 @@ test('navigation clears dormant tracker IDs without aborting unrelated layer res
);
assert.doesNotMatch(stamp, /cancelPendingRestores\(\)/);
assert.match(stamp, /flightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
assert.match(stamp, /militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
assert.match(
stamp,
/militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/,
);
assert.match(stamp, /satellitesLayer\.cancelPendingTrackingRestore\?\.\(\)/);
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/);
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/);
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/);
assert.match(
stamp,
/if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/,
);
assert.match(
stamp,
/if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/,
);
assert.match(
stamp,
/if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/,
);
});
test('voice Cockpit entry reaches the camera only through stamping seams', () => {
@@ -104,10 +145,14 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
/export function enterCockpitWithTracking\(\{[\s\S]*?\n\}\) \{([\s\S]*?)\n\}\n/,
'Cockpit entry transaction',
);
ordered(transaction, [
/if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/,
'if (!entryError) entered = Boolean(cockpitView.enter());',
], 'Cockpit entry transaction');
ordered(
transaction,
[
/if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/,
'if (!entryError) entered = Boolean(cockpitView.enter());',
],
'Cockpit entry transaction',
);
// No direct camera control: every mutation goes through a layer tracker or
// the cockpit controller, both of which stamp.
assert.doesNotMatch(transaction, /\b(?:viewer\.)?camera\s*(?:\.|\[|=)/);
@@ -146,27 +191,41 @@ test('voice Cockpit next/previous shares the manual Context navigation path', ()
/this\._listen\(this\.contextNext, 'click', \(\) =>\s*this\.navigateContext\(1, \{ origin: 'user' \}\),?\s*\);/,
);
const funnel = navigateContext.toString();
assert.match(funnel, /const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/);
ordered(funnel, [
"const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';",
'const navigationOptions = wasActive',
'militaryAwarenessLayer?.[method]?.(navigationOptions)',
'this._adoptTrackedEntity(performance.now());',
], 'Cockpit Context navigation funnel');
assert.match(
funnel,
/const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/,
);
ordered(
funnel,
[
"const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';",
'const navigationOptions = wasActive',
'militaryAwarenessLayer?.[method]?.(navigationOptions)',
'this._adoptTrackedEntity(performance.now());',
],
'Cockpit Context navigation funnel',
);
const navigate = body(
ui,
/if \(normalized === 'next' \|\| normalized === 'previous'\) \{([\s\S]*?)\n \}/,
'controlCockpit navigation branch',
);
ordered(navigate, [
'this.cockpitView.navigateContext(',
"normalized === 'next' ? 1 : -1,",
], 'controlCockpit navigation branch');
ordered(
navigate,
['this.cockpitView.navigateContext(', "normalized === 'next' ? 1 : -1,"],
'controlCockpit navigation branch',
);
// No private camera route around the awareness layer.
assert.doesNotMatch(navigate, /flyTo|camera|trackById|_runExplicitNavigation/);
assert.doesNotMatch(
navigate,
/flyTo|camera|trackById|_runExplicitNavigation/,
);
// An exhausted cohort is an honest failure, not a silent success.
assert.match(navigate, /ok: changed,/);
assert.match(navigate, /error: changed \? null : 'No further context target was available',/);
assert.match(
navigate,
/error: changed \? null : 'No further context target was available',/,
);
});
test('accepted navigation releases through PR15-aware ownership before flight', () => {
@@ -175,37 +234,69 @@ test('accepted navigation releases through PR15-aware ownership before flight',
/_runExplicitNavigation\(noun, navigate, releaseOptions = undefined\) \{([\s\S]*?)\n \}/,
'explicit navigation',
);
ordered(run, [
'cockpitActive: this.isCockpitActive()',
'stamp: () => this._stampNavigation()',
'release: () => this._releaseFollowCamera(releaseOptions)',
'navigate,',
], 'explicit navigation');
ordered(
run,
[
'cockpitActive: this.isCockpitActive()',
'stamp: () => this._stampNavigation()',
'release: () => this._releaseFollowCamera(releaseOptions)',
'navigate,',
],
'explicit navigation',
);
const release = body(
ui,
/_releaseFollowCamera\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/,
'follow release',
);
ordered(release, [
'origin: trackingOrigin',
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
'rocketLaunchesLayer.releaseCameraOwnership?.()',
'this.viewer.trackedEntity = undefined;',
"interruptCameraMotion('explicit-navigation')",
'this.viewer.camera.cancelFlight();',
'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);',
], 'follow release');
assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
ordered(
release,
[
'origin: trackingOrigin',
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
'rocketLaunchesLayer.releaseCameraOwnership?.()',
'this.viewer.trackedEntity = undefined;',
"interruptCameraMotion('explicit-navigation')",
'this.viewer.camera.cancelFlight();',
'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);',
],
'follow release',
);
assert.match(
release,
/flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/,
);
assert.match(
release,
/militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/,
);
});
test('validated voice camera destinations share the UI navigation authority facade', () => {
assert.match(ui, /runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/);
assert.match(
ui,
/runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/,
);
assert.match(
voice,
/runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/,
);
assert.match(
voice,
/runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/,
);
assert.match(
voice,
/runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/,
);
assert.match(
voice,
/runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/,
);
assert.match(
voice,
/runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/,
);
const trackedVoice = voice.slice(
voice.indexOf('async function trackEntity'),
voice.indexOf('async function frameOverhead'),
@@ -222,47 +313,80 @@ test('validated voice camera destinations share the UI navigation authority faca
/export function moveCamera\(args = \{\}, runNavigation = null\) \{([\s\S]*?)\n\}/,
'move camera',
);
ordered(move, [
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
'const start = () => {',
/const preserveCameraFlight =\s*motion === 'orbit'/,
'runNavigation(start, { preserveCameraFlight })',
], 'move validation before handoff');
ordered(
move,
[
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
'const start = () => {',
/const preserveCameraFlight =\s*motion === 'orbit'/,
'runNavigation(start, { preserveCameraFlight })',
],
'move validation before handoff',
);
const route = body(
cameraVerbs,
/export function flyRoute\(\s*annoList,\s*args = \{\},\s*floorFn = null,\s*runNavigation = null,\s*warmFn = null,?\s*\) \{([\s\S]*?)\n\}/,
'fly route',
);
ordered(route, [
'if (!routes.length)',
'const pts = route.path.map',
'const start = () => {',
/return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/,
], 'route validation before handoff');
ordered(
route,
[
'if (!routes.length)',
'const pts = route.path.map',
'const start = () => {',
/return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/,
],
'route validation before handoff',
);
// The corridor warm is injected the same way the floor READ is — the dolly
// never reaches into the data layer itself, and the voice dispatch is the one
// place that binds both.
assert.match(voice, /\(lat, lon\) => floorServices\.cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => floorServices\.warmGroundFloor\(cells\),/);
assert.match(
voice,
/\(lat, lon\) => floorServices\.cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => floorServices\.warmGroundFloor\(cells\),/,
);
});
test('deferred search releases only after its final authority check', () => {
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
ordered(search, [
'const authority = this.begin();',
'this.onStart?.(authority);',
'await this.search(query',
'beforeFly: () => current() && this.beforeFly(authority)',
'if (!current() || controller.signal.aborted) return;',
'destination?.cancelled',
'finally',
'this.onSettled?.(authority)',
], 'deferred search component');
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/);
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/);
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
const search = fs.readFileSync(
path.join(ROOT, 'src', 'ui', 'locationSearch.js'),
'utf8',
);
ordered(
search,
[
'const authority = this.begin();',
'this.onStart?.(authority);',
'await this.search(query',
'beforeFly: () => current() && this.beforeFly(authority)',
'if (!current() || controller.signal.aborted) return;',
'destination?.cancelled',
'finally',
'this.onSettled?.(authority)',
],
'deferred search component',
);
assert.match(
ui,
/begin: \(\) => this\._beginDeferredNavigation\('location'\)/,
);
assert.match(
ui,
/beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/,
);
assert.match(
ui,
/isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/,
);
assert.match(
ui,
/change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/,
);
assert.doesNotMatch(
search.slice(0, search.indexOf('await this.search')),
/_releaseFollowCamera/,
);
});
test('a direct globe gesture retires delayed camera and selection restore only', () => {
@@ -287,77 +411,115 @@ test('a direct globe gesture retires delayed camera and selection restore only',
/_stampNavigation\(\{ cancelPendingSelection = true[^)]*\} = \{\}\) \{([\s\S]*?)\n \}/,
'navigation stamp',
);
assert.match(stamp, /if \(cancelPendingSelection\) \{[\s\S]*?cancelPendingTrackingRestore/);
assert.match(
stamp,
/if \(cancelPendingSelection\) \{[\s\S]*?cancelPendingTrackingRestore/,
);
assert.doesNotMatch(stamp, /cancelPendingRestores\(\)/);
});
test('newer navigation, reset, Cockpit, and teardown share one generation', () => {
assert.equal((ui.match(/_navigationGeneration \+= 1/g) || []).length, 1);
const reset = body(ui, /resetToGlobeView\(\) \{([\s\S]*?)\n \}/, 'reset');
ordered(reset, [
'if (this._globeResetPromise) return this._globeResetPromise;',
'this._stampNavigation();',
"interruptCameraMotion('reset-globe')",
], 'reset supersession');
ordered(
reset,
[
'if (this._globeResetPromise) return this._globeResetPromise;',
'this._stampNavigation();',
"interruptCameraMotion('reset-globe')",
],
'reset supersession',
);
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
ordered(dispose, [
'this._disposed = true;',
'this._layerBindings.stop();',
'this._navigation.destroy();',
'await this._contextControls.restoreForDisposal();',
], 'dispose invalidation');
ordered(
dispose,
[
'this._disposed = true;',
'this._layerBindings.stop();',
'this._navigation.destroy();',
'await this._contextControls.restoreForDisposal();',
],
'dispose invalidation',
);
assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\)/);
});
test('teardown synchronously closes immediate camera entry points', () => {
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
ordered(dispose, [
'this._disposed = true;',
'this._layerBindings.stop();',
'await this._contextControls.restoreForDisposal();',
], 'synchronous teardown barrier');
ordered(LayerBindings.prototype.stop.toString(), [
'this._disposed = true;',
'this._removeCctvRequestFocusListener?.();',
'this._removeWorldRequestFocusListener?.();',
'this._navigationOwnerChangedRemover?.();',
], 'camera bindings close synchronously');
ordered(
dispose,
[
'this._disposed = true;',
'this._layerBindings.stop();',
'await this._contextControls.restoreForDisposal();',
],
'synchronous teardown barrier',
);
ordered(
LayerBindings.prototype.stop.toString(),
[
'this._disposed = true;',
'this._removeCctvRequestFocusListener?.();',
'this._removeWorldRequestFocusListener?.();',
'this._navigationOwnerChangedRemover?.();',
],
'camera bindings close synchronously',
);
const navigation = body(
ui,
/_runExplicitNavigation\(noun, navigate, releaseOptions = undefined\) \{([\s\S]*?)\n \}/,
'explicit navigation',
);
ordered(navigation, [
'disposed: this._disposed',
'release: () => this._releaseFollowCamera(releaseOptions)',
'navigate,',
], 'disposed navigation guard');
ordered(
navigation,
[
'disposed: this._disposed',
'release: () => this._releaseFollowCamera(releaseOptions)',
'navigate,',
],
'disposed navigation guard',
);
const cctvFocus = body(
ui,
/_runExplicitCctvFocus\(activate, focus\) \{([\s\S]*?)\n \}/,
'explicit CCTV focus',
);
ordered(cctvFocus, [
'if (this._disposed) return false;',
'const cameraId = activate();',
], 'disposed CCTV activation guard');
ordered(
cctvFocus,
['if (this._disposed) return false;', 'const cameraId = activate();'],
'disposed CCTV activation guard',
);
});
test('teardown refuses deferred location work before geocoding begins', () => {
const deferred = body(ui, /_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/, 'deferred navigation');
const deferred = body(
ui,
/_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/,
'deferred navigation',
);
assert.match(deferred, /disposed: this\._disposed/);
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
ordered(search, [
'if (!query || this.destroyed) return;',
'const authority = this.begin();',
'if (authority === false)',
'this.input?.blur();',
'this.onStart?.(authority);',
'await this.search(query',
], 'disposed search refusal');
assert.match(search, /if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/);
const search = fs.readFileSync(
path.join(ROOT, 'src', 'ui', 'locationSearch.js'),
'utf8',
);
ordered(
search,
[
'if (!query || this.destroyed) return;',
'const authority = this.begin();',
'if (authority === false)',
'this.input?.blur();',
'this.onStart?.(authority);',
'await this.search(query',
],
'disposed search refusal',
);
assert.match(
search,
/if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/,
);
});
test('refused canned destinations commit no location or POI state', () => {
@@ -366,11 +528,15 @@ test('refused canned destinations commit no location or POI state', () => {
['poi', /_onPoiClick\(cityId, poiIndex\) \{([\s\S]*?)\n \}/],
]) {
const handler = body(ui, pattern, name);
ordered(handler, [
'this._flyWithTransition(',
'if (result === false) return;',
'this._setActiveLocation(cityId);',
], name);
ordered(
handler,
[
'this._flyWithTransition(',
'if (result === false) return;',
'this._setActiveLocation(cityId);',
],
name,
);
}
});
@@ -392,11 +558,15 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
/inputFor\(viewer\)\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/,
'vessel click',
);
ordered(vesselClick, [
"isOwnedByOtherLayer('ais-live-vessels', pickedId)",
'_vesselOverlayHost.hitTest?.(',
'selectAndFocusVessel(record)',
], 'vessel sibling ownership');
ordered(
vesselClick,
[
"isOwnedByOtherLayer('ais-live-vessels', pickedId)",
'_vesselOverlayHost.hitTest?.(',
'selectAndFocusVessel(record)',
],
'vessel sibling ownership',
);
const vesselFocus = body(
vessels,
/function selectAndFocusVessel\(record\) \{([\s\S]*?)\n \}/,
@@ -408,9 +578,13 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
/input\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/,
'fire click',
);
ordered(fireClick, [
'isOwnedByOtherLayer(id, pickedId)',
'overlayHost.hitTest?.(',
'selectAndFocusFire(carded)',
], 'fire sibling ownership');
ordered(
fireClick,
[
'isOwnedByOtherLayer(id, pickedId)',
'overlayHost.hitTest?.(',
'selectAndFocusFire(carded)',
],
'fire sibling ownership',
);
});
+18 -14
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { governorRequestRender } from './renderGovernor.js';
import { cameraOf } from './render/cesium/camera.js';
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
export const KEYHOLE_OUTER_RADIUS = 1.05;
@@ -576,30 +577,33 @@ export class CelestialRing {
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
const camera = cameraOf(this.viewer);
this._focusInProgress = true;
const finishFocus = () => {
this._focusInProgress = false;
this.viewer.scene.requestRender?.();
};
this.viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromRadians(
camera.flyTo(
Cesium.Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
altitude,
),
orientation: {
heading: this.viewer.camera.heading,
pitch: -Cesium.Math.PI_OVER_TWO,
roll: 0,
{
orientation: {
heading: camera.pose().heading,
pitch: -Cesium.Math.PI_OVER_TWO,
roll: 0,
},
durationS: duration,
easing: 'cubicInOut',
onComplete: finishFocus,
onCancel: () => {
finishFocus();
if (!this.isGlobeFullyVisible()) this._autoDisable();
},
},
duration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete: finishFocus,
cancel: () => {
finishFocus();
if (!this.isGlobeFullyVisible()) this._autoDisable();
},
});
);
return true;
}
+48 -42
View File
@@ -4,6 +4,7 @@ import {
setOverlayEntries,
} from '../overlays/worldOverlay.js';
import { governorRequestRender } from '../renderGovernor.js';
import { cameraOf } from '../render/cesium/camera.js';
import {
BHOTE_KOSHI_LOCATOR_CREDIT,
registerDynamicCredit,
@@ -620,6 +621,7 @@ export function createBhoteKoshiLocatorLayer({
requestRender('bhote-koshi-trigger-record-reveal');
const camera = _viewer?.camera;
if (!camera?.flyTo) return;
const port = cameraOf(_viewer);
const generation = _generation;
_triggerApproachInFlight = true;
const finish = () => {
@@ -627,50 +629,54 @@ export function createBhoteKoshiLocatorLayer({
attempt.phase = 'finished';
_triggerApproachInFlight = false;
};
camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
TRIGGER_APPROACH_CAMERA.lon,
TRIGGER_APPROACH_CAMERA.lat,
TRIGGER_APPROACH_CAMERA.alt,
),
orientation: {
heading: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.heading),
pitch: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.pitch),
roll: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.roll),
port.flyTo(
{
lon: TRIGGER_APPROACH_CAMERA.lon,
lat: TRIGGER_APPROACH_CAMERA.lat,
height: TRIGGER_APPROACH_CAMERA.alt,
},
duration: TRIGGER_APPROACH_DURATION_SEC,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete: () => {
if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying')
return;
if (
!_enabled ||
generation !== _generation ||
_presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
) {
finish();
return;
}
attempt.phase = 'orbiting';
camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
TRIGGER_ORBIT_CAMERA.lon,
TRIGGER_ORBIT_CAMERA.lat,
TRIGGER_ORBIT_CAMERA.alt,
),
orientation: {
heading: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.heading),
pitch: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.pitch),
roll: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.roll),
},
duration: TRIGGER_ORBIT_DURATION_SEC,
easingFunction: Cesium.EasingFunction.SINUSOIDAL_IN_OUT,
complete: finish,
cancel: finish,
});
{
orientation: {
heading: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.heading),
pitch: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.pitch),
roll: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.roll),
},
durationS: TRIGGER_APPROACH_DURATION_SEC,
easing: 'cubicInOut',
onComplete: () => {
if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying')
return;
if (
!_enabled ||
generation !== _generation ||
_presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
) {
finish();
return;
}
attempt.phase = 'orbiting';
port.flyTo(
{
lon: TRIGGER_ORBIT_CAMERA.lon,
lat: TRIGGER_ORBIT_CAMERA.lat,
height: TRIGGER_ORBIT_CAMERA.alt,
},
{
orientation: {
heading: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.heading),
pitch: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.pitch),
roll: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.roll),
},
durationS: TRIGGER_ORBIT_DURATION_SEC,
easing: 'sineInOut',
onComplete: finish,
onCancel: finish,
},
);
},
onCancel: finish,
},
cancel: finish,
});
);
}
function armTriggerApproach() {
+9 -2
View File
@@ -1,3 +1,4 @@
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
@@ -647,6 +648,7 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
},
cancelFlight() {},
};
bindFakeCesiumCamera(viewer);
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
requestRender() {},
@@ -703,6 +705,9 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
},
cancelFlight() {},
};
bindFakeCesiumCamera(viewer);
// The renderer's camera port relays these; the layer's own come on top.
const portListeners = [moveStart, moveEnd].map((e) => e.numberOfListeners);
const layer = createBhoteKoshiLocatorLayer({
boundaryResolver: async () => null,
overlayHost: overlayHostFixture(),
@@ -745,8 +750,10 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
const oldFlights = [...flights];
SceneDirector.prototype.stopScene.call(director);
assert.equal(timers.size, 0, 'Stop cancels the actual delayed work');
assert.equal(moveStart.numberOfListeners, 0);
assert.equal(moveEnd.numberOfListeners, 0);
assert.deepEqual(
[moveStart, moveEnd].map((e) => e.numberOfListeners),
portListeners,
);
callbacks[0]();
moveEnd.raiseEvent();
for (const flight of oldFlights) flight.complete();
+2 -2
View File
@@ -20,7 +20,7 @@ import { applicationServices } from './services/application.js';
*/
import { toDegrees, toRadians } from './geo/index.js';
import { rendererOf } from './render/rendererOf.js';
import { findCamera } from './render/rendererOf.js';
import { greatCircleKm } from './geoDistance.js';
import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './cityPois.js';
@@ -241,7 +241,7 @@ export class IntelHUD {
basemapContext = {},
summaryService = applicationServices.summary,
publish = () => {},
camera = rendererOf(viewer)?.camera,
camera = findCamera(viewer),
} = {},
) {
if (!camera) throw new Error('IntelHUD needs a camera port');
+1 -1
View File
@@ -26,7 +26,7 @@ import {
import { entityRecord, readEntityRecord } from '../../data/entityRecords.js';
import { alprMarkers } from './markerState.js';
import { heightsFor } from '../../queries/heights.js';
import { cameraOf } from '../../render/rendererOf.js';
import { cameraOf } from '../../render/cesium/camera.js';
export function createAlprPresentation({ state, services, source }) {
const { governorRequestRender } = services.render;
+1 -1
View File
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { VESSEL_FOCUS_RADIUS_M } from './policy.js';
import { cameraOf } from '../../render/rendererOf.js';
import { cameraOf } from '../../render/cesium/camera.js';
export function createFocus({ state: layerState, services, parts, source }) {
const flightsLayer = services.flights;
+1 -1
View File
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { CCTV_FOCUS_RESULT } from './policy.js';
import { cameraOf } from '../../render/rendererOf.js';
import { cameraOf } from '../../render/cesium/camera.js';
export function createNavigation({
state: layerState,
+1 -1
View File
@@ -23,7 +23,7 @@ import {
translateKnown,
} from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { cameraOf } from '../../render/rendererOf.js';
import { cameraOf } from '../../render/cesium/camera.js';
// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word
// joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen).
+1 -1
View File
@@ -6,7 +6,7 @@ import {
ANCHOR_REFRESH_M,
} from './policy.js';
import { formatNumber, t, tn } from '../../i18n/index.js';
import { cameraOf } from '../../render/rendererOf.js';
import { cameraOf } from '../../render/cesium/camera.js';
// installationFeedback.js is shared with the HUD (src/loadingFeedback.js) and
// hands back msg()-marked English plus values; getStats translates them here.
+1 -1
View File
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { cameraOf } from '../../render/rendererOf.js';
import { cameraOf } from '../../render/cesium/camera.js';
import {
SATELLITE_STANDALONE_DEFAULTS,
LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M,
+13 -12
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { cameraOf } from '../../render/cesium/camera.js';
import {
REPLAY_ASCENT_FALLBACK_SEC,
REPLAY_ASCENT_MIN_SEC,
@@ -353,7 +354,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
parts.overlays.syncMissionOverlayEntries();
parts.policyHelpers.releaseAircraftTracking(layerState._dataManager);
layerState._viewer.trackedEntity = undefined;
layerState._viewer.camera.cancelFlight();
cameraOf(layerState._viewer).cancelFlight();
layerState._animationStarts.set(
launchId,
Date.now() +
@@ -485,20 +486,20 @@ export function createReplay({ state: layerState, services, parts, source }) {
}
});
layerState._viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(track.ascentPath[0], 0),
cameraOf(layerState._viewer).flyToBounds(
{ center: track.ascentPath[0], radius: 0 },
{
offset: new Cesium.HeadingPitchRange(
initialHeading,
Cesium.Math.toRadians(-20),
REPLAY_INITIAL_RANGE_M,
),
duration: 1.4,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete: () => {
offset: {
heading: initialHeading,
pitch: Cesium.Math.toRadians(-20),
range: REPLAY_INITIAL_RANGE_M,
},
durationS: 1.4,
easing: 'cubicInOut',
onComplete: () => {
cameraReady = true;
},
cancel: () => {
onCancel: () => {
if (
token === layerState._replayCameraToken &&
layerState._replayCameraLaunchId === launchId
+26 -27
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { MISSION_GLOBE_VIEW_RANGE_M, MISSION_FOCUS_RANGE_M } from './policy.js';
import { cameraOf } from '../../render/cesium/camera.js';
export function createSelection({
state: layerState,
@@ -32,20 +33,23 @@ export function createSelection({
);
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromRadians(
const camera = cameraOf(viewer);
camera.flyTo(
Cesium.Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
altitude,
),
orientation: {
heading: viewer.camera.heading,
pitch: -Cesium.Math.PI_OVER_TWO,
roll: 0,
{
orientation: {
heading: camera.pose().heading,
pitch: -Cesium.Math.PI_OVER_TWO,
roll: 0,
},
durationS: duration,
easing: 'cubicInOut',
},
duration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
});
);
}
function entityLaunchId(entity) {
@@ -187,11 +191,10 @@ export function createSelection({
Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
layerState._viewer.camera.flyTo({
destination,
cameraOf(layerState._viewer).flyTo(destination, {
orientation: { direction, up },
duration: 1.1,
complete: () => {
durationS: 1.1,
onComplete: () => {
if (layerState._selectedLaunchId === launch.id)
startMissionZoomAnchor(launch, range);
},
@@ -202,24 +205,20 @@ export function createSelection({
if (!layerState._viewer || !launch) return;
parts.replay.stopMissionReplay();
stopMissionZoomAnchor();
const launchPosition = Cesium.Cartesian3.fromDegrees(
launch.lon,
launch.lat,
);
layerState._viewer.selectedEntity = layerState._dataSource.entities.getById(
`rocket-launch:${launch.id}`,
);
layerState._viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(launchPosition, 0),
cameraOf(layerState._viewer).flyToBounds(
{ center: { lon: launch.lon, lat: launch.lat }, radius: 0 },
{
offset: new Cesium.HeadingPitchRange(
0,
Cesium.Math.toRadians(-42),
MISSION_FOCUS_RANGE_M,
),
duration: 1.4,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete: () => {
offset: {
heading: 0,
pitch: Cesium.Math.toRadians(-42),
range: MISSION_FOCUS_RANGE_M,
},
durationS: 1.4,
easing: 'cubicInOut',
onComplete: () => {
if (layerState._selectedLaunchId === launch.id) {
startMissionZoomAnchor(launch, MISSION_FOCUS_RANGE_M);
}
+1 -1
View File
@@ -4,7 +4,7 @@ import {
GLOBAL_RADIO_ALTITUDE_M,
RADIO_GLOBE_RECENTER_MAX_HEIGHT_M,
} from './policy.js';
import { cameraOf } from '../../render/rendererOf.js';
import { cameraOf } from '../../render/cesium/camera.js';
export function createNavigation({
state: layerState,
+7 -5
View File
@@ -34,7 +34,7 @@ import {
} from './model.js';
import { msg, t } from '../../i18n/index.js';
import { READOUT_WORDS, displayDay, displayMetres } from './display.js';
import { cameraOf } from '../../render/rendererOf.js';
import { findCamera } from '../../render/rendererOf.js';
/**
* Quiet time after the last arrow key before the focused day previews, so a
@@ -980,15 +980,17 @@ export function createRecentImageryLayer({
const target = _boxError ? _refusalTarget : null;
const camera = _viewer?.camera;
const ellipsoid = ellipsoidOf(_viewer);
if (!target || !camera?.flyTo || !ellipsoid?.cartographicToCartesian)
return null;
// No camera port: a viewer of the caller's own whose renderer did not
// bring the camera factory (GEV's own imagery renderer does).
const port = findCamera(_viewer);
if (!target || !port || !ellipsoid?.cartographicToCartesian) return null;
const canvas = _viewer.scene?.canvas;
const height = fitViewHeightM({
width: canvas?.clientWidth,
height: canvas?.clientHeight,
fovy: camera.frustum?.fovy,
fovy: camera?.frustum?.fovy,
});
cameraOf(_viewer).flyTo(
port.flyTo(
ellipsoid.cartographicToCartesian({
longitude: toRadians(target.lon),
latitude: toRadians(target.lat),
+3
View File
@@ -8,6 +8,9 @@
*/
import * as Cesium from 'cesium';
import { withTileRequestHook } from '../../render/cesium/tileRequests.js';
// The layer is Cesium-free and flies through `cameraOf`; its Cesium renderer
// brings the factory a viewer with no renderer bound gets a camera from.
import '../../render/cesium/camera.js';
import { PRODUCTS, gibsTemplate } from './model.js';
const SLOT_IDS = ['a', 'b'];
+1 -1
View File
@@ -1,7 +1,7 @@
import * as Cesium from 'cesium';
import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js';
import { heightsFor } from '../../queries/heights.js';
import { cameraOf } from '../../render/rendererOf.js';
import { cameraOf } from '../../render/cesium/camera.js';
/** Fly the globe camera once to frame a photo after it opens. */
export function createCameraFraming({ state }) {
+1 -1
View File
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { cablePicks } from './pickState.js';
import { cameraOf } from '../../render/rendererOf.js';
import { cameraOf } from '../../render/cesium/camera.js';
export function createInteraction({ state, inputFor }) {
function registerPickEntity(entity, info) {
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from './clock.js';
import { formatUtcDayMonth, t, msg } from '../../i18n/index.js';
import { formatScaleLabel } from '../../ui/unitLabels.js';
import { cameraOf } from '../../render/rendererOf.js';
import { cameraOf } from '../../render/cesium/camera.js';
/**
* A stored (English) failure in the language. HTTP failures from source.js
+1 -1
View File
@@ -13,7 +13,7 @@ import { cancelCameraArrival } from './data/cameraArrival.js';
import { guardCameraAboveGround } from './cameraGroundGuard.js';
import { CITY_POIS } from './cityPois.js';
import { heightsFor } from './queries/heights.js';
import { cameraOf } from './render/rendererOf.js';
import { cameraOf } from './render/cesium/camera.js';
/** Newest landmark arrival; an older post-arrival ground guard yields to it. */
let arrivalGeneration = 0;
+3 -1
View File
@@ -121,11 +121,13 @@ export function createCesiumRendererPlugin({
},
destroy() {
try {
unbind();
input.dispose();
ports.dispose();
if (!viewer.isDestroyed()) viewer.destroy();
} finally {
// Bound until the viewer is gone: a late `cameraOf` must not
// build a camera of its own over the application's viewer.
unbind();
creditContainer.remove();
}
},
+25 -1
View File
@@ -6,8 +6,15 @@
*/
import * as Cesium from 'cesium';
import { createCameraEvents } from '../camera/events.js';
import { listenerList, toCartesian } from './common.js';
import { listenerList, toCartesian, uncaught } from './common.js';
import { cameraFlightActive } from './internals.js';
import { cameraOf, provideUnboundCamera } from '../rendererOf.js';
/**
* `cameraOf`, for code that already reaches Cesium: importing it from here
* loads the factory a viewer with no renderer bound gets its camera from.
*/
export { cameraOf };
/** The contract's easings as Cesium's. */
export const EASINGS = Object.freeze({
@@ -16,6 +23,7 @@ export const EASINGS = Object.freeze({
cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT,
cubicOut: Cesium.EasingFunction.CUBIC_OUT,
quinticInOut: Cesium.EasingFunction.QUINTIC_IN_OUT,
sineInOut: Cesium.EasingFunction.SINUSOIDAL_IN_OUT,
});
/** A contract destination as Cesium's: a Cartesian3, or a Rectangle. */
@@ -265,3 +273,19 @@ export function createCesiumCamera({ scene, camera, report }) {
},
};
}
// A Cesium viewer of a consumer's own, with no renderer bound to it, gets a
// camera port over its camera when code first asks `cameraOf` for one.
provideUnboundCamera((viewer) =>
// By shape, not `instanceof`: a consumer's viewer may come from another
// copy of Cesium.
typeof viewer.camera?.flyTo === 'function' &&
typeof viewer.camera.moveStart?.addEventListener === 'function' &&
typeof viewer.scene?.postUpdate?.addEventListener === 'function'
? createCesiumCamera({
scene: viewer.scene,
camera: viewer.camera,
report: uncaught,
})
: null,
);
+1
View File
@@ -29,6 +29,7 @@ const EASINGS = {
cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT,
cubicOut: Cesium.EasingFunction.CUBIC_OUT,
quinticInOut: Cesium.EasingFunction.QUINTIC_IN_OUT,
sineInOut: Cesium.EasingFunction.SINUSOIDAL_IN_OUT,
};
/** One frame of the virtual clock, ms. */
+24
View File
@@ -15,6 +15,7 @@ import {
import { FRAME_MS, headlessCamera, withVirtualClock } from './cameraGoldens.js';
import { createCesiumCamera } from './camera.js';
import { createCesiumPorts } from './ports.js';
import { cameraOf } from '../rendererOf.js';
/** Records the headless scene cannot answer: they need the query port. */
const QUERY_RECORDS = new Set([
@@ -208,3 +209,26 @@ test('a flight Cesium does not start ends cancelled rather than never', async ()
assert.equal(await flight.done, 'cancelled');
assert.equal(port.flying, false);
});
test('a Cesium viewer with no renderer bound gets a camera port over its own camera', async () => {
await withVirtualClock(async () => {
const { scene, camera } = headlessCamera();
scene.postUpdate = new Cesium.Event();
scene.requestRender = () => {};
const viewer = { scene, camera };
const port = cameraOf(viewer);
assert.equal(cameraOf(viewer), port, 'one port per viewer');
let outcome = null;
port.flyTo(
{ lon: 2.35, lat: 48.86, height: 1500 },
{ durationS: 0, onComplete: () => (outcome = 'complete') },
);
assert.equal(outcome, 'complete');
const pose = port.pose();
assert.ok(
Math.abs(pose.lon - 2.35) < 1e-9 && Math.abs(pose.lat - 48.86) < 1e-9,
);
assert.ok(Math.abs(pose.height - 1500) < 1e-6);
assert.throws(() => cameraOf({ scene, camera: {} }), /no renderer camera/);
});
});
+15
View File
@@ -50,3 +50,18 @@ export function toCartesian(pos) {
if ('x' in pos) return new Cesium.Cartesian3(pos.x, pos.y, pos.z);
return Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, pos.height ?? 0);
}
/**
* Where a listener's throw goes with no `error` listener: the platform's
* `reportError` (or a rethrow on a microtask), as a browser reports an event
* listener's exception, loud, while the frame and the other listeners carry
* on.
*/
export function uncaught(error) {
if (typeof globalThis.reportError === 'function')
globalThis.reportError(error);
else
queueMicrotask(() => {
throw error;
});
}
+1 -16
View File
@@ -14,7 +14,7 @@
import * as Cesium from 'cesium';
import { heightsFor } from '../../queries/heights.js';
import { createCesiumCamera } from './camera.js';
import { listenerList, toCartesian } from './common.js';
import { listenerList, toCartesian, uncaught } from './common.js';
import {
atEndOfTick,
billboardLoadError,
@@ -25,21 +25,6 @@ import {
export { listenerList, toCartesian };
/**
* Where a listener's throw goes with no `error` listener: the platform's
* `reportError` (or a rethrow on a microtask), as a browser reports an event
* listener's exception, loud, while the frame and the other listeners carry
* on.
*/
function uncaught(error) {
if (typeof globalThis.reportError === 'function')
globalThis.reportError(error);
else
queueMicrotask(() => {
throw error;
});
}
/** The contract's colour as Cesium's. */
export function toColor(color, alpha = 1) {
if (color === undefined || color === null)
+23
View File
@@ -392,6 +392,29 @@ export const CAMERA_SCRIPTS = Object.freeze([
{ record: 'pose' },
],
},
{
name: 'flyTo sineInOut',
steps: [
START,
{
do: 'flyTo',
args: [
{ ...AUSTIN, height: 4000 },
{
durationS: 1,
easing: 'sineInOut',
orientation: { heading: 0.3, pitch: -0.5, roll: 0 },
},
],
},
{ do: 'frames', count: 15 },
{ record: 'pose' },
{ do: 'frames', count: 30 },
{ record: 'pose' },
{ do: 'frames', count: 30 },
{ record: 'pose' },
],
},
{
name: 'a cancelled flight stops where it was',
steps: [
+104
View File
@@ -2144,6 +2144,110 @@
}
]
},
"flyTo sineInOut": {
"records": [
{
"step": 3,
"kind": "pose",
"value": {
"position": {
"x": -2264202.3786493177,
"y": -16652052.298267456,
"z": 9785772.494992554
},
"direction": {
"x": 0.1297004555930398,
"y": 0.9146885584650551,
"z": -0.38278274886948277
},
"up": {
"x": 0.08931821646822154,
"y": 0.3736965407891975,
"z": 0.9232405708205872
},
"right": {
"x": 0.9875221759666432,
"y": -0.1539341950812336,
"z": -0.0332297390720854
},
"heading": 0.03852827617838983,
"pitch": -1.433276538096362,
"roll": 6.283185307179586,
"height": 13074099.901429506,
"frameIsIdentity": true
}
},
{
"step": 5,
"kind": "pose",
"value": {
"position": {
"x": -1031979.3952742261,
"y": -7589681.480280083,
"z": 4448442.5808664905
},
"direction": {
"x": 0.31556045134531424,
"y": 0.8875905643040791,
"z": 0.33556607651118137
},
"up": {
"x": 0.10445908107755925,
"y": -0.38397902960887687,
"z": 0.9174139770033249
},
"right": {
"x": 0.9431383259772522,
"y": -0.254446644660509,
"z": -0.21388549061082934
},
"heading": 0.25036959095382905,
"pitch": -0.6771468656798949,
"roll": 6.283185307179586,
"height": 2484858.7135209497,
"frameIsIdentity": true
}
},
{
"step": 7,
"kind": "pose",
"value": {
"position": {
"x": -743292.8811925538,
"y": -5466539.584651379,
"z": 3198007.239048282
},
"direction": {
"x": 0.36970163659367444,
"y": 0.7940777354746522,
"z": 0.48245336554262613
},
"up": {
"x": 0.0693712174939781,
"y": -0.5413817815775576,
"z": 0.8379101388331048
},
"right": {
"x": 0.9265572481413991,
"y": -0.2763083722932726,
"z": -0.2552358699659599
},
"heading": 0.2999999999999998,
"pitch": -0.5,
"roll": 6.283185307179586,
"height": 3999.9999999988663,
"frameIsIdentity": true
}
}
],
"flights": [
{
"step": 1,
"outcome": "complete",
"frames": 62
}
]
},
"a cancelled flight stops where it was": {
"records": [
{
+6 -1
View File
@@ -78,7 +78,12 @@ export interface ViewSpec {
}
export type Easing =
'linear' | 'quadraticInOut' | 'cubicInOut' | 'cubicOut' | 'quinticInOut';
| 'linear'
| 'quadraticInOut'
| 'cubicInOut'
| 'cubicOut'
| 'quinticInOut'
| 'sineInOut';
/**
* A flight's omitted orientation is the same as `setView`'s. Its omitted
+59 -6
View File
@@ -9,13 +9,36 @@
const renderers = new WeakMap();
/**
* Bind a renderer to its scene object.
* Camera ports built for scene objects no renderer is bound to (a viewer of
* a consumer's own, say), each `{port, dispose}` and kept for as long as its
* scene object lives, or until a renderer is bound to it.
*/
const unboundCameras = new WeakMap();
let buildUnboundCamera = null;
/**
* Provide the factory `cameraOf` builds a camera port with for a scene
* object no renderer is bound to. The renderer adapter provides it as it
* loads; a later call replaces it.
* @param {(host: object) => ({port: object, dispose: () => void} | null)} build
*/
export function provideUnboundCamera(build) {
buildUnboundCamera = build;
}
/**
* Bind a renderer to its scene object. A camera `cameraOf` built for it
* while it had no renderer is disposed: its listeners hear nothing more,
* and `cameraOf` answers with the renderer's camera from then on.
* @param {object} host The scene object callers hold.
* @param {object} renderer
* @returns {() => void} Unbinds, if the binding is still this one.
*/
export function bindRenderer(host, renderer) {
if (renderers.has(host)) throw new Error('This scene already has a renderer');
// The renderer's camera supersedes one built while there was none.
unboundCameras.get(host)?.dispose();
unboundCameras.delete(host);
renderers.set(host, renderer);
return () => {
if (renderers.get(host) === renderer) renderers.delete(host);
@@ -29,13 +52,43 @@ export function bindRenderer(host, renderer) {
export const rendererOf = (host) => (host && renderers.get(host)) || null;
/**
* The camera port of the renderer bound to `host`.
* The camera port of the renderer bound to `host`; with no renderer bound,
* one built over `host` by the provided factory and kept with it while
* `host` lives.
* @param {object|null|undefined} host
* @returns {import('./contract/camera').CameraPort | null} Null when `host`
* has no renderer and no camera can be built for it, or is destroyed.
*/
export function findCamera(host) {
const camera = rendererOf(host)?.camera;
if (camera) return camera;
if (!host || typeof host !== 'object') return null;
if (host.isDestroyed?.()) {
// Gone with its scene: let the built camera go with it.
unboundCameras.get(host)?.dispose();
unboundCameras.delete(host);
return null;
}
let built = unboundCameras.get(host);
if (!built && buildUnboundCamera) {
built = buildUnboundCamera(host);
if (built) unboundCameras.set(host, built);
}
return built?.port ?? null;
}
/**
* `findCamera`, for code that needs the camera.
* @param {object} host
* @returns {import('./contract/camera').CameraPort}
* @throws When no renderer is bound to `host`.
* @throws When there is no camera for `host`.
*/
export function cameraOf(host) {
const camera = rendererOf(host)?.camera;
if (!camera) throw new Error('This scene has no renderer camera');
return camera;
const camera = findCamera(host);
if (camera) return camera;
throw new Error(
host?.isDestroyed?.()
? 'This scene has been destroyed'
: 'This scene has no renderer camera',
);
}
+56 -1
View File
@@ -1,6 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { bindRenderer, cameraOf, rendererOf } from './rendererOf.js';
import {
bindRenderer,
cameraOf,
findCamera,
provideUnboundCamera,
rendererOf,
} from './rendererOf.js';
test('a scene finds its renderer until it is unbound', () => {
const scene = {};
@@ -32,3 +38,52 @@ test('cameraOf names the missing renderer instead of returning nothing', () => {
assert.equal(cameraOf(scene), camera);
unbind();
});
test('with no renderer bound, cameraOf builds one camera per scene until a renderer is bound', (t) => {
t.after(() => provideUnboundCamera(null));
const built = [];
provideUnboundCamera((host) => {
if (!host.camera) return null;
const entry = { port: { id: built.length }, disposed: 0 };
entry.dispose = () => (entry.disposed += 1);
built.push(entry);
return entry;
});
const viewer = { camera: {} };
assert.equal(cameraOf(viewer), built[0].port);
assert.equal(cameraOf(viewer), built[0].port, 'kept, not rebuilt');
assert.equal(built.length, 1);
assert.throws(
() => cameraOf({}),
/no renderer camera/,
'nothing to build over',
);
assert.equal(findCamera({}), null);
assert.equal(findCamera(null), null);
const camera = { pose() {} };
const unbind = bindRenderer(viewer, { camera });
assert.equal(
built[0].disposed,
1,
'the renderer supersedes the built camera',
);
assert.equal(cameraOf(viewer), camera);
unbind();
assert.equal(cameraOf(viewer), built[1].port, 'unbound again: a fresh one');
});
test('a destroyed scene gets no built camera, and lets go of the one it had', (t) => {
t.after(() => provideUnboundCamera(null));
let disposed = 0;
provideUnboundCamera(() => ({ port: {}, dispose: () => (disposed += 1) }));
let destroyed = false;
const viewer = { isDestroyed: () => destroyed };
cameraOf(viewer);
destroyed = true;
assert.throws(() => cameraOf(viewer), /destroyed/);
assert.equal(disposed, 1);
assert.equal(findCamera(viewer), null, 'findCamera answers null instead');
assert.throws(() => cameraOf(viewer), /destroyed/);
assert.equal(disposed, 1, 'once');
});
+1
View File
@@ -61,6 +61,7 @@ const EASINGS = {
cubicInOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2),
quinticInOut: (t) => (t < 0.5 ? 16 * t ** 5 : 1 - (-2 * t + 2) ** 5 / 2),
cubicOut: (t) => (t - 1) ** 3 + 1,
sineInOut: (t) => 0.5 * (1 - Math.cos(Math.PI * t)),
};
/** Above this height, a descending flight eases out rather than in and out. */
+146
View File
@@ -0,0 +1,146 @@
/**
* A public entry that reaches the renderer bridge (`rendererOf.js`) may hand
* `cameraOf` a viewer of the consumer's own, with no renderer bound. The
* bridge builds a camera port for it with the factory the Cesium adapter
* provides as `render/cesium/camera.js` loads, so every such entry must load
* that module too.
*
* The graph is every static import, re-export and literal dynamic import of
* the entry's module and those it reaches, read by the parsers the Cesium
* ratchet uses; bare specifiers (packages) end the walk.
*/
import assert from 'node:assert/strict';
import { existsSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { parsers } from 'prettier/plugins/babel.mjs';
import { parse as parseSfc } from 'vue/compiler-sfc';
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
const BRIDGE = 'src/render/rendererOf.js';
const FACTORY = 'src/render/cesium/camera.js';
/**
* Entries that take their Cesium code injected: the module named here is
* GEV's Cesium side for them, and must load the factory. Given another, the
* entry's flight finds no camera (`findCamera`) and does nothing.
*/
const INJECTED = {
'./layers/recent-imagery': 'src/layers/recentImagery/rendering.js',
};
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
/** The browser module an export resolves to, or null for Node-only ones. */
function browserTarget(declaration) {
if (typeof declaration === 'string') return declaration;
if (!declaration || typeof declaration !== 'object') return null;
for (const condition of ['browser', 'import', 'default']) {
const target = browserTarget(declaration[condition]);
if (target) return target;
}
return null;
}
function specifiersOf(file) {
let code = readFileSync(path.join(root, file), 'utf8');
if (file.endsWith('.vue')) {
const { descriptor } = parseSfc(code, { filename: file });
code = [descriptor.script, descriptor.scriptSetup]
.filter(Boolean)
.map((block) => block.content)
.join('\n');
}
const found = [];
const walk = (node) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) return node.forEach(walk);
if (
(node.type === 'ImportDeclaration' ||
node.type === 'ExportNamedDeclaration' ||
node.type === 'ExportAllDeclaration') &&
node.source
)
found.push({ specifier: node.source.value, lazy: false });
if (
node.type === 'ImportExpression' &&
node.source?.type === 'StringLiteral'
)
found.push({ specifier: node.source.value, lazy: true });
if (
node.type === 'CallExpression' &&
node.callee?.type === 'Import' &&
node.arguments[0]?.type === 'StringLiteral'
)
found.push({ specifier: node.arguments[0].value, lazy: true });
for (const key of Object.keys(node))
if (key !== 'loc' && key !== 'start' && key !== 'end') walk(node[key]);
};
walk(parsers.babel.parse(code));
return found;
}
/** A relative specifier as a repository file, or null when it is a package. */
function resolve(from, specifier) {
const bare = specifier.split('?')[0];
if (!bare.startsWith('.')) return null;
const base = path.join(path.dirname(from), bare);
const candidates = ['', '.js', '.mjs', '.vue', '/index.js', '/index.mjs'];
for (const suffix of candidates) {
const full = path.join(root, base + suffix);
if (existsSync(full) && statSync(full).isFile())
return path.normalize(base + suffix);
}
// A subgraph dropped here would hide a missing factory.
throw new Error(`${from}: cannot resolve ${specifier}`);
}
/**
* The modules `entry` reaches. With `eager`, only through static imports:
* what has loaded once the entry has, before any lazy import resolves.
*/
function reachable(entry, { eager = false } = {}) {
const seen = new Set([entry]);
const queue = [entry];
while (queue.length) {
const file = queue.pop();
if (!/\.(m?js|vue)$/.test(file)) continue;
for (const { specifier, lazy } of specifiersOf(file)) {
if (eager && lazy) continue;
const next = resolve(file, specifier);
if (next && !seen.has(next)) {
seen.add(next);
queue.push(next);
}
}
}
return seen;
}
test('every public entry that reaches the renderer bridge loads the unbound camera factory', () => {
const missing = [];
let bridged = 0;
for (const [key, declaration] of Object.entries(pkg.exports)) {
const target = browserTarget(declaration);
if (typeof target !== 'string' || !target.startsWith('./src/')) continue;
const entry = path.normalize(target.slice(2));
const graph = reachable(entry);
// Any path to the bridge counts, lazy or not; only an eager one loads
// the factory in time for the first `cameraOf`.
if (!graph.has(BRIDGE)) continue;
bridged += 1;
const carrier = INJECTED[key];
const loads = (file) => reachable(file, { eager: true }).has(FACTORY);
if (!loads(entry) && !(carrier && loads(carrier))) missing.push(key);
}
assert.ok(bridged > 0, 'no entry reaches the bridge: the walk is broken');
assert.deepEqual(
missing,
[],
`These entries can reach cameraOf without ${FACTORY}; import it where the entry first reaches the bridge.`,
);
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { toDegrees, toRadians } from './geo/index.js';
import { rendererOf } from './render/rendererOf.js';
import { findCamera } from './render/rendererOf.js';
import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from './bloom.js';
import {
migrateDetectionState,
@@ -69,7 +69,7 @@ export class ShareLinkManager {
isNavigationCurrent,
cancelOwnedNavigation,
initialShareCode = null,
camera = rendererOf(viewer)?.camera,
camera = findCamera(viewer),
} = {},
) {
if (!camera) throw new Error('ShareLinkManager needs a camera port');
+1 -1
View File
@@ -30,7 +30,7 @@ import {
} from './cyberSonarControls.js';
import * as Cesium from 'cesium';
import { cameraOf } from '../render/rendererOf.js';
import { cameraOf } from '../render/cesium/camera.js';
import { aircraftTrackingTarget } from '../cockpitTracking.js';
+2 -1
View File
@@ -1,5 +1,6 @@
/** Camera placement, ground acquisition and motion correction for the Cockpit controller. */
import * as Cesium from 'cesium';
import { cameraOf } from '../render/cesium/camera.js';
import {
cockpitAnchorCorrectionStep,
cockpitGroundSafeHeight,
@@ -349,7 +350,7 @@ export function update() {
}
}
this.viewer.camera.setView({
cameraOf(this.viewer).setView({
destination: this.scratchCamera,
orientation: { direction: this.scratchForward, up: this.scratchUp },
});
+2
View File
@@ -10,6 +10,7 @@ import { CockpitViewController } from './cockpitController.js';
import { useCockpitStore } from './stores/cockpit.js';
import CockpitView from './views/CockpitView.vue';
import ContextView from './views/ContextView.vue';
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
const INFO = Object.freeze({
icao24: 'abc123',
@@ -51,6 +52,7 @@ function fixture(t) {
},
trackedEntityChanged: listeners(),
};
bindFakeCesiumCamera(viewer);
const cockpit = new CockpitViewController(viewer, {
services: {
flightsLayer: { getTrackedInfo: () => INFO, refreshTr3b() {} },
+2 -1
View File
@@ -1,5 +1,6 @@
/** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */
import { t } from '../i18n/index.js';
import { cameraOf } from '../render/rendererOf.js';
import {
formatHeadingReadout,
normalizeHeading,
@@ -150,7 +151,7 @@ export function enter() {
: null;
// Retire deferred navigation before cancelFlight can run its callbacks.
this.onCameraTakeover?.();
this.viewer.camera.cancelFlight();
cameraOf(this.viewer).cancelFlight();
this.trackedEntity = entity;
this.trackedEntityWasShown = entity.show;
entity.show = false;
+2 -2
View File
@@ -2,7 +2,7 @@
* One-click camera transfer for layer-owned world targets.
*/
import { WGS84, toRadians } from './geo/index.js';
import { rendererOf } from './render/rendererOf.js';
import { findCamera } from './render/rendererOf.js';
export const WORLD_FOCUS_REQUEST_EVENT = 'gev:world-request-focus';
export const WORLD_CLICK_FOCUS_DURATION_SEC = 1.9;
@@ -69,7 +69,7 @@ export function routeWorldFocusRequest(event, runExplicitFocus, fly) {
/** Fly to a world target after ownership has been released. */
export function flyToWorldTarget(viewer, target = {}) {
const camera = rendererOf(viewer)?.camera;
const camera = findCamera(viewer);
const framing = WORLD_FOCUS_FRAMING[target.kind];
if (!camera || !framing || !isValidWorldFocusTarget(target)) return false;
const poseHeading = camera.pose().heading;