mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Fly the camera through the renderer's camera port
The Cesium camera port moves into render/cesium/camera.js, with the listener list and position conversion shared from common.js. It now covers flights (flyTo, flyToBounds, cancelFlight, flying), lookAt and release, command hooks, and a flightEnd event for the flights it starts. A flight's end that Cesium raises inside one of the port's own calls is delivered once Cesium's call returns, so its callback sees the flight let go of. A flight Cesium declines to start, mid scene-mode morph, ends as cancelled instead of never settling. Flight traces run in Node on the recorder's headless camera with a virtual clock; the browser harness keeps the instant traces. The startup fly-in, landmark and viewport flights, share-link restore and the shell's camera state now go through the port. camera.js and sharelink.js leave the Cesium allow-list.
This commit is contained in:
1 parent
de0ff6cd2d
commit
f95324acb1
20 files changed
+846
-305
No files matched your search
@@ -968,6 +968,7 @@
|
|||||||
"src/styles/surveillance.js",
|
"src/styles/surveillance.js",
|
||||||
"src/styles/thermal.js",
|
"src/styles/thermal.js",
|
||||||
"src/ui/applicationShell.js",
|
"src/ui/applicationShell.js",
|
||||||
|
"src/render/rendererOf.js",
|
||||||
"src/ui/localePicker.js",
|
"src/ui/localePicker.js",
|
||||||
"src/ui/cyberSonarControls.js",
|
"src/ui/cyberSonarControls.js",
|
||||||
"src/ui/locationNavigation.js",
|
"src/ui/locationNavigation.js",
|
||||||
@@ -2492,6 +2493,8 @@
|
|||||||
"src/render/cesium/appRenderer.js",
|
"src/render/cesium/appRenderer.js",
|
||||||
"src/render/rendererOf.js",
|
"src/render/rendererOf.js",
|
||||||
"src/render/cesium/ports.js",
|
"src/render/cesium/ports.js",
|
||||||
|
"src/render/cesium/common.js",
|
||||||
|
"src/render/cesium/camera.js",
|
||||||
"src/render/camera/events.js",
|
"src/render/camera/events.js",
|
||||||
"src/render/contract/index.js",
|
"src/render/contract/index.js",
|
||||||
"src/sources/sourceSlot.js",
|
"src/sources/sourceSlot.js",
|
||||||
@@ -3800,6 +3803,7 @@
|
|||||||
"src/layers/vessels/testing.js",
|
"src/layers/vessels/testing.js",
|
||||||
"src/layers/vessels/tracking.js",
|
"src/layers/vessels/tracking.js",
|
||||||
"src/locations.js",
|
"src/locations.js",
|
||||||
|
"src/render/rendererOf.js",
|
||||||
"src/cityPois.js",
|
"src/cityPois.js",
|
||||||
"src/navigationPolicy.js",
|
"src/navigationPolicy.js",
|
||||||
"src/overlays/worldOverlay.js",
|
"src/overlays/worldOverlay.js",
|
||||||
@@ -4096,6 +4100,7 @@
|
|||||||
"src/keylessGeocoder.js",
|
"src/keylessGeocoder.js",
|
||||||
"src/layers/firms/anchors.js",
|
"src/layers/firms/anchors.js",
|
||||||
"src/locations.js",
|
"src/locations.js",
|
||||||
|
"src/render/rendererOf.js",
|
||||||
"src/cityPois.js",
|
"src/cityPois.js",
|
||||||
"src/cameraGroundGuard.js",
|
"src/cameraGroundGuard.js",
|
||||||
"src/search/alpsGeocoder.js",
|
"src/search/alpsGeocoder.js",
|
||||||
|
|||||||
@@ -1,25 +1,68 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { flyToAustin } from '../camera.js';
|
import { flyToAustin } from '../camera.js';
|
||||||
|
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||||
|
|
||||||
test('teardown before the initial camera delay prevents a late flight', (t) => {
|
test('teardown before the initial camera delay prevents a late flight', (t) => {
|
||||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
let flights = 0;
|
let flights = 0;
|
||||||
let cancelled = 0;
|
let cancelled = 0;
|
||||||
const stop = flyToAustin({
|
const stop = flyToAustin(
|
||||||
isDestroyed: () => false,
|
bindFakeCesiumCamera({
|
||||||
camera: {
|
isDestroyed: () => false,
|
||||||
setView() {},
|
camera: {
|
||||||
flyTo() {
|
setView() {},
|
||||||
flights++;
|
flyTo() {
|
||||||
|
flights++;
|
||||||
|
},
|
||||||
|
cancelFlight() {
|
||||||
|
cancelled++;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
cancelFlight() {
|
}),
|
||||||
cancelled++;
|
);
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
stop();
|
stop();
|
||||||
t.mock.timers.tick(1000);
|
t.mock.timers.tick(1000);
|
||||||
assert.equal(flights, 0);
|
assert.equal(flights, 0);
|
||||||
assert.equal(cancelled, 1);
|
assert.equal(cancelled, 0, 'no fly-in started, so none to cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('teardown cancels the fly-in, not a flight the user started since', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const cancelled = [];
|
||||||
|
let current = null;
|
||||||
|
const viewer = bindFakeCesiumCamera({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
camera: {
|
||||||
|
get _currentFlight() {
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
setView() {},
|
||||||
|
flyTo(options) {
|
||||||
|
current?.cancel();
|
||||||
|
current = {
|
||||||
|
name: options.duration === 4 ? 'fly-in' : 'user',
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
cancelFlight() {
|
||||||
|
cancelled.push(current?.name);
|
||||||
|
const flight = current;
|
||||||
|
current = null;
|
||||||
|
flight?.cancel();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const stop = flyToAustin(viewer);
|
||||||
|
t.mock.timers.tick(500);
|
||||||
|
stop();
|
||||||
|
assert.deepEqual(cancelled, ['fly-in']);
|
||||||
|
|
||||||
|
// A user flight replaces the fly-in; teardown leaves it alone.
|
||||||
|
const second = flyToAustin(viewer);
|
||||||
|
t.mock.timers.tick(500);
|
||||||
|
viewer.camera.flyTo({ duration: 2 });
|
||||||
|
second();
|
||||||
|
assert.deepEqual(cancelled, ['fly-in']);
|
||||||
|
assert.equal(current.name, 'user');
|
||||||
});
|
});
|
||||||
+16
-18
@@ -1,36 +1,34 @@
|
|||||||
import * as Cesium from 'cesium';
|
import { toRadians } from './geo/index.js';
|
||||||
|
import { cameraOf } from './render/rendererOf.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set camera to Austin on load with a cinematic fly-in.
|
* Set camera to Austin on load with a cinematic fly-in.
|
||||||
* @returns {Function} Cancels the pending or active startup flight.
|
* @returns {Function} Cancels the pending or active startup flight.
|
||||||
*/
|
*/
|
||||||
export function flyToAustin(viewer) {
|
export function flyToAustin(viewer) {
|
||||||
|
const camera = cameraOf(viewer);
|
||||||
// Start from a high altitude, then fly down
|
// Start from a high altitude, then fly down
|
||||||
viewer.camera.setView({
|
camera.setView({
|
||||||
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 25000),
|
destination: { lon: -97.7431, lat: 30.2672, height: 25000 },
|
||||||
orientation: {
|
orientation: { heading: 0, pitch: toRadians(-90), roll: 0 },
|
||||||
heading: Cesium.Math.toRadians(0),
|
|
||||||
pitch: Cesium.Math.toRadians(-90),
|
|
||||||
roll: 0.0,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cinematic fly-in after a brief pause
|
// Cinematic fly-in after a brief pause
|
||||||
|
let flight = null;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (viewer.isDestroyed()) return;
|
if (viewer.isDestroyed()) return;
|
||||||
viewer.camera.flyTo({
|
flight = camera.flyTo(
|
||||||
destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 600),
|
{ lon: -97.7431, lat: 30.2672, height: 600 },
|
||||||
orientation: {
|
{
|
||||||
heading: Cesium.Math.toRadians(15),
|
orientation: { heading: toRadians(15), pitch: toRadians(-30), roll: 0 },
|
||||||
pitch: Cesium.Math.toRadians(-30),
|
durationS: 4.0,
|
||||||
roll: 0.0,
|
easing: 'cubicInOut',
|
||||||
},
|
},
|
||||||
duration: 4.0,
|
);
|
||||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
|
||||||
});
|
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (!viewer.isDestroyed()) viewer.camera.cancelFlight();
|
// Only the fly-in: a flight the user started since is theirs.
|
||||||
|
if (!viewer.isDestroyed()) flight?.cancel();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+60
-61
@@ -13,6 +13,7 @@ import { cancelCameraArrival } from './data/cameraArrival.js';
|
|||||||
import { guardCameraAboveGround } from './cameraGroundGuard.js';
|
import { guardCameraAboveGround } from './cameraGroundGuard.js';
|
||||||
import { CITY_POIS } from './cityPois.js';
|
import { CITY_POIS } from './cityPois.js';
|
||||||
import { heightsFor } from './queries/heights.js';
|
import { heightsFor } from './queries/heights.js';
|
||||||
|
import { cameraOf } from './render/rendererOf.js';
|
||||||
|
|
||||||
/** Newest landmark arrival; an older post-arrival ground guard yields to it. */
|
/** Newest landmark arrival; an older post-arrival ground guard yields to it. */
|
||||||
let arrivalGeneration = 0;
|
let arrivalGeneration = 0;
|
||||||
@@ -37,30 +38,23 @@ export const GLOBE_VIEW = Object.freeze({
|
|||||||
* @returns {{latitude: number, longitude: number, heightM: number}}
|
* @returns {{latitude: number, longitude: number, heightM: number}}
|
||||||
*/
|
*/
|
||||||
export function flyToGlobeView(viewer, options = {}) {
|
export function flyToGlobeView(viewer, options = {}) {
|
||||||
const carto = viewer.camera.positionCartographic;
|
const camera = cameraOf(viewer);
|
||||||
const longitude = Cesium.Math.toDegrees(carto.longitude);
|
const { lon: longitude, lat: latitude } = camera.pose();
|
||||||
const latitude = Cesium.Math.toDegrees(carto.latitude);
|
camera.cancelFlight();
|
||||||
viewer.camera.cancelFlight();
|
camera.flyTo(
|
||||||
viewer.camera.flyTo({
|
{ lon: longitude, lat: latitude, height: GLOBE_VIEW.heightM },
|
||||||
destination: Cesium.Cartesian3.fromDegrees(
|
{
|
||||||
longitude,
|
orientation: {
|
||||||
latitude,
|
heading: 0,
|
||||||
GLOBE_VIEW.heightM,
|
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
|
||||||
),
|
roll: 0,
|
||||||
orientation: {
|
},
|
||||||
heading: 0,
|
durationS: finitePositive(options.duration) || GLOBE_VIEW.durationS,
|
||||||
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
|
endFrame: 'world',
|
||||||
roll: 0,
|
onComplete: options.onComplete,
|
||||||
|
onCancel: options.onCancel,
|
||||||
},
|
},
|
||||||
duration: finitePositive(options.duration) || GLOBE_VIEW.durationS,
|
);
|
||||||
endTransform: Cesium.Matrix4.IDENTITY,
|
|
||||||
// Cesium's Camera.flyTo reads `complete`/`cancel`. `onComplete`/`onCancel`
|
|
||||||
// are this module's OWN option names and are silently ignored by Cesium —
|
|
||||||
// spelling them through to flyTo meant the reset never resolved on the
|
|
||||||
// flight's own events and every caller fell back to its watchdog timeout.
|
|
||||||
complete: options.onComplete,
|
|
||||||
cancel: options.onCancel,
|
|
||||||
});
|
|
||||||
return { latitude, longitude, heightM: GLOBE_VIEW.heightM };
|
return { latitude, longitude, heightM: GLOBE_VIEW.heightM };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,11 +141,11 @@ export function flyToLandmark(viewer, lat, lon, options = {}) {
|
|||||||
)
|
)
|
||||||
: range;
|
: range;
|
||||||
|
|
||||||
const hpr = new Cesium.HeadingPitchRange(
|
const hpr = {
|
||||||
Cesium.Math.toRadians(heading),
|
heading: Cesium.Math.toRadians(heading),
|
||||||
Cesium.Math.toRadians(pitch),
|
pitch: Cesium.Math.toRadians(pitch),
|
||||||
framingRange,
|
range: framingRange,
|
||||||
);
|
};
|
||||||
|
|
||||||
if (typeof onStart === 'function') {
|
if (typeof onStart === 'function') {
|
||||||
try {
|
try {
|
||||||
@@ -162,14 +156,15 @@ export function flyToLandmark(viewer, lat, lon, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fly to target, then lock with lookAt for guaranteed centering
|
// Fly to target, then lock with lookAt for guaranteed centering
|
||||||
viewer.camera.flyToBoundingSphere(
|
const camera = cameraOf(viewer);
|
||||||
new Cesium.BoundingSphere(targetPosition, boundingRadius),
|
camera.flyToBounds(
|
||||||
|
{ center: targetPosition, radius: boundingRadius },
|
||||||
{
|
{
|
||||||
offset: hpr,
|
offset: hpr,
|
||||||
duration,
|
durationS: duration,
|
||||||
complete: () => {
|
onComplete: () => {
|
||||||
viewer.camera.lookAt(targetPosition, hpr);
|
camera.lookAt(targetPosition, hpr);
|
||||||
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
camera.release();
|
||||||
// Framing used a predicted ground height. Once tiles stream in, measure
|
// Framing used a predicted ground height. Once tiles stream in, measure
|
||||||
// the rendered surface and lift the eye if it is buried or pressed
|
// the rendered surface and lift the eye if it is buried or pressed
|
||||||
// against it; a newer arrival or a manual gesture ends the check.
|
// against it; a newer arrival or a manual gesture ends the check.
|
||||||
@@ -190,7 +185,7 @@ export function flyToLandmark(viewer, lat, lon, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cancel: () => {
|
onCancel: () => {
|
||||||
if (typeof onCancel === 'function') {
|
if (typeof onCancel === 'function') {
|
||||||
try {
|
try {
|
||||||
onCancel();
|
onCancel();
|
||||||
@@ -788,15 +783,17 @@ function flyToViewportBounds(viewer, viewport, options = {}) {
|
|||||||
const paddedLonSpan = metrics.lonSpanDeg + longitudePadding * 2;
|
const paddedLonSpan = metrics.lonSpanDeg + longitudePadding * 2;
|
||||||
const south = Math.max(-89.9, southwest.lat - latitudePadding);
|
const south = Math.max(-89.9, southwest.lat - latitudePadding);
|
||||||
const north = Math.min(89.9, northeast.lat + latitudePadding);
|
const north = Math.min(89.9, northeast.lat + latitudePadding);
|
||||||
const rectangle =
|
const rect =
|
||||||
paddedLonSpan >= 360
|
paddedLonSpan >= 360
|
||||||
? Cesium.Rectangle.fromDegrees(-180, south, 180, north)
|
? { west: -180, south, east: 180, north }
|
||||||
: Cesium.Rectangle.fromDegrees(
|
: {
|
||||||
wrapLongitude(southwest.lng - longitudePadding),
|
west: wrapLongitude(southwest.lng - longitudePadding),
|
||||||
south,
|
south,
|
||||||
wrapLongitude(southwest.lng + metrics.lonSpanDeg + longitudePadding),
|
east: wrapLongitude(
|
||||||
|
southwest.lng + metrics.lonSpanDeg + longitudePadding,
|
||||||
|
),
|
||||||
north,
|
north,
|
||||||
);
|
};
|
||||||
if (typeof beforeFly === 'function' && beforeFly() === false)
|
if (typeof beforeFly === 'function' && beforeFly() === false)
|
||||||
return CANCELLED_SEARCH;
|
return CANCELLED_SEARCH;
|
||||||
if (typeof onStart === 'function') {
|
if (typeof onStart === 'function') {
|
||||||
@@ -806,29 +803,31 @@ function flyToViewportBounds(viewer, viewport, options = {}) {
|
|||||||
/* no-op */
|
/* no-op */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewer.camera.flyTo({
|
cameraOf(viewer).flyTo(
|
||||||
destination: rectangle,
|
{ rect },
|
||||||
duration,
|
{
|
||||||
endTransform: Cesium.Matrix4.IDENTITY,
|
durationS: duration,
|
||||||
complete: () => {
|
endFrame: 'world',
|
||||||
if (typeof onComplete === 'function') {
|
onComplete: () => {
|
||||||
try {
|
if (typeof onComplete === 'function') {
|
||||||
onComplete();
|
try {
|
||||||
} catch {
|
onComplete();
|
||||||
/* no-op */
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
onCancel: () => {
|
||||||
cancel: () => {
|
if (typeof onCancel === 'function') {
|
||||||
if (typeof onCancel === 'function') {
|
try {
|
||||||
try {
|
onCancel();
|
||||||
onCancel();
|
} catch {
|
||||||
} catch {
|
/* no-op */
|
||||||
/* no-op */
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
// Same short-way-round rule for the reported centre: averaging raw longitudes
|
// Same short-way-round rule for the reported centre: averaging raw longitudes
|
||||||
// puts a dateline-crossing box's centre on the opposite side of the planet.
|
// puts a dateline-crossing box's centre on the opposite side of the planet.
|
||||||
return {
|
return {
|
||||||
|
|||||||
+26
-24
@@ -21,10 +21,11 @@ import {
|
|||||||
GLOBE_VIEW,
|
GLOBE_VIEW,
|
||||||
searchAndFlyTo,
|
searchAndFlyTo,
|
||||||
} from './locations.js';
|
} from './locations.js';
|
||||||
|
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
|
||||||
|
|
||||||
function stubViewer() {
|
function stubViewer() {
|
||||||
const flights = [];
|
const flights = [];
|
||||||
return {
|
return bindFakeCesiumCamera({
|
||||||
flights,
|
flights,
|
||||||
scene: { globe: null, canvas: { clientWidth: 0, clientHeight: 0 } },
|
scene: { globe: null, canvas: { clientWidth: 0, clientHeight: 0 } },
|
||||||
camera: {
|
camera: {
|
||||||
@@ -39,7 +40,7 @@ function stubViewer() {
|
|||||||
lookAt() {},
|
lookAt() {},
|
||||||
lookAtTransform() {},
|
lookAtTransform() {},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUSTIN_RESULT = {
|
const AUSTIN_RESULT = {
|
||||||
@@ -604,29 +605,22 @@ test('GLOBE_VIEW preset height sits in the global view band', () => {
|
|||||||
assert.equal(GLOBE_VIEW.pitchDeg, -90);
|
assert.equal(GLOBE_VIEW.pitchDeg, -90);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The globe flight's callbacks are what resolves Reset Globe / zoom_to_globe.
|
// The globe flight's callbacks are what resolves Reset Globe / zoom_to_globe:
|
||||||
// They were published under this module's OWN option names (`onComplete` /
|
// each must reach the caller when the flight ends that way.
|
||||||
// `onCancel`), which Cesium's Camera.flyTo ignores — so neither ever fired and
|
test('the globe flight ends through its own callbacks', () => {
|
||||||
// every caller resolved off its ~4.2 s watchdog timeout instead of the flight.
|
|
||||||
test('the globe flight publishes its callbacks under Cesium\'s own option names', () => {
|
|
||||||
const viewer = stubViewer();
|
const viewer = stubViewer();
|
||||||
const fired = [];
|
const fired = [];
|
||||||
flyToGlobeView(viewer, {
|
const hooks = {
|
||||||
onComplete: () => fired.push('complete'),
|
onComplete: () => fired.push('complete'),
|
||||||
onCancel: () => fired.push('cancel'),
|
onCancel: () => fired.push('cancel'),
|
||||||
});
|
};
|
||||||
|
flyToGlobeView(viewer, hooks);
|
||||||
const flight = viewer.flights[0];
|
flyToGlobeView(viewer, hooks);
|
||||||
assert.equal(typeof flight.complete, 'function', 'Cesium resolves arrival through `complete`');
|
viewer.flights[0].complete();
|
||||||
assert.equal(typeof flight.cancel, 'function', 'Cesium reports supersession through `cancel`');
|
viewer.flights[1].cancel();
|
||||||
// Not merely present under both spellings: the ignored names must be gone,
|
|
||||||
// or a later reader can "fix" the wrong one back.
|
|
||||||
assert.equal('onComplete' in flight, false, 'Cesium ignores onComplete — do not publish it');
|
|
||||||
assert.equal('onCancel' in flight, false, 'Cesium ignores onCancel — do not publish it');
|
|
||||||
|
|
||||||
flight.complete();
|
|
||||||
flight.cancel();
|
|
||||||
assert.deepEqual(fired, ['complete', 'cancel'], 'each hook reaches the caller it belongs to');
|
assert.deepEqual(fired, ['complete', 'cancel'], 'each hook reaches the caller it belongs to');
|
||||||
|
viewer.flights[0].cancel();
|
||||||
|
assert.deepEqual(fired, ['complete', 'cancel'], 'a flight ends once');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a globe flight without callbacks still flies (both hooks are optional)', () => {
|
test('a globe flight without callbacks still flies (both hooks are optional)', () => {
|
||||||
@@ -634,8 +628,7 @@ test('a globe flight without callbacks still flies (both hooks are optional)', (
|
|||||||
const target = flyToGlobeView(viewer);
|
const target = flyToGlobeView(viewer);
|
||||||
assert.equal(viewer.flights.length, 1);
|
assert.equal(viewer.flights.length, 1);
|
||||||
assert.equal(target.heightM, GLOBE_VIEW.heightM);
|
assert.equal(target.heightM, GLOBE_VIEW.heightM);
|
||||||
assert.equal(viewer.flights[0].complete, undefined);
|
viewer.flights[0].complete();
|
||||||
assert.equal(viewer.flights[0].cancel, undefined);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('geocoded Location branches forward the resolved-navigation ownership hook', () => {
|
test('geocoded Location branches forward the resolved-navigation ownership hook', () => {
|
||||||
@@ -674,8 +667,13 @@ test('city and landmark flights expose completion and cancellation hooks', () =>
|
|||||||
onComplete: () => overviewEvents.push('complete'),
|
onComplete: () => overviewEvents.push('complete'),
|
||||||
onCancel: () => overviewEvents.push('cancel'),
|
onCancel: () => overviewEvents.push('cancel'),
|
||||||
});
|
});
|
||||||
|
flyToPresetLocation(overviewViewer, 'austin', {
|
||||||
|
viewMode: 'overview',
|
||||||
|
onComplete: () => overviewEvents.push('complete'),
|
||||||
|
onCancel: () => overviewEvents.push('cancel'),
|
||||||
|
});
|
||||||
overviewViewer.flights[0].complete();
|
overviewViewer.flights[0].complete();
|
||||||
overviewViewer.flights[0].cancel();
|
overviewViewer.flights[1].cancel();
|
||||||
assert.deepEqual(overviewEvents, ['complete', 'cancel']);
|
assert.deepEqual(overviewEvents, ['complete', 'cancel']);
|
||||||
|
|
||||||
const landmarkViewer = stubViewer();
|
const landmarkViewer = stubViewer();
|
||||||
@@ -684,8 +682,12 @@ test('city and landmark flights expose completion and cancellation hooks', () =>
|
|||||||
onComplete: () => landmarkEvents.push('complete'),
|
onComplete: () => landmarkEvents.push('complete'),
|
||||||
onCancel: () => landmarkEvents.push('cancel'),
|
onCancel: () => landmarkEvents.push('cancel'),
|
||||||
});
|
});
|
||||||
|
flyToPresetLocation(landmarkViewer, 'austin', {
|
||||||
|
onComplete: () => landmarkEvents.push('complete'),
|
||||||
|
onCancel: () => landmarkEvents.push('cancel'),
|
||||||
|
});
|
||||||
landmarkViewer.flights[0].complete();
|
landmarkViewer.flights[0].complete();
|
||||||
landmarkViewer.flights[0].cancel();
|
landmarkViewer.flights[1].cancel();
|
||||||
assert.deepEqual(landmarkEvents, ['complete', 'cancel']);
|
assert.deepEqual(landmarkEvents, ['complete', 'cancel']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -318,7 +318,6 @@ test('the application refuses a renderer it cannot run on, and destroys it', asy
|
|||||||
});
|
});
|
||||||
|
|
||||||
const EXPECTED_MISSING = [
|
const EXPECTED_MISSING = [
|
||||||
'camera.cancelFlight',
|
|
||||||
'camera.controls',
|
'camera.controls',
|
||||||
'camera.controls.coast',
|
'camera.controls.coast',
|
||||||
'camera.controls.coast.drag',
|
'camera.controls.coast.drag',
|
||||||
@@ -334,9 +333,6 @@ const EXPECTED_MISSING = [
|
|||||||
'camera.controls.suspend',
|
'camera.controls.suspend',
|
||||||
'camera.controls.zoomFactor',
|
'camera.controls.zoomFactor',
|
||||||
'camera.controls.zoomInputs',
|
'camera.controls.zoomInputs',
|
||||||
'camera.flyTo',
|
|
||||||
'camera.flyToBounds',
|
|
||||||
'camera.flying',
|
|
||||||
'camera.follow',
|
'camera.follow',
|
||||||
'camera.followFramed',
|
'camera.followFramed',
|
||||||
'camera.followOffset',
|
'camera.followOffset',
|
||||||
@@ -344,11 +340,8 @@ const EXPECTED_MISSING = [
|
|||||||
'camera.followOffset.set',
|
'camera.followOffset.set',
|
||||||
'camera.followed',
|
'camera.followed',
|
||||||
'camera.look',
|
'camera.look',
|
||||||
'camera.lookAt',
|
|
||||||
'camera.matrices',
|
'camera.matrices',
|
||||||
'camera.move',
|
'camera.move',
|
||||||
'camera.onCommand',
|
|
||||||
'camera.release',
|
|
||||||
'camera.rotateAround',
|
'camera.rotateAround',
|
||||||
'camera.setFov',
|
'camera.setFov',
|
||||||
'camera.zoom',
|
'camera.zoom',
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* @module render/cesium/camera
|
||||||
|
* @description The Cesium renderer's camera port over a Cesium camera:
|
||||||
|
* poses, views, flights, `lookAt`, events and command hooks, each mapped
|
||||||
|
* onto Cesium's own camera call.
|
||||||
|
*/
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createCameraEvents } from '../camera/events.js';
|
||||||
|
import { listenerList, toCartesian } from './common.js';
|
||||||
|
import { cameraFlightActive } from './internals.js';
|
||||||
|
|
||||||
|
/** The contract's easings as Cesium's. */
|
||||||
|
export const EASINGS = Object.freeze({
|
||||||
|
linear: Cesium.EasingFunction.LINEAR_NONE,
|
||||||
|
quadraticInOut: Cesium.EasingFunction.QUADRATIC_IN_OUT,
|
||||||
|
cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||||
|
cubicOut: Cesium.EasingFunction.CUBIC_OUT,
|
||||||
|
quinticInOut: Cesium.EasingFunction.QUINTIC_IN_OUT,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A contract destination as Cesium's: a Cartesian3, or a Rectangle. */
|
||||||
|
export function toDestination(d) {
|
||||||
|
if ('rect' in d) {
|
||||||
|
const r = d.rect;
|
||||||
|
return Cesium.Rectangle.fromDegrees(r.west, r.south, r.east, r.north);
|
||||||
|
}
|
||||||
|
return toCartesian(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A contract orientation as Cesium's; omitted angles are heading 0, pitch
|
||||||
|
* straight down and roll 0, as the contract says.
|
||||||
|
*/
|
||||||
|
export function toOrientation(o = {}) {
|
||||||
|
if ('direction' in o)
|
||||||
|
return { direction: toCartesian(o.direction), up: toCartesian(o.up) };
|
||||||
|
return {
|
||||||
|
heading: o.heading ?? 0,
|
||||||
|
pitch: o.pitch ?? -Math.PI / 2,
|
||||||
|
roll: o.roll ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{scene: object, camera: object, report: (error: unknown) => void}}
|
||||||
|
* options `scene` gives `requestRender` and the `postUpdate` tick
|
||||||
|
* `changed` is measured on.
|
||||||
|
* @returns {{port: import('../contract/camera').CameraPort, dispose: () => void}}
|
||||||
|
*/
|
||||||
|
export function createCesiumCamera({ scene, camera, report }) {
|
||||||
|
const removers = [];
|
||||||
|
// Camera events: Cesium's own moveStart and moveEnd (500 ms after the
|
||||||
|
// last change, never while following), and `changed` measured per
|
||||||
|
// listener on every tick, where Cesium measures its single threshold.
|
||||||
|
const cameraEvents = createCameraEvents({
|
||||||
|
pose: () => cameraPort.pose(),
|
||||||
|
report,
|
||||||
|
});
|
||||||
|
removers.push(
|
||||||
|
scene.postUpdate.addEventListener(() => cameraEvents.stepChanged()),
|
||||||
|
camera.moveStart.addEventListener(() => cameraEvents.emit('moveStart')),
|
||||||
|
camera.moveEnd.addEventListener(() => cameraEvents.emit('moveEnd')),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Camera commands: hooks run before each one, so an owner can yield.
|
||||||
|
const commandHooks = listenerList(report);
|
||||||
|
const command = (kind) => commandHooks.fire({ kind });
|
||||||
|
|
||||||
|
// The flight this port started, while it is in progress. Cesium cancels
|
||||||
|
// a flight in progress when another starts, including one started on
|
||||||
|
// its camera directly, and runs the callbacks inside the call or frame
|
||||||
|
// that ends it. `flying` sees every Cesium flight; `flightEnd` comes for
|
||||||
|
// the flights this port started (the application's flights all move onto
|
||||||
|
// the port with the camera family).
|
||||||
|
let flight = null;
|
||||||
|
|
||||||
|
// A flight's end that Cesium raises inside one of this port's own calls
|
||||||
|
// waits until Cesium's call returns, still inside the port's call: Cesium
|
||||||
|
// runs a cancelled flight's callback before it lets go of that flight, so
|
||||||
|
// the callback would read `flying` wrong, and a flight it started would
|
||||||
|
// be let go of with the old one.
|
||||||
|
let inCesiumCall = 0;
|
||||||
|
const pendingEnds = [];
|
||||||
|
function cesiumCall(fn) {
|
||||||
|
inCesiumCall += 1;
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
inCesiumCall -= 1;
|
||||||
|
if (inCesiumCall === 0)
|
||||||
|
for (const deliver of pendingEnds.splice(0)) deliver();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startFlight(start, options) {
|
||||||
|
let resolve;
|
||||||
|
const done = new Promise((r) => (resolve = r));
|
||||||
|
let ended = false;
|
||||||
|
const handle = {
|
||||||
|
done,
|
||||||
|
cancel() {
|
||||||
|
if (flight === handle) cesiumCall(() => camera.cancelFlight());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const end = (outcome) => {
|
||||||
|
if (ended) return;
|
||||||
|
ended = true;
|
||||||
|
if (flight === handle) flight = null;
|
||||||
|
const deliver = () => {
|
||||||
|
const cb =
|
||||||
|
outcome === 'complete' ? options.onComplete : options.onCancel;
|
||||||
|
try {
|
||||||
|
cb?.();
|
||||||
|
} catch (error) {
|
||||||
|
report(error);
|
||||||
|
}
|
||||||
|
resolve(outcome);
|
||||||
|
cameraEvents.emit('flightEnd');
|
||||||
|
};
|
||||||
|
if (inCesiumCall) pendingEnds.push(deliver);
|
||||||
|
else deliver();
|
||||||
|
};
|
||||||
|
flight = handle;
|
||||||
|
cesiumCall(() =>
|
||||||
|
start({
|
||||||
|
complete: () => end('complete'),
|
||||||
|
cancel: () => end('cancelled'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Cesium starts nothing mid scene-mode morph, and calls neither back.
|
||||||
|
if (!ended && !cameraFlightActive(camera)) end('cancelled');
|
||||||
|
scene.requestRender();
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cameraPort = {
|
||||||
|
on: (event, cb, options) => cameraEvents.on(event, cb, options),
|
||||||
|
onCommand: (cb) => commandHooks.on(cb),
|
||||||
|
setView(spec) {
|
||||||
|
command('setView');
|
||||||
|
const d = spec.destination;
|
||||||
|
camera.setView({
|
||||||
|
...(d ? { destination: toDestination(d) } : {}),
|
||||||
|
orientation: toOrientation(spec.orientation),
|
||||||
|
});
|
||||||
|
scene.requestRender();
|
||||||
|
},
|
||||||
|
flyTo(destination, options = {}) {
|
||||||
|
command('flyTo');
|
||||||
|
return startFlight(
|
||||||
|
(callbacks) =>
|
||||||
|
camera.flyTo({
|
||||||
|
destination: toDestination(destination),
|
||||||
|
orientation: toOrientation(options.orientation),
|
||||||
|
duration: options.durationS,
|
||||||
|
easingFunction: options.easing && EASINGS[options.easing],
|
||||||
|
maximumHeight: options.maxHeight,
|
||||||
|
pitchAdjustHeight: options.pitchAdjustHeight,
|
||||||
|
endTransform:
|
||||||
|
options.endFrame === 'world'
|
||||||
|
? Cesium.Matrix4.IDENTITY
|
||||||
|
: undefined,
|
||||||
|
...callbacks,
|
||||||
|
}),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
flyToBounds(sphere, options = {}) {
|
||||||
|
command('flyToBounds');
|
||||||
|
const o = options.offset ?? {};
|
||||||
|
return startFlight(
|
||||||
|
(callbacks) =>
|
||||||
|
camera.flyToBoundingSphere(
|
||||||
|
new Cesium.BoundingSphere(
|
||||||
|
toCartesian(sphere.center),
|
||||||
|
sphere.radius,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
offset: new Cesium.HeadingPitchRange(
|
||||||
|
o.heading ?? 0,
|
||||||
|
o.pitch ?? -Math.PI / 4,
|
||||||
|
o.range ?? 0,
|
||||||
|
),
|
||||||
|
duration: options.durationS,
|
||||||
|
...callbacks,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cancelFlight() {
|
||||||
|
command('cancelFlight');
|
||||||
|
cesiumCall(() => camera.cancelFlight());
|
||||||
|
},
|
||||||
|
get flying() {
|
||||||
|
return cameraFlightActive(camera);
|
||||||
|
},
|
||||||
|
lookAt(target, offset) {
|
||||||
|
command('lookAt');
|
||||||
|
camera.lookAt(
|
||||||
|
toCartesian(target),
|
||||||
|
new Cesium.HeadingPitchRange(
|
||||||
|
offset.heading,
|
||||||
|
offset.pitch,
|
||||||
|
offset.range,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
scene.requestRender();
|
||||||
|
},
|
||||||
|
release() {
|
||||||
|
command('release');
|
||||||
|
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||||
|
scene.requestRender();
|
||||||
|
},
|
||||||
|
viewRectangle() {
|
||||||
|
const rect = camera.computeViewRectangle(Cesium.Ellipsoid.WGS84);
|
||||||
|
if (!rect) return null;
|
||||||
|
const deg = Cesium.Math.toDegrees;
|
||||||
|
return {
|
||||||
|
west: deg(rect.west),
|
||||||
|
south: deg(rect.south),
|
||||||
|
east: deg(rect.east),
|
||||||
|
north: deg(rect.north),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
pose() {
|
||||||
|
const v = (c) => ({ x: c.x, y: c.y, z: c.z });
|
||||||
|
// Cesium has no cartographic position for an eye at the earth's
|
||||||
|
// centre; the pose then says so with NaN.
|
||||||
|
const carto = camera.positionCartographic ?? {
|
||||||
|
longitude: NaN,
|
||||||
|
latitude: NaN,
|
||||||
|
height: NaN,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||||
|
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||||
|
position: v(camera.positionWC),
|
||||||
|
direction: v(camera.directionWC),
|
||||||
|
up: v(camera.upWC),
|
||||||
|
right: v(camera.rightWC),
|
||||||
|
heading: camera.heading,
|
||||||
|
pitch: camera.pitch,
|
||||||
|
roll: camera.roll,
|
||||||
|
height: carto.height,
|
||||||
|
fovy: camera.frustum.fovy,
|
||||||
|
aspectRatio: camera.frustum.aspectRatio,
|
||||||
|
near: camera.frustum.near,
|
||||||
|
far: camera.frustum.far,
|
||||||
|
frameIsIdentity: Cesium.Matrix4.equals(
|
||||||
|
camera.transform,
|
||||||
|
Cesium.Matrix4.IDENTITY,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
port: cameraPort,
|
||||||
|
dispose() {
|
||||||
|
for (const off of removers.splice(0)) off();
|
||||||
|
cameraEvents.clear();
|
||||||
|
commandHooks.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,9 +19,10 @@ import {
|
|||||||
import { runCameraScript } from '../conformance/cameraRunner.js';
|
import { runCameraScript } from '../conformance/cameraRunner.js';
|
||||||
import { updateCameraChanged } from './internals.js';
|
import { updateCameraChanged } from './internals.js';
|
||||||
|
|
||||||
const FRAME_MS = 1000 / 60;
|
/**
|
||||||
|
* The contract's easings as Cesium's, kept here rather than taken from the
|
||||||
/** The contract's easings as Cesium's. */
|
* port: the traces are the reference the port is checked against.
|
||||||
|
*/
|
||||||
const EASINGS = {
|
const EASINGS = {
|
||||||
linear: Cesium.EasingFunction.LINEAR_NONE,
|
linear: Cesium.EasingFunction.LINEAR_NONE,
|
||||||
quadraticInOut: Cesium.EasingFunction.QUADRATIC_IN_OUT,
|
quadraticInOut: Cesium.EasingFunction.QUADRATIC_IN_OUT,
|
||||||
@@ -30,8 +31,11 @@ const EASINGS = {
|
|||||||
quinticInOut: Cesium.EasingFunction.QUINTIC_IN_OUT,
|
quinticInOut: Cesium.EasingFunction.QUINTIC_IN_OUT,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** One frame of the virtual clock, ms. */
|
||||||
|
export const FRAME_MS = 1000 / 60;
|
||||||
|
|
||||||
/** Run `fn` with `performance.now()` reading the virtual clock. */
|
/** Run `fn` with `performance.now()` reading the virtual clock. */
|
||||||
async function withVirtualClock(fn) {
|
export async function withVirtualClock(fn) {
|
||||||
const real = Object.getOwnPropertyDescriptor(globalThis, 'performance');
|
const real = Object.getOwnPropertyDescriptor(globalThis, 'performance');
|
||||||
const clock = { now: 0 };
|
const clock = { now: 0 };
|
||||||
Object.defineProperty(globalThis, 'performance', {
|
Object.defineProperty(globalThis, 'performance', {
|
||||||
@@ -45,7 +49,8 @@ async function withVirtualClock(fn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function headlessCamera() {
|
/** A Cesium camera on a scene with no globe, sized as the scripts' viewport. */
|
||||||
|
export function headlessCamera() {
|
||||||
const scene = {
|
const scene = {
|
||||||
canvas: {
|
canvas: {
|
||||||
clientWidth: SCRIPT_VIEWPORT.width,
|
clientWidth: SCRIPT_VIEWPORT.width,
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* The Cesium port's camera commands against the reference traces: the
|
||||||
|
* conformance camera checks run on the port over the same headless camera
|
||||||
|
* and virtual clock the traces were recorded with, so a mapping of the
|
||||||
|
* contract onto Cesium that differs from the recorder's shows.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
CAMERA_SCRIPTS,
|
||||||
|
DEFAULT_TOLERANCE,
|
||||||
|
runConformance,
|
||||||
|
} from '../conformance/index.js';
|
||||||
|
import { FRAME_MS, headlessCamera, withVirtualClock } from './cameraGoldens.js';
|
||||||
|
import { createCesiumCamera } from './camera.js';
|
||||||
|
import { createCesiumPorts } from './ports.js';
|
||||||
|
|
||||||
|
/** Records the headless scene cannot answer: they need the query port. */
|
||||||
|
const QUERY_RECORDS = new Set([
|
||||||
|
'project',
|
||||||
|
'pickEllipsoid',
|
||||||
|
'pickRay',
|
||||||
|
'occluded',
|
||||||
|
]);
|
||||||
|
/** Commands and records the port does not have yet. */
|
||||||
|
const MISSING = new Set(['move', 'look', 'rotateAround', 'zoom', 'matrices']);
|
||||||
|
|
||||||
|
const runnable = CAMERA_SCRIPTS.filter((script) =>
|
||||||
|
script.steps.every(
|
||||||
|
(step) =>
|
||||||
|
!MISSING.has(step.do) &&
|
||||||
|
!MISSING.has(step.record) &&
|
||||||
|
!QUERY_RECORDS.has(step.record),
|
||||||
|
),
|
||||||
|
).map((script) => `camera.trace.${script.name}`);
|
||||||
|
|
||||||
|
const CHECKS = new Set([...runnable, 'camera.flight.callbacks']);
|
||||||
|
|
||||||
|
function headlessHarness(clock) {
|
||||||
|
return {
|
||||||
|
name: 'cesium (headless camera)',
|
||||||
|
async create() {
|
||||||
|
const { scene } = headlessCamera();
|
||||||
|
for (const name of ['preUpdate', 'postUpdate', 'preRender', 'postRender'])
|
||||||
|
scene[name] = new Cesium.Event();
|
||||||
|
scene.primitives = { add() {}, remove() {} };
|
||||||
|
scene.requestRender = () => {};
|
||||||
|
clock.now = 0;
|
||||||
|
const ports = createCesiumPorts(
|
||||||
|
{ scene, clock: { currentTime: Cesium.JulianDate.now() } },
|
||||||
|
{ capabilities: new Set() },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
camera: ports.camera,
|
||||||
|
query: ports.query,
|
||||||
|
destroy: () => ports.dispose(),
|
||||||
|
frameScene() {
|
||||||
|
clock.now += FRAME_MS;
|
||||||
|
scene.tweens.update();
|
||||||
|
scene.postUpdate.raiseEvent(scene);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
frame: async (r) => r.frameScene(),
|
||||||
|
now: () => clock.now,
|
||||||
|
toEcef: (p) => {
|
||||||
|
const e =
|
||||||
|
'lon' in p
|
||||||
|
? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height ?? 0)
|
||||||
|
: p;
|
||||||
|
return { x: e.x, y: e.y, z: e.z };
|
||||||
|
},
|
||||||
|
tolerance: DEFAULT_TOLERANCE,
|
||||||
|
flightPaths: 'trace',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the camera port retraces the reference flights, views and frames', async () => {
|
||||||
|
assert.ok(runnable.length >= 10, `only ${runnable.length} scripts run`);
|
||||||
|
const report = await withVirtualClock((clock) =>
|
||||||
|
runConformance(headlessHarness(clock), { only: (id) => CHECKS.has(id) }),
|
||||||
|
);
|
||||||
|
const failed = report.results.filter((r) => r.status === 'fail');
|
||||||
|
assert.deepEqual(
|
||||||
|
failed.map((r) => `${r.id}: ${r.detail}`),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.equal(report.passed, CHECKS.size);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cancelled flight has let go before its callbacks, so they can fly again', async () => {
|
||||||
|
await withVirtualClock(async (clock) => {
|
||||||
|
const { scene, camera } = headlessCamera();
|
||||||
|
scene.postUpdate = new Cesium.Event();
|
||||||
|
scene.requestRender = () => {};
|
||||||
|
const port = createCesiumCamera({
|
||||||
|
scene,
|
||||||
|
camera,
|
||||||
|
report: (error) => {
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
}).port;
|
||||||
|
const seen = [];
|
||||||
|
let replacement = null;
|
||||||
|
port.flyTo(
|
||||||
|
{ lon: 0, lat: 0, height: 1e6 },
|
||||||
|
{
|
||||||
|
durationS: 1,
|
||||||
|
onCancel: () => {
|
||||||
|
seen.push(['cancelled, flying', port.flying]);
|
||||||
|
replacement = port.flyTo(
|
||||||
|
{ lon: 1, lat: 1, height: 1e6 },
|
||||||
|
{ durationS: 0.2, onComplete: () => seen.push(['replaced']) },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
port.cancelFlight();
|
||||||
|
assert.deepEqual(seen, [['cancelled, flying', false]]);
|
||||||
|
assert.ok(port.flying, 'the replacement flies');
|
||||||
|
for (let i = 0; i < 30 && port.flying; i++) {
|
||||||
|
clock.now += FRAME_MS;
|
||||||
|
scene.tweens.update();
|
||||||
|
}
|
||||||
|
assert.equal(await replacement.done, 'complete');
|
||||||
|
assert.deepEqual(seen.at(-1), ['replaced']);
|
||||||
|
|
||||||
|
// Superseded by a new flyTo: the old one's onCancel still runs inside
|
||||||
|
// that call, and sees the new flight flying.
|
||||||
|
const order = [];
|
||||||
|
port.flyTo(
|
||||||
|
{ lon: 2, lat: 2, height: 1e6 },
|
||||||
|
{
|
||||||
|
durationS: 1,
|
||||||
|
onCancel: () => order.push(['old cancelled', port.flying]),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
port.flyTo({ lon: 3, lat: 3, height: 1e6 }, { durationS: 1 });
|
||||||
|
assert.deepEqual(order, [['old cancelled', true]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function headlessPort() {
|
||||||
|
const { scene, camera } = headlessCamera();
|
||||||
|
scene.postUpdate = new Cesium.Event();
|
||||||
|
scene.requestRender = () => {};
|
||||||
|
const port = createCesiumCamera({
|
||||||
|
scene,
|
||||||
|
camera,
|
||||||
|
report: (error) => {
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
}).port;
|
||||||
|
return { scene, camera, port };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('commands announce themselves first; flights raise flightEnd once each', () => {
|
||||||
|
const { port } = headlessPort();
|
||||||
|
const seen = [];
|
||||||
|
const off = port.onCommand((c) => seen.push(c.kind));
|
||||||
|
port.on('flightEnd', () => seen.push('flightEnd'));
|
||||||
|
const target = Cesium.Cartesian3.fromDegrees(0, 0, 0);
|
||||||
|
port.setView({ destination: { lon: 0, lat: 0, height: 1e6 } });
|
||||||
|
port.lookAt(target, { heading: 0, pitch: -0.5, range: 1000 });
|
||||||
|
port.release();
|
||||||
|
port.flyTo({ lon: 1, lat: 1, height: 1e6 }, { durationS: 1 });
|
||||||
|
port.flyToBounds({ center: target, radius: 10 }, { durationS: 1 });
|
||||||
|
port.cancelFlight();
|
||||||
|
off();
|
||||||
|
port.setView({ destination: { lon: 0, lat: 0, height: 1e6 } });
|
||||||
|
assert.deepEqual(seen, [
|
||||||
|
'setView',
|
||||||
|
'lookAt',
|
||||||
|
'release',
|
||||||
|
'flyTo',
|
||||||
|
'flyToBounds',
|
||||||
|
'flightEnd',
|
||||||
|
'cancelFlight',
|
||||||
|
'flightEnd',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endFrame world ends a flight out of a lookAt frame', async () => {
|
||||||
|
await withVirtualClock(async (clock) => {
|
||||||
|
const { scene, port } = headlessPort();
|
||||||
|
const target = Cesium.Cartesian3.fromDegrees(0, 0, 0);
|
||||||
|
port.lookAt(target, { heading: 0, pitch: -0.5, range: 1000 });
|
||||||
|
assert.equal(port.pose().frameIsIdentity, false);
|
||||||
|
const flight = port.flyTo(
|
||||||
|
{ lon: 1, lat: 1, height: 5000 },
|
||||||
|
{ durationS: 0.2, endFrame: 'world' },
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 30 && port.flying; i++) {
|
||||||
|
clock.now += FRAME_MS;
|
||||||
|
scene.tweens.update();
|
||||||
|
}
|
||||||
|
assert.equal(await flight.done, 'complete');
|
||||||
|
assert.equal(port.pose().frameIsIdentity, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a flight Cesium does not start ends cancelled rather than never', async () => {
|
||||||
|
const { camera, port } = headlessPort();
|
||||||
|
// Mid scene-mode morph Cesium returns without flying or calling back.
|
||||||
|
camera._mode = Cesium.SceneMode.MORPHING;
|
||||||
|
const flight = port.flyTo({ lon: 1, lat: 1, height: 1e6 }, { durationS: 1 });
|
||||||
|
assert.equal(await flight.done, 'cancelled');
|
||||||
|
assert.equal(port.flying, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* @module render/cesium/common
|
||||||
|
* @description What the Cesium ports share: the contract's listener rules
|
||||||
|
* and its positions as Cesium's.
|
||||||
|
*/
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One subscription list under the contract's listener rules: subscription
|
||||||
|
* order, one removed during delivery does not run, one added during
|
||||||
|
* delivery waits for the next event, and a throw goes to `onThrow` while
|
||||||
|
* the rest still run. Each subscription is its own entry, so a callback
|
||||||
|
* subscribed twice runs twice.
|
||||||
|
* @param {(error: unknown) => void} onThrow
|
||||||
|
*/
|
||||||
|
export function listenerList(onThrow) {
|
||||||
|
const entries = [];
|
||||||
|
return {
|
||||||
|
get size() {
|
||||||
|
return entries.length;
|
||||||
|
},
|
||||||
|
on(cb) {
|
||||||
|
const entry = { cb, live: true };
|
||||||
|
entries.push(entry);
|
||||||
|
return () => {
|
||||||
|
entry.live = false;
|
||||||
|
const i = entries.indexOf(entry);
|
||||||
|
if (i >= 0) entries.splice(i, 1);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
fire(...args) {
|
||||||
|
for (const entry of entries.slice()) {
|
||||||
|
if (!entry.live) continue;
|
||||||
|
try {
|
||||||
|
entry.cb(...args);
|
||||||
|
} catch (error) {
|
||||||
|
onThrow(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
for (const entry of entries) entry.live = false;
|
||||||
|
entries.length = 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A contract position as ECEF. */
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
* `CESIUM_CHECKS` lists the checks of the ports the Cesium renderer has.
|
* `CESIUM_CHECKS` lists the checks of the ports the Cesium renderer has.
|
||||||
* Each migration adds the checks of the members it brings, so the suite
|
* Each migration adds the checks of the members it brings, so the suite
|
||||||
* shows how far the renderer is through the contract; every other check is
|
* shows how far the renderer is through the contract; every other check is
|
||||||
* left out of the run rather than failed.
|
* left out of the run rather than failed. Camera flights are checked in
|
||||||
|
* Node instead (`cameraPort.test.mjs`): a page's frames come on the real
|
||||||
|
* clock at the display's rate, where the reference traces step 1/60 s.
|
||||||
*/
|
*/
|
||||||
import { DEFAULT_TOLERANCE } from '../conformance/index.js';
|
import { DEFAULT_TOLERANCE } from '../conformance/index.js';
|
||||||
import { toEcef } from '../stub/math.js';
|
import { toEcef } from '../stub/math.js';
|
||||||
@@ -14,6 +16,11 @@ import { createCesiumSceneRenderer, pointerFeedOf } from './sceneRenderer.js';
|
|||||||
|
|
||||||
/** @type {ReadonlySet<string>} */
|
/** @type {ReadonlySet<string>} */
|
||||||
export const CESIUM_CHECKS = new Set([
|
export const CESIUM_CHECKS = new Set([
|
||||||
|
'camera.trace.setView angles',
|
||||||
|
'camera.trace.setView keeps the position when only orientation is given',
|
||||||
|
'camera.trace.lookAt sweep and release',
|
||||||
|
'camera.trace.flyTo with a duration of 0 completes inside the call',
|
||||||
|
'camera.trace.flyTo where the camera already is completes inside the call',
|
||||||
'camera.viewRectangle',
|
'camera.viewRectangle',
|
||||||
'draw.drillPick',
|
'draw.drillPick',
|
||||||
'draw.ids',
|
'draw.ids',
|
||||||
|
|||||||
+7
-122
@@ -13,7 +13,8 @@
|
|||||||
*/
|
*/
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { heightsFor } from '../../queries/heights.js';
|
import { heightsFor } from '../../queries/heights.js';
|
||||||
import { createCameraEvents } from '../camera/events.js';
|
import { createCesiumCamera } from './camera.js';
|
||||||
|
import { listenerList, toCartesian } from './common.js';
|
||||||
import {
|
import {
|
||||||
atEndOfTick,
|
atEndOfTick,
|
||||||
billboardLoadError,
|
billboardLoadError,
|
||||||
@@ -22,6 +23,8 @@ import {
|
|||||||
renderPending,
|
renderPending,
|
||||||
} from './internals.js';
|
} from './internals.js';
|
||||||
|
|
||||||
|
export { listenerList, toCartesian };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a listener's throw goes with no `error` listener: the platform's
|
* 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
|
* `reportError` (or a rethrow on a microtask), as a browser reports an event
|
||||||
@@ -37,46 +40,6 @@ function uncaught(error) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* One subscription list under the contract's listener rules: subscription
|
|
||||||
* order, one removed during delivery does not run, one added during
|
|
||||||
* delivery waits for the next event, and a throw goes to `onThrow` while
|
|
||||||
* the rest still run. Each subscription is its own entry, so a callback
|
|
||||||
* subscribed twice runs twice.
|
|
||||||
* @param {(error: unknown) => void} onThrow
|
|
||||||
*/
|
|
||||||
export function listenerList(onThrow) {
|
|
||||||
const entries = [];
|
|
||||||
return {
|
|
||||||
get size() {
|
|
||||||
return entries.length;
|
|
||||||
},
|
|
||||||
on(cb) {
|
|
||||||
const entry = { cb, live: true };
|
|
||||||
entries.push(entry);
|
|
||||||
return () => {
|
|
||||||
entry.live = false;
|
|
||||||
const i = entries.indexOf(entry);
|
|
||||||
if (i >= 0) entries.splice(i, 1);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
fire(...args) {
|
|
||||||
for (const entry of entries.slice()) {
|
|
||||||
if (!entry.live) continue;
|
|
||||||
try {
|
|
||||||
entry.cb(...args);
|
|
||||||
} catch (error) {
|
|
||||||
onThrow(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
for (const entry of entries) entry.live = false;
|
|
||||||
entries.length = 0;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The contract's colour as Cesium's. */
|
/** The contract's colour as Cesium's. */
|
||||||
export function toColor(color, alpha = 1) {
|
export function toColor(color, alpha = 1) {
|
||||||
if (color === undefined || color === null)
|
if (color === undefined || color === null)
|
||||||
@@ -97,12 +60,6 @@ export function toImage(image) {
|
|||||||
throw new Error('the Cesium renderer takes URL, SVG and canvas images only');
|
throw new Error('the Cesium renderer takes URL, SVG and canvas images only');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A contract position as ECEF. */
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ANCHORS = {
|
const ANCHORS = {
|
||||||
center: Cesium.VerticalOrigin.CENTER,
|
center: Cesium.VerticalOrigin.CENTER,
|
||||||
bottom: Cesium.VerticalOrigin.BOTTOM,
|
bottom: Cesium.VerticalOrigin.BOTTOM,
|
||||||
@@ -789,80 +746,8 @@ export function createCesiumPorts(widget, { capabilities }) {
|
|||||||
models: (spec) => makeSet('models', { ...spec }, buildModels),
|
models: (spec) => makeSet('models', { ...spec }, buildModels),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Camera events: Cesium's own moveStart and moveEnd (500 ms after the
|
const cameraPart = createCesiumCamera({ scene, camera, report });
|
||||||
// last change, never while following), and `changed` measured per
|
const cameraPort = cameraPart.port;
|
||||||
// listener on every tick, where Cesium measures its single threshold.
|
|
||||||
const cameraEvents = createCameraEvents({
|
|
||||||
pose: () => cameraPort.pose(),
|
|
||||||
report,
|
|
||||||
});
|
|
||||||
removers.push(
|
|
||||||
scene.postUpdate.addEventListener(() => {
|
|
||||||
if (!disposed) cameraEvents.stepChanged();
|
|
||||||
}),
|
|
||||||
camera.moveStart.addEventListener(() => cameraEvents.emit('moveStart')),
|
|
||||||
camera.moveEnd.addEventListener(() => cameraEvents.emit('moveEnd')),
|
|
||||||
);
|
|
||||||
|
|
||||||
const cameraPort = {
|
|
||||||
on: (event, cb, options) => cameraEvents.on(event, cb, options),
|
|
||||||
setView(spec) {
|
|
||||||
const d = spec.destination;
|
|
||||||
if (d && 'rect' in d)
|
|
||||||
throw new Error('the Cesium renderer takes position destinations only');
|
|
||||||
const o = spec.orientation ?? {};
|
|
||||||
camera.setView({
|
|
||||||
...(d ? { destination: toCartesian(d) } : {}),
|
|
||||||
orientation: {
|
|
||||||
heading: o.heading ?? 0,
|
|
||||||
pitch: o.pitch ?? -Math.PI / 2,
|
|
||||||
roll: o.roll ?? 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
scene.requestRender();
|
|
||||||
},
|
|
||||||
viewRectangle() {
|
|
||||||
const rect = camera.computeViewRectangle(Cesium.Ellipsoid.WGS84);
|
|
||||||
if (!rect) return null;
|
|
||||||
const deg = Cesium.Math.toDegrees;
|
|
||||||
return {
|
|
||||||
west: deg(rect.west),
|
|
||||||
south: deg(rect.south),
|
|
||||||
east: deg(rect.east),
|
|
||||||
north: deg(rect.north),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
pose() {
|
|
||||||
const v = (c) => ({ x: c.x, y: c.y, z: c.z });
|
|
||||||
// Cesium has no cartographic position for an eye at the earth's
|
|
||||||
// centre; the pose then says so with NaN.
|
|
||||||
const carto = camera.positionCartographic ?? {
|
|
||||||
longitude: NaN,
|
|
||||||
latitude: NaN,
|
|
||||||
height: NaN,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
|
||||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
|
||||||
position: v(camera.positionWC),
|
|
||||||
direction: v(camera.directionWC),
|
|
||||||
up: v(camera.upWC),
|
|
||||||
right: v(camera.rightWC),
|
|
||||||
heading: camera.heading,
|
|
||||||
pitch: camera.pitch,
|
|
||||||
roll: camera.roll,
|
|
||||||
height: carto.height,
|
|
||||||
fovy: camera.frustum.fovy,
|
|
||||||
aspectRatio: camera.frustum.aspectRatio,
|
|
||||||
near: camera.frustum.near,
|
|
||||||
far: camera.frustum.far,
|
|
||||||
frameIsIdentity: Cesium.Matrix4.equals(
|
|
||||||
camera.transform,
|
|
||||||
Cesium.Matrix4.IDENTITY,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Query -----------------------------------------------------------------
|
// --- Query -----------------------------------------------------------------
|
||||||
|
|
||||||
@@ -1220,7 +1105,7 @@ export function createCesiumPorts(widget, { capabilities }) {
|
|||||||
// A listener that disposes the renderer stops the rest of its event.
|
// A listener that disposes the renderer stops the rest of its event.
|
||||||
for (const list of Object.values(rendererListeners)) list.clear();
|
for (const list of Object.values(rendererListeners)) list.clear();
|
||||||
for (const list of Object.values(frameListeners)) list.clear();
|
for (const list of Object.values(frameListeners)) list.clear();
|
||||||
cameraEvents.clear();
|
cameraPart.dispose();
|
||||||
for (const done of [...pendingRenders]) done();
|
for (const done of [...pendingRenders]) done();
|
||||||
// Late model loads find no host and destroy themselves.
|
// Late model loads find no host and destroy themselves.
|
||||||
for (const record of sets.values()) record.host = null;
|
for (const record of sets.values()) record.host = null;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
"src/app/embed.js",
|
"src/app/embed.js",
|
||||||
"src/app/scene.js",
|
"src/app/scene.js",
|
||||||
"src/app/viewer.js",
|
"src/app/viewer.js",
|
||||||
"src/camera.js",
|
|
||||||
"src/cameraGroundGuard.js",
|
"src/cameraGroundGuard.js",
|
||||||
"src/cameraVerbs.js",
|
"src/cameraVerbs.js",
|
||||||
"src/celestialRing.js",
|
"src/celestialRing.js",
|
||||||
@@ -218,7 +217,6 @@
|
|||||||
"src/scenes/director.js",
|
"src/scenes/director.js",
|
||||||
"src/services/groundSnap.js",
|
"src/services/groundSnap.js",
|
||||||
"src/services/meshFloorSampler.js",
|
"src/services/meshFloorSampler.js",
|
||||||
"src/sharelink.js",
|
|
||||||
"src/sky/starrySky.js",
|
"src/sky/starrySky.js",
|
||||||
"src/ui/applicationShell.js",
|
"src/ui/applicationShell.js",
|
||||||
"src/ui/cameraOrientationControls.js",
|
"src/ui/cameraOrientationControls.js",
|
||||||
|
|||||||
@@ -27,3 +27,15 @@ export function bindRenderer(host, renderer) {
|
|||||||
* @returns {object|null} The renderer bound to `host`, or null.
|
* @returns {object|null} The renderer bound to `host`, or null.
|
||||||
*/
|
*/
|
||||||
export const rendererOf = (host) => (host && renderers.get(host)) || null;
|
export const rendererOf = (host) => (host && renderers.get(host)) || null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The camera port of the renderer bound to `host`.
|
||||||
|
* @param {object} host
|
||||||
|
* @returns {import('./contract/camera').CameraPort}
|
||||||
|
* @throws When no renderer is bound to `host`.
|
||||||
|
*/
|
||||||
|
export function cameraOf(host) {
|
||||||
|
const camera = rendererOf(host)?.camera;
|
||||||
|
if (!camera) throw new Error('This scene has no renderer camera');
|
||||||
|
return camera;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { bindRenderer, rendererOf } from './rendererOf.js';
|
import { bindRenderer, cameraOf, rendererOf } from './rendererOf.js';
|
||||||
|
|
||||||
test('a scene finds its renderer until it is unbound', () => {
|
test('a scene finds its renderer until it is unbound', () => {
|
||||||
const scene = {};
|
const scene = {};
|
||||||
@@ -23,3 +23,12 @@ test('a stale unbind leaves a later binding alone', () => {
|
|||||||
unbindFirst();
|
unbindFirst();
|
||||||
assert.equal(rendererOf(scene), second);
|
assert.equal(rendererOf(scene), second);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cameraOf names the missing renderer instead of returning nothing', () => {
|
||||||
|
const scene = {};
|
||||||
|
assert.throws(() => cameraOf(scene), /no renderer camera/);
|
||||||
|
const camera = { pose() {} };
|
||||||
|
const unbind = bindRenderer(scene, { camera });
|
||||||
|
assert.equal(cameraOf(scene), camera);
|
||||||
|
unbind();
|
||||||
|
});
|
||||||
@@ -456,12 +456,13 @@ test('share-link restore forces a final stationary render for Google 3D Tiles',
|
|||||||
|
|
||||||
assert.ok(calls.flyTo, 'restore must start a camera flight');
|
assert.ok(calls.flyTo, 'restore must start a camera flight');
|
||||||
assert.equal(typeof calls.flyTo.complete, 'function');
|
assert.equal(typeof calls.flyTo.complete, 'function');
|
||||||
|
const rendersInFlight = calls.renders;
|
||||||
calls.flyTo.complete();
|
calls.flyTo.complete();
|
||||||
assert.deepEqual(calls.setView, {
|
assert.deepEqual(calls.setView, {
|
||||||
destination: calls.flyTo.destination,
|
destination: calls.flyTo.destination,
|
||||||
orientation: calls.flyTo.orientation,
|
orientation: calls.flyTo.orientation,
|
||||||
});
|
});
|
||||||
assert.equal(calls.renders, 1);
|
assert.equal(calls.renders, rendersInFlight + 1, 'one render at rest');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('newer navigation suppresses delayed share camera while non-camera state still restores', async () => {
|
test('newer navigation suppresses delayed share camera while non-camera state still restores', async () => {
|
||||||
|
|||||||
+9
-13
@@ -1,5 +1,4 @@
|
|||||||
import * as Cesium from 'cesium';
|
import { toDegrees, toRadians } from './geo/index.js';
|
||||||
import { fromDegrees, toDegrees, toRadians } from './geo/index.js';
|
|
||||||
import { rendererOf } from './render/rendererOf.js';
|
import { rendererOf } from './render/rendererOf.js';
|
||||||
import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from './bloom.js';
|
import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from './bloom.js';
|
||||||
import {
|
import {
|
||||||
@@ -333,9 +332,7 @@ export class ShareLinkManager {
|
|||||||
if (this._destroyed || !state)
|
if (this._destroyed || !state)
|
||||||
return { succeeded: false, reason: 'unavailable' };
|
return { succeeded: false, reason: 'unavailable' };
|
||||||
const view = {
|
const view = {
|
||||||
destination: Cesium.Cartesian3.clone(
|
destination: { lon: state.lon, lat: state.lat, height: state.alt },
|
||||||
fromDegrees(state.lon, state.lat, state.alt),
|
|
||||||
),
|
|
||||||
orientation: {
|
orientation: {
|
||||||
heading: toRadians(state.heading),
|
heading: toRadians(state.heading),
|
||||||
pitch: toRadians(state.pitch),
|
pitch: toRadians(state.pitch),
|
||||||
@@ -364,11 +361,11 @@ export class ShareLinkManager {
|
|||||||
};
|
};
|
||||||
// Re-apply the final pose only while this share restoration still owns
|
// Re-apply the final pose only while this share restoration still owns
|
||||||
// navigation. A later user or voice command wins over delayed restore.
|
// navigation. A later user or voice command wins over delayed restore.
|
||||||
this.viewer.camera.flyTo({
|
this._camera.flyTo(view.destination, {
|
||||||
...view,
|
orientation: view.orientation,
|
||||||
duration: 3.0,
|
durationS: 3.0,
|
||||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
easing: 'cubicInOut',
|
||||||
complete: () => {
|
onComplete: () => {
|
||||||
if (
|
if (
|
||||||
this._destroyed ||
|
this._destroyed ||
|
||||||
restoreGeneration !== this._restoreGeneration ||
|
restoreGeneration !== this._restoreGeneration ||
|
||||||
@@ -377,11 +374,10 @@ export class ShareLinkManager {
|
|||||||
releaseOwnedFlight('superseded');
|
releaseOwnedFlight('superseded');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.viewer.camera.setView(view);
|
this._camera.setView(view);
|
||||||
this.viewer.scene?.requestRender?.();
|
|
||||||
releaseOwnedFlight('applied');
|
releaseOwnedFlight('applied');
|
||||||
},
|
},
|
||||||
cancel: () => releaseOwnedFlight('cancelled'),
|
onCancel: () => releaseOwnedFlight('cancelled'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { createCameraEvents } from '../render/camera/events.js';
|
import { createCameraEvents } from '../render/camera/events.js';
|
||||||
import { toDegrees } from '../geo/index.js';
|
import { toDegrees } from '../geo/index.js';
|
||||||
import { bindRenderer, rendererOf } from '../render/rendererOf.js';
|
import { bindRenderer, rendererOf } from '../render/rendererOf.js';
|
||||||
|
import { createCesiumCamera } from '../render/cesium/camera.js';
|
||||||
|
|
||||||
const DEFAULT_POSE = Object.freeze({
|
const DEFAULT_POSE = Object.freeze({
|
||||||
lon: 0,
|
lon: 0,
|
||||||
@@ -64,38 +65,94 @@ export function fakeCameraPort({ pose = {}, viewRectangle = null } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bind to a fake viewer a camera port that reads its Cesium-shaped fake
|
* Bind to a fake viewer the Cesium renderer's own camera port over its
|
||||||
* camera live (`positionCartographic`, `heading`, `pitch`, `roll`, and the
|
* Cesium-shaped fake camera, for tests whose code is half on the port: the
|
||||||
* `changed`/`moveStart`/`moveEnd` events it has), for tests whose code
|
* port's commands reach the fake camera as the renderer maps them
|
||||||
* still moves that camera directly. Returns the viewer.
|
* (`flyTo({destination: Cartesian3, ...})`), while poses are read leniently
|
||||||
|
* from what the fake has (`positionCartographic`, `heading`, `pitch`,
|
||||||
|
* `roll`) and events come from the fake's own `changed`, `moveStart` and
|
||||||
|
* `moveEnd`. Binding a viewer twice keeps the first. Returns the viewer.
|
||||||
* @template {{camera: object}} V
|
* @template {{camera: object}} V
|
||||||
* @param {V} viewer
|
* @param {V} viewer
|
||||||
* @returns {V}
|
* @returns {V}
|
||||||
*/
|
*/
|
||||||
export function bindFakeCesiumCamera(viewer) {
|
export function bindFakeCesiumCamera(viewer) {
|
||||||
if (rendererOf(viewer)) return viewer;
|
if (rendererOf(viewer)) return viewer;
|
||||||
const camera = viewer.camera;
|
const fake = viewer.camera;
|
||||||
const port = {
|
const noEvent = { addEventListener: () => () => {} };
|
||||||
pose() {
|
// A fake that does not keep Cesium's current flight gets one kept for it,
|
||||||
const c = camera.positionCartographic;
|
// as Cesium does: from a flight's start until it completes or cancels.
|
||||||
return {
|
let current = null;
|
||||||
...DEFAULT_POSE,
|
const flown = (fly) =>
|
||||||
lon: c ? toDegrees(c.longitude) : NaN,
|
function (...args) {
|
||||||
lat: c ? toDegrees(c.latitude) : NaN,
|
const options = args.at(-1) ?? {};
|
||||||
height: c ? c.height : NaN,
|
const flight = {};
|
||||||
heading: camera.heading ?? 0,
|
current = flight;
|
||||||
pitch: camera.pitch ?? -Math.PI / 2,
|
const settle = (cb) => () => {
|
||||||
roll: camera.roll ?? 0,
|
if (current === flight) current = null;
|
||||||
|
cb?.();
|
||||||
};
|
};
|
||||||
|
args[args.length - 1] = {
|
||||||
|
...options,
|
||||||
|
complete: settle(options.complete),
|
||||||
|
cancel: settle(options.cancel),
|
||||||
|
};
|
||||||
|
return fly.apply(this, args);
|
||||||
|
};
|
||||||
|
const keepsFlight = '_currentFlight' in fake;
|
||||||
|
const camera = new Proxy(fake, {
|
||||||
|
get(target, key, receiver) {
|
||||||
|
if ((key === 'moveStart' || key === 'moveEnd') && !target[key])
|
||||||
|
return noEvent;
|
||||||
|
if (!keepsFlight) {
|
||||||
|
if (key === '_currentFlight') return current ?? undefined;
|
||||||
|
if ((key === 'flyTo' || key === 'flyToBoundingSphere') && target[key])
|
||||||
|
return flown(target[key]);
|
||||||
|
if (key === 'cancelFlight' && target[key])
|
||||||
|
return function (...args) {
|
||||||
|
current = null;
|
||||||
|
return target[key].apply(this, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Reflect.get(target, key, receiver);
|
||||||
},
|
},
|
||||||
viewRectangle: () => null,
|
});
|
||||||
on(event, cb) {
|
const scene = {
|
||||||
const source = camera[event];
|
postUpdate: noEvent,
|
||||||
const off = source?.addEventListener?.(cb);
|
requestRender: () => viewer.scene?.requestRender?.(),
|
||||||
if (typeof off === 'function') return off;
|
|
||||||
return () => source?.removeEventListener?.(cb);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
const real = createCesiumCamera({
|
||||||
|
scene,
|
||||||
|
camera,
|
||||||
|
report: (error) => {
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
}).port;
|
||||||
|
const port = Object.create(real, {
|
||||||
|
pose: {
|
||||||
|
value() {
|
||||||
|
const c = fake.positionCartographic;
|
||||||
|
return {
|
||||||
|
...DEFAULT_POSE,
|
||||||
|
lon: c ? toDegrees(c.longitude) : NaN,
|
||||||
|
lat: c ? toDegrees(c.latitude) : NaN,
|
||||||
|
height: c ? c.height : NaN,
|
||||||
|
heading: fake.heading ?? 0,
|
||||||
|
pitch: fake.pitch ?? -Math.PI / 2,
|
||||||
|
roll: fake.roll ?? 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
viewRectangle: { value: () => null },
|
||||||
|
on: {
|
||||||
|
value(event, cb) {
|
||||||
|
const source = fake[event];
|
||||||
|
const off = source?.addEventListener?.(cb);
|
||||||
|
if (typeof off === 'function') return off;
|
||||||
|
return () => source?.removeEventListener?.(cb);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
bindRenderer(viewer, { camera: port });
|
bindRenderer(viewer, { camera: port });
|
||||||
return viewer;
|
return viewer;
|
||||||
}
|
}
|
||||||
+13
-14
@@ -30,6 +30,7 @@ import {
|
|||||||
} from './cyberSonarControls.js';
|
} from './cyberSonarControls.js';
|
||||||
|
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { cameraOf } from '../render/rendererOf.js';
|
||||||
|
|
||||||
import { aircraftTrackingTarget } from '../cockpitTracking.js';
|
import { aircraftTrackingTarget } from '../cockpitTracking.js';
|
||||||
|
|
||||||
@@ -441,7 +442,7 @@ export class StyleManager extends ShellFacade {
|
|||||||
},
|
},
|
||||||
isNavigationCurrent: (generation) =>
|
isNavigationCurrent: (generation) =>
|
||||||
generation === this._navigationGeneration,
|
generation === this._navigationGeneration,
|
||||||
cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(),
|
cancelOwnedNavigation: () => cameraOf(this.viewer).cancelFlight(),
|
||||||
// A host may open a share code without a `/share/<code>` address.
|
// A host may open a share code without a `/share/<code>` address.
|
||||||
initialShareCode,
|
initialShareCode,
|
||||||
});
|
});
|
||||||
@@ -1452,20 +1453,18 @@ export class StyleManager extends ShellFacade {
|
|||||||
*/
|
*/
|
||||||
applyCameraState(cameraState, duration = 2.8) {
|
applyCameraState(cameraState, duration = 2.8) {
|
||||||
if (!cameraState) return;
|
if (!cameraState) return;
|
||||||
this.viewer.camera.flyTo({
|
cameraOf(this.viewer).flyTo(
|
||||||
destination: Cesium.Cartesian3.fromDegrees(
|
{ lon: cameraState.lon, lat: cameraState.lat, height: cameraState.alt },
|
||||||
cameraState.lon,
|
{
|
||||||
cameraState.lat,
|
orientation: {
|
||||||
cameraState.alt,
|
heading: Cesium.Math.toRadians(cameraState.heading || 0),
|
||||||
),
|
pitch: Cesium.Math.toRadians(cameraState.pitch || -35),
|
||||||
orientation: {
|
roll: Cesium.Math.toRadians(cameraState.roll || 0),
|
||||||
heading: Cesium.Math.toRadians(cameraState.heading || 0),
|
},
|
||||||
pitch: Cesium.Math.toRadians(cameraState.pitch || -35),
|
durationS: Math.max(0.2, duration || 0),
|
||||||
roll: Cesium.Math.toRadians(cameraState.roll || 0),
|
easing: 'cubicInOut',
|
||||||
},
|
},
|
||||||
duration: Math.max(0.2, duration || 0),
|
);
|
||||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
|
|||||||
import { presentResult } from './resultDisplay.js';
|
import { presentResult } from './resultDisplay.js';
|
||||||
import { attachVoiceResult } from './speech.js';
|
import { attachVoiceResult } from './speech.js';
|
||||||
import { withEntityRecord } from '../data/entityRecords.js';
|
import { withEntityRecord } from '../data/entityRecords.js';
|
||||||
|
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||||
|
|
||||||
test('every live basemap is reachable by its own id — no enum value without a voice alias', () => {
|
test('every live basemap is reachable by its own id — no enum value without a voice alias', () => {
|
||||||
// B1 regression: a stack added to MAP_STACKS (and the set_map_stack enum)
|
// B1 regression: a stack added to MAP_STACKS (and the set_map_stack enum)
|
||||||
@@ -131,6 +132,7 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) {
|
|||||||
lookAtTransform() {},
|
lookAtTransform() {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
bindFakeCesiumCamera(viewer);
|
||||||
const styleManager = {
|
const styleManager = {
|
||||||
runImmediateNavigation(noun, navigate, releaseOptions = undefined) {
|
runImmediateNavigation(noun, navigate, releaseOptions = undefined) {
|
||||||
return runExplicitNavigation({
|
return runExplicitNavigation({
|
||||||
@@ -3288,7 +3290,9 @@ test('voice Radio: the keyless path applies no country filter the keyed path wou
|
|||||||
});
|
});
|
||||||
|
|
||||||
const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ });
|
const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ });
|
||||||
function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); }
|
/** Fake viewers with a fake Cesium camera reach the renderer's camera port too. */
|
||||||
|
const withCameraPort = (viewer) => (viewer?.camera ? bindFakeCesiumCamera(viewer) : viewer);
|
||||||
|
function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options, viewer: withCameraPort(options?.viewer) }); }
|
||||||
function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); }
|
function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); }
|
||||||
|
|
||||||
test('ALPR common names toggle only the registered camera layer through the normal voice action', async () => {
|
test('ALPR common names toggle only the registered camera layer through the normal voice action', async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user