mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 14:52:19 +00:00
Stop Navara's memory growth on engine switches, hide polygon zones reliably, and drop the Cesium camera rig
- Navara 0.2.1 never frees pick batch ids, and the mover banks rebuilt their instanced meshes on each attach, leaking about 260 KiB of WebAssembly memory per switch. A per-view pool hands released ids back out; the long soak now asserts native growth of at most 32 KiB per switch. - Navara 0.2.1 sometimes drops the first show:false update on a new vector layer, so a zone turned off right after a switch back stayed drawn. Polygon displays hide by deleting their layers and rebuild them when shown. - Camera verbs drive both engines through the globe contract; the Cesium viewer rig and the tests of states it can no longer reach are gone. - Flights and military tracking share src/layers/aircraft/tracking.js. - The geo import scenario reads movers only where the engine has them. - Add the Navara section to docs/CURRENT-STATE.md and a changelog fragment.
This commit is contained in:
1 parent
7abf74518f
commit
a6c64713ee
46 files changed
+2367
-2894
No files matched your search
+2
-2
@@ -153,7 +153,6 @@ async function createRenderer({
|
||||
creditContainer,
|
||||
});
|
||||
defer(() => {
|
||||
releaseCameraVerbs(viewer);
|
||||
uninstallRenderGovernor(viewer);
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
@@ -161,6 +160,8 @@ async function createRenderer({
|
||||
// once it loads.
|
||||
const tilesetRef = { current: null };
|
||||
const engine = createSceneEngine(viewer, { tilesetRef, defer });
|
||||
// As the Navara scene above: a released engine keeps no camera verb.
|
||||
defer(() => releaseCameraVerbs(engine));
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
registerDataCredits(engine, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
@@ -243,7 +244,6 @@ async function createRenderer({
|
||||
suspend() {
|
||||
uninstallRenderGovernor(viewer);
|
||||
engine.camera.cancelFlight();
|
||||
releaseCameraVerbs(viewer);
|
||||
releaseCameraVerbs(engine);
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
},
|
||||
|
||||
@@ -270,6 +270,7 @@ function attachViewerTools(
|
||||
});
|
||||
geoFileImport?.attachScene({
|
||||
viewer,
|
||||
engine,
|
||||
navigation: styleManager,
|
||||
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
moveCamera,
|
||||
} from './cameraVerbs.js';
|
||||
import { getRenderGovernorDiagnostics } from './renderGovernor.js';
|
||||
import { createCesiumEngine } from './globe/cesium/createCesiumEngine.js';
|
||||
import { createCameraViewer } from './globe/cesium/testing/cameraViewer.mjs';
|
||||
|
||||
const radians = Cesium.Math.toRadians;
|
||||
const clone = (value) => Cesium.Cartesian3.clone(value);
|
||||
@@ -94,7 +96,10 @@ function projection(camera, target = Cesium.Cartesian3.ZERO) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Real Cesium geometry with no renderer, GPU, provider or wall-clock waits. */
|
||||
/**
|
||||
* Real Cesium geometry with no renderer, GPU, provider or wall-clock waits:
|
||||
* the Cesium engine over a real Cesium camera, as the app drives it.
|
||||
*/
|
||||
function harness(
|
||||
t,
|
||||
{
|
||||
@@ -104,7 +109,6 @@ function harness(
|
||||
heading = 0,
|
||||
pitch = -90,
|
||||
roll = 0,
|
||||
ellipsoid = Cesium.Ellipsoid.WGS84,
|
||||
viewportWidth = 1280,
|
||||
viewportHeight = 800,
|
||||
} = {},
|
||||
@@ -112,127 +116,33 @@ function harness(
|
||||
const realPerformance = globalThis.performance;
|
||||
let nowMs = 0;
|
||||
globalThis.performance = { now: () => nowMs };
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: viewportWidth,
|
||||
clientHeight: viewportHeight,
|
||||
const { viewer, scene, camera, canvas, engine, frame } = createCameraViewer({
|
||||
width: viewportWidth,
|
||||
height: viewportHeight,
|
||||
});
|
||||
const scene = {
|
||||
canvas,
|
||||
drawingBufferWidth: viewportWidth,
|
||||
drawingBufferHeight: viewportHeight,
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
mapProjection: new Cesium.GeographicProjection(ellipsoid),
|
||||
globe: { ellipsoid },
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
tweens: [],
|
||||
requestRender() {},
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
scene.camera = camera;
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
longitudeDeg,
|
||||
latitude,
|
||||
height,
|
||||
ellipsoid,
|
||||
),
|
||||
destination: Cesium.Cartesian3.fromDegrees(longitudeDeg, latitude, height),
|
||||
orientation: {
|
||||
heading: radians(heading),
|
||||
pitch: radians(pitch),
|
||||
roll: radians(roll),
|
||||
},
|
||||
});
|
||||
const viewer = {
|
||||
camera,
|
||||
scene,
|
||||
trackedEntity: undefined,
|
||||
trackedEntityChanged: new Cesium.Event(),
|
||||
clock: { onTick: new Cesium.Event() },
|
||||
};
|
||||
const target = Cesium.Cartesian3.fromDegrees(
|
||||
longitudeDeg,
|
||||
latitude,
|
||||
0,
|
||||
ellipsoid,
|
||||
);
|
||||
initCameraVerbs(viewer, () => target);
|
||||
const target = Cesium.Cartesian3.fromDegrees(longitudeDeg, latitude, 0);
|
||||
initCameraVerbs(engine, () => target);
|
||||
t.after(() => {
|
||||
interruptCameraMotion('test-cleanup');
|
||||
globalThis.performance = realPerformance;
|
||||
});
|
||||
const tick = (seconds = 0.1) => {
|
||||
nowMs += seconds * 1000;
|
||||
viewer.clock.onTick.raiseEvent(viewer.clock);
|
||||
frame();
|
||||
};
|
||||
const finishOnce = () => {
|
||||
for (let i = 0; i < 20 && getActiveCameraMotion(); i += 1) tick();
|
||||
assert.equal(getActiveCameraMotion(), null, 'once motion must finish');
|
||||
};
|
||||
return { viewer, camera, canvas, target, tick, finishOnce };
|
||||
}
|
||||
|
||||
for (const [name, sceneMode] of [
|
||||
['2D', Cesium.SceneMode.SCENE2D],
|
||||
['Columbus', Cesium.SceneMode.COLUMBUS_VIEW],
|
||||
['morphing', Cesium.SceneMode.MORPHING],
|
||||
]) {
|
||||
test(`globe rotation refuses ${name} before changing the prior owner`, (t) => {
|
||||
const { viewer, camera } = harness(t);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' })
|
||||
.ok,
|
||||
true,
|
||||
);
|
||||
const priorId = activeCameraMotionId();
|
||||
const before = pose(camera);
|
||||
const transform = Cesium.Matrix4.clone(camera.transform);
|
||||
viewer.scene.mode = sceneMode;
|
||||
let handoffs = 0;
|
||||
let cancellations = 0;
|
||||
camera.cancelFlight = () => {
|
||||
cancellations += 1;
|
||||
};
|
||||
for (const mode of ['once', 'continuous']) {
|
||||
const result = moveCamera(
|
||||
{ motion: 'rotate_globe', direction: 'left', mode },
|
||||
() => {
|
||||
handoffs += 1;
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /3D/);
|
||||
}
|
||||
assert.equal(handoffs, 0);
|
||||
assert.equal(cancellations, 0);
|
||||
assert.equal(activeCameraMotionId(), priorId);
|
||||
assert.equal(hasRenderHold(), true);
|
||||
assert.ok(Cesium.Matrix4.equals(camera.transform, transform));
|
||||
samePose(camera, before);
|
||||
});
|
||||
|
||||
test(`active globe rotation yields to ${name} without changing the camera`, (t) => {
|
||||
const { viewer, camera, tick } = harness(t);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
viewer.scene.mode = sceneMode;
|
||||
const before = pose(camera);
|
||||
let rotations = 0;
|
||||
camera.rotate = () => {
|
||||
rotations += 1;
|
||||
};
|
||||
tick();
|
||||
assert.equal(rotations, 0);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
assert.equal(hasRenderHold(), false);
|
||||
samePose(camera, before);
|
||||
});
|
||||
return { viewer, scene, camera, canvas, engine, target, tick, finishOnce };
|
||||
}
|
||||
|
||||
test('globe rotation yields to tracking acquired and cleared before the next tick', (t) => {
|
||||
@@ -242,16 +152,14 @@ test('globe rotation yields to tracking acquired and cleared before the next tic
|
||||
true,
|
||||
);
|
||||
const before = pose(camera);
|
||||
const entity = { id: 'cockpit-aircraft', position: {} };
|
||||
viewer.trackedEntity = entity;
|
||||
viewer.trackedEntityChanged.raiseEvent(entity);
|
||||
// Assigning the tracked entity raises trackedEntityChanged (the follow).
|
||||
viewer.trackedEntity = { id: 'cockpit-aircraft', position: {} };
|
||||
assert.equal(
|
||||
getActiveCameraMotion(),
|
||||
null,
|
||||
'tracking acquisition must synchronously yield',
|
||||
);
|
||||
viewer.trackedEntity = undefined;
|
||||
viewer.trackedEntityChanged.raiseEvent(undefined);
|
||||
tick();
|
||||
samePose(camera, before);
|
||||
assert.equal(hasRenderHold(), false);
|
||||
@@ -264,8 +172,9 @@ test('tracking event keeps legacy yaw and an untracked globe rotation active', (
|
||||
true,
|
||||
);
|
||||
const yawId = activeCameraMotionId();
|
||||
viewer.trackedEntityChanged.raiseEvent({ id: 'aircraft' });
|
||||
viewer.trackedEntity = { id: 'aircraft' };
|
||||
assert.equal(activeCameraMotionId(), yawId);
|
||||
viewer.trackedEntity = undefined;
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
|
||||
true,
|
||||
@@ -275,24 +184,24 @@ test('tracking event keeps legacy yaw and an untracked globe rotation active', (
|
||||
assert.equal(activeCameraMotionId(), globeId);
|
||||
});
|
||||
|
||||
test('replaced viewer releases the old tracking listener without duplicate subscriptions', (t) => {
|
||||
test('a replaced engine keeps no listener and its tracking stays out of the new motion', (t) => {
|
||||
const first = harness(t);
|
||||
assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1);
|
||||
initCameraVerbs(first.viewer);
|
||||
assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1);
|
||||
initCameraVerbs(first.engine);
|
||||
const next = harness(t);
|
||||
assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 0);
|
||||
assert.equal(next.viewer.trackedEntityChanged.numberOfListeners, 1);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
// The motion's frame listener is on the engine it runs on, once.
|
||||
assert.equal(first.scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(next.scene.preRender.numberOfListeners, 1);
|
||||
const id = activeCameraMotionId();
|
||||
first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' });
|
||||
first.viewer.trackedEntity = { id: 'stale-aircraft' };
|
||||
assert.equal(activeCameraMotionId(), id);
|
||||
next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' });
|
||||
next.viewer.trackedEntity = { id: 'new-aircraft' };
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
assert.equal(hasRenderHold(), false);
|
||||
assert.equal(next.scene.preRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
for (const latitude of [0, 30, -60, 89.5]) {
|
||||
@@ -526,23 +435,22 @@ test('replacement motion rejects a stale globe owner cancellation', (t) => {
|
||||
assert.equal(hasRenderHold(), false);
|
||||
});
|
||||
|
||||
test('replacing the viewer retires globe motion and its old clock listener', (t) => {
|
||||
const { viewer, camera, tick } = harness(t);
|
||||
test('replacing the engine retires globe motion and its frame listener', (t) => {
|
||||
const { viewer, scene, camera, tick } = harness(t);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
tick();
|
||||
const before = pose(camera);
|
||||
const nextViewer = {
|
||||
...viewer,
|
||||
clock: { onTick: new Cesium.Event() },
|
||||
};
|
||||
initCameraVerbs(nextViewer);
|
||||
// Another engine over the same Cesium camera.
|
||||
const nextEngine = createCesiumEngine(viewer, { requestRender() {} });
|
||||
t.after(() => nextEngine.destroy());
|
||||
initCameraVerbs(nextEngine);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
assert.equal(hasRenderHold(), false);
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
tick();
|
||||
nextViewer.clock.onTick.raiseEvent(nextViewer.clock);
|
||||
samePose(camera, before);
|
||||
});
|
||||
|
||||
@@ -683,8 +591,8 @@ test('navigation callback never releases tracking for globe rotation', (t) => {
|
||||
samePose(camera, before);
|
||||
});
|
||||
test('direct globe request refuses Cockpit before navigation mutation', (t) => {
|
||||
const { viewer, camera } = harness(t);
|
||||
initCameraVerbs(viewer, null, { isCockpitActive: () => true });
|
||||
const { engine, camera } = harness(t);
|
||||
initCameraVerbs(engine, null, { isCockpitActive: () => true });
|
||||
const before = pose(camera);
|
||||
let calls = 0;
|
||||
assert.equal(
|
||||
@@ -866,7 +774,7 @@ for (const owner of ['tracking', 'cockpit', 'close-up'])
|
||||
test(`actual voice runner refuses ${owner} without releasing or repositioning`, async (t) => {
|
||||
const { createGevActionRunner } = await import('./voice/gevActions.js');
|
||||
const { runExplicitNavigation } = await import('./navigationPolicy.js');
|
||||
const { viewer, camera } = harness(t, {
|
||||
const { viewer, engine, camera } = harness(t, {
|
||||
height: owner === 'close-up' ? 5000 : GLOBE_VIEW.heightM,
|
||||
});
|
||||
const before = pose(camera);
|
||||
@@ -893,6 +801,7 @@ for (const owner of ['tracking', 'cockpit', 'close-up'])
|
||||
};
|
||||
const runner = createGevActionRunner({
|
||||
viewer,
|
||||
engine,
|
||||
styleManager,
|
||||
dataManager: {
|
||||
layers: new Map(),
|
||||
@@ -918,29 +827,6 @@ for (const owner of ['tracking', 'cockpit', 'close-up'])
|
||||
}
|
||||
});
|
||||
|
||||
for (const [label, ellipsoid] of [
|
||||
['nonfinite radii', { minimumRadius: 1, maximumRadius: Infinity }],
|
||||
['nonpositive radii', { minimumRadius: 0, maximumRadius: 1 }],
|
||||
['non-WGS84 radii', new Cesium.Ellipsoid(30_000_000, 30_000_000, 1_000_000)],
|
||||
])
|
||||
test(`globe rotation refuses ${label} before handoff`, (t) => {
|
||||
const { viewer, camera } = harness(t, { height: 100_000_000 });
|
||||
const before = pose(camera);
|
||||
viewer.scene.globe.ellipsoid = ellipsoid;
|
||||
let handedOff = false;
|
||||
const result = moveCamera({ motion: 'rotate_globe' }, () => {
|
||||
handedOff = true;
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(
|
||||
result.reason,
|
||||
label === 'non-WGS84 radii' ? 'unsupported-view' : 'unsafe-ellipsoid',
|
||||
);
|
||||
assert.equal(handedOff, false);
|
||||
assert.equal(hasRenderHold(), false);
|
||||
samePose(camera, before);
|
||||
});
|
||||
|
||||
test('an external radius change retires globe rotation without another write', (t) => {
|
||||
const { camera, tick } = harness(t);
|
||||
moveCamera({ motion: 'rotate_globe', mode: 'continuous' });
|
||||
@@ -952,16 +838,6 @@ test('an external radius change retires globe rotation without another write', (
|
||||
samePose(camera, changed);
|
||||
});
|
||||
|
||||
test('a non-WGS84 ellipsoid fails closed rather than borrowing the WGS84 silhouette', (t) => {
|
||||
const ellipsoid = new Cesium.Ellipsoid(10_000_000, 10_000_000, 8_000_000);
|
||||
const { camera } = harness(t, { ellipsoid, latitude: 60 });
|
||||
const before = pose(camera);
|
||||
const result = moveCamera({ motion: 'rotate_globe' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'unsupported-view');
|
||||
samePose(camera, before);
|
||||
});
|
||||
|
||||
test('untouched polar framing permits repeated direction and speed changes below preset altitude', (t) => {
|
||||
const { camera, finishOnce, tick } = harness(t, { latitude: 90 });
|
||||
const radius = Cesium.Cartesian3.magnitude(camera.positionWC);
|
||||
@@ -1064,12 +940,7 @@ test('wide viewport uses vertical fovy rather than horizontal fov', (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
for (const invalid of [
|
||||
'orthographic',
|
||||
'behind',
|
||||
'zero-css-width',
|
||||
'offset-frustum',
|
||||
])
|
||||
for (const invalid of ['orthographic', 'behind', 'zero-css-width'])
|
||||
test(`unsupported ${invalid} fails without a reset offer`, (t) => {
|
||||
const h = harness(t);
|
||||
if (invalid === 'orthographic')
|
||||
@@ -1079,7 +950,6 @@ for (const invalid of [
|
||||
});
|
||||
if (invalid === 'behind') h.camera.lookRight(Math.PI);
|
||||
if (invalid === 'zero-css-width') h.canvas.clientWidth = 0;
|
||||
if (invalid === 'offset-frustum') h.camera.frustum.xOffset = 0.1;
|
||||
const result = moveCamera({ motion: 'rotate_globe' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'unsupported-view');
|
||||
@@ -1203,22 +1073,18 @@ test('keyhole overlap after resize stops rotation before another pose write', (t
|
||||
samePose(h.camera, before);
|
||||
});
|
||||
|
||||
for (const clipped of ['near', 'far', 'entirely-beyond-far'])
|
||||
test(`depth-${clipped} globe is unsupported without a reset offer`, (t) => {
|
||||
const h = harness(t, { latitude: 0 });
|
||||
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||
const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC);
|
||||
if (clipped === 'near') h.camera.frustum.near = distance - radius + 100;
|
||||
if (clipped === 'far') h.camera.frustum.far = distance + radius - 100;
|
||||
if (clipped === 'entirely-beyond-far')
|
||||
h.camera.frustum.far = distance - 2 * radius;
|
||||
const before = pose(h.camera);
|
||||
const result = moveCamera({ motion: 'rotate_globe' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'unsupported-view');
|
||||
samePose(h.camera, before);
|
||||
assert.equal(hasRenderHold(), false);
|
||||
});
|
||||
test('a depth-near globe is unsupported without a reset offer', (t) => {
|
||||
const h = harness(t, { latitude: 0 });
|
||||
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||
const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC);
|
||||
h.camera.frustum.near = distance - radius + 100;
|
||||
const before = pose(h.camera);
|
||||
const result = moveCamera({ motion: 'rotate_globe' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'unsupported-view');
|
||||
samePose(h.camera, before);
|
||||
assert.equal(hasRenderHold(), false);
|
||||
});
|
||||
|
||||
test('fully depth-contained globe remains eligible at actual near/far limits', (t) => {
|
||||
const h = harness(t, { latitude: 0 });
|
||||
@@ -1231,11 +1097,9 @@ test('fully depth-contained globe remains eligible at actual near/far limits', (
|
||||
|
||||
for (const [near, far] of [
|
||||
[0, 1e9],
|
||||
[-1, 1e9],
|
||||
[NaN, 1e9],
|
||||
[1, Infinity],
|
||||
[10, 10],
|
||||
[10, 9],
|
||||
])
|
||||
test(`invalid depth limits ${near}/${far} are unsupported`, (t) => {
|
||||
const h = harness(t);
|
||||
|
||||
+42
-255
@@ -18,14 +18,9 @@
|
||||
* globe motion, opposite to the camera's travel around Earth.
|
||||
*/
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from './geo/index.js';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import {
|
||||
projectEarthDiscToViewport,
|
||||
earthDiscScreenRadius,
|
||||
getKeyholeGeometry,
|
||||
} from './celestialRing.js';
|
||||
import { earthDiscScreenRadius, getKeyholeGeometry } from './celestialRing.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
@@ -955,21 +950,17 @@ export function advanceRouteFlight(state, dt) {
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Camera rigs ────────────────────────────────────────────────────────────
|
||||
* A motion drives one camera through a rig, and remembers which one.
|
||||
/* ── Camera rig ─────────────────────────────────────────────────────────────
|
||||
* A motion drives one globe engine's camera through its rig, and remembers
|
||||
* which one.
|
||||
*
|
||||
* The VIEWER rig is a Cesium viewer's own camera, exactly as the verbs were
|
||||
* proven on it: exact basis writes (setView's heading/pitch conversion is
|
||||
* singular at nadir), the scene's tween queue, the frustum's depth planes and
|
||||
* the scene modes. Its clock and input listeners are installed once per viewer.
|
||||
*
|
||||
* The ENGINE rig speaks only the engine-neutral globe contract
|
||||
* The rig speaks only the engine-neutral globe contract
|
||||
* (src/globe/contract.js): camera.pose/height/setView/lookAt/release/
|
||||
* transform/flightActive/following/followChanged/cancelFlight, frames.preRender,
|
||||
* host.canvas/size, projector.worldToWindow, surface and render.request. The
|
||||
* same verbs therefore run on Navara, on the fake engine and on Cesium through
|
||||
* engineFromViewer. Its listeners exist only while one of its motions runs, so
|
||||
* an engine that is parked or destroyed holds nothing of ours.
|
||||
* same verbs therefore run on Navara, on Cesium (src/globe/cesium) and on the
|
||||
* fake engine. Its listeners exist only while one of its motions runs, so an
|
||||
* engine that is parked or destroyed holds nothing of ours.
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Manual camera input that reclaims control (the cancelFlight reflex). */
|
||||
@@ -1037,30 +1028,6 @@ function orientationVectors(position, heading, pitch, roll) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The keyhole admission's view of a Cesium camera (live or a probe clone). */
|
||||
function viewerKeyholeView(camera) {
|
||||
const frustum = camera.frustum;
|
||||
return {
|
||||
position: camera.positionWC,
|
||||
direction: camera.directionWC,
|
||||
right: camera.rightWC,
|
||||
up: camera.upWC,
|
||||
perspective: frustum instanceof Cesium.PerspectiveFrustum,
|
||||
// Read only once the depth planes passed: Cesium's frustum getters throw
|
||||
// on invalid planes.
|
||||
get fovy() {
|
||||
return frustum?.fovy;
|
||||
},
|
||||
get aspectRatio() {
|
||||
return frustum?.aspectRatio;
|
||||
},
|
||||
near: frustum?.near,
|
||||
far: frustum?.far,
|
||||
xOffset: frustum?.xOffset,
|
||||
yOffset: frustum?.yOffset,
|
||||
};
|
||||
}
|
||||
|
||||
/** The keyhole admission's view of a contract camera pose. */
|
||||
function engineKeyholeView(pose) {
|
||||
return {
|
||||
@@ -1083,134 +1050,6 @@ function engineKeyholeView(pose) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the horizon back.
|
||||
*
|
||||
* The route dolly is the only motion that rolls the camera, and Cesium keeps
|
||||
* whatever world-space up vector it was last handed — so releasing a flight
|
||||
* mid-bank leaves the user holding a tilted horizon until some later motion
|
||||
* happens to level it. Heading, pitch and position are preserved; only the
|
||||
* roll is taken out.
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* @returns {boolean} Whether a roll was actually removed.
|
||||
*/
|
||||
function levelViewerRoll(viewer) {
|
||||
try {
|
||||
const cam = viewer?.camera;
|
||||
if (!cam || typeof cam.setView !== 'function') return false;
|
||||
const roll = geo.math.negativePiToPi(cam.roll || 0);
|
||||
if (Math.abs(roll) < 1e-5) return false;
|
||||
cam.setView({
|
||||
orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false; // teardown race
|
||||
}
|
||||
}
|
||||
|
||||
/** A Cesium viewer's own camera (see the block comment above). */
|
||||
function createViewerRig(viewer) {
|
||||
const cam = () => viewer.camera;
|
||||
const ellipsoid = () =>
|
||||
viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
return {
|
||||
kind: 'viewer',
|
||||
owner: viewer,
|
||||
viewer,
|
||||
engine: null,
|
||||
position: () => cam().positionWC,
|
||||
pitch: () => cam().pitch,
|
||||
heightM: () => cam().positionCartographic.height,
|
||||
orbitPose: () => ({ position: cam().positionWC, heading: cam().heading }),
|
||||
// Camera flights are scene tweens, including ones started straight on
|
||||
// the Cesium camera rather than through the engine.
|
||||
flightBusy: () => viewer.scene.tweens.length > 0,
|
||||
tracking: () => Boolean(viewer.trackedEntity),
|
||||
trackedLabel() {
|
||||
const entity = viewer.trackedEntity;
|
||||
return entity ? entity.name || entity.id || 'the tracked target' : null;
|
||||
},
|
||||
is3D: () => viewer.scene.mode === Cesium.SceneMode.SCENE3D,
|
||||
unlocked: () =>
|
||||
Cesium.Matrix4.equals(cam().transform, Cesium.Matrix4.IDENTITY),
|
||||
lookAt: (target, hpr) => cam().lookAt(target, hpr),
|
||||
release: () => cam().lookAtTransform(Cesium.Matrix4.IDENTITY),
|
||||
move(direction, amount) {
|
||||
const c = cam();
|
||||
if (direction === 'left') c.moveLeft(amount);
|
||||
else if (direction === 'right') c.moveRight(amount);
|
||||
else if (direction === 'up') c.moveUp(amount);
|
||||
else c.moveDown(amount);
|
||||
},
|
||||
look(direction, angle) {
|
||||
const c = cam();
|
||||
if (direction === 'up') c.lookUp(angle);
|
||||
else if (direction === 'down') c.lookDown(angle);
|
||||
else if (direction === 'left') c.lookLeft(angle);
|
||||
else c.lookRight(angle);
|
||||
},
|
||||
rotate: (axis, angle) => cam().rotate(axis, angle),
|
||||
setFrame: (eye, direction, up) =>
|
||||
cam().setView({ destination: eye, orientation: { direction, up } }),
|
||||
levelRoll: () => levelViewerRoll(viewer),
|
||||
surface: () => viewer.scene,
|
||||
ellipsoid,
|
||||
supportedEllipsoid: () =>
|
||||
Cesium.Cartesian3.equals(ellipsoid().radii, Cesium.Ellipsoid.WGS84.radii),
|
||||
viewport: () => ({
|
||||
width: viewer.scene.canvas.clientWidth,
|
||||
height: viewer.scene.canvas.clientHeight,
|
||||
}),
|
||||
keyholeView: () => viewerKeyholeView(cam()),
|
||||
earthDiscProjects: (width, height) =>
|
||||
Boolean(projectEarthDiscToViewport(viewer, width, height)),
|
||||
resetView() {
|
||||
// The unchanged Globe View destination, on a clone: the live camera
|
||||
// never moves for the check.
|
||||
const camera = Cesium.Camera.clone(cam());
|
||||
const { longitude, latitude } = cam().positionCartographic;
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
longitude,
|
||||
latitude,
|
||||
GLOBE_VIEW.heightM,
|
||||
),
|
||||
orientation: {
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg),
|
||||
roll: 0,
|
||||
},
|
||||
endTransform: Cesium.Matrix4.IDENTITY,
|
||||
});
|
||||
return viewerKeyholeView(camera);
|
||||
},
|
||||
captureGlobePose() {
|
||||
const c = cam();
|
||||
return {
|
||||
position: Cesium.Cartesian3.clone(c.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(c.directionWC),
|
||||
up: Cesium.Cartesian3.normalize(c.upWC, new Cesium.Cartesian3()),
|
||||
right: Cesium.Cartesian3.clone(c.rightWC),
|
||||
};
|
||||
},
|
||||
takeGlobePose(pose) {
|
||||
// Convert an inherited ground-target frame without changing the world
|
||||
// pose. Rotation must pivot about Earth, never the previous local target.
|
||||
const c = cam();
|
||||
c.cancelFlight();
|
||||
c.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
// Handoff may level a route's bank. Keep the caller's exact world pose.
|
||||
// Copy the basis directly: setView's heading/pitch conversion is singular
|
||||
// at nadir and can change the captured orientation near the poles.
|
||||
Cesium.Cartesian3.clone(pose.position, c.position);
|
||||
Cesium.Cartesian3.clone(pose.direction, c.direction);
|
||||
Cesium.Cartesian3.clone(pose.up, c.up);
|
||||
Cesium.Cartesian3.clone(pose.right, c.right);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The globe contract's camera (see the block comment above). */
|
||||
function createEngineRig(engine) {
|
||||
const camera = engine.camera;
|
||||
@@ -1220,7 +1059,6 @@ function createEngineRig(engine) {
|
||||
return {
|
||||
kind: 'engine',
|
||||
owner: engine,
|
||||
viewer: null,
|
||||
engine,
|
||||
position: () => pose().position,
|
||||
pitch: () => pose().pitch,
|
||||
@@ -1230,7 +1068,9 @@ function createEngineRig(engine) {
|
||||
tracking: () => Boolean(camera.following()),
|
||||
trackedLabel() {
|
||||
const handle = camera.following();
|
||||
return handle ? handle.label?.id || 'the tracked target' : null;
|
||||
return handle
|
||||
? handle.name || handle.label?.id || 'the tracked target'
|
||||
: null;
|
||||
},
|
||||
// Both engines draw only the 3D globe.
|
||||
is3D: () => true,
|
||||
@@ -1292,6 +1132,9 @@ function createEngineRig(engine) {
|
||||
);
|
||||
},
|
||||
setFrame: (eye, direction, up) => place(eye, direction, up),
|
||||
// Put the horizon back: the route dolly is the only motion that rolls
|
||||
// the camera, and a bank released mid-flight would stay until some later
|
||||
// motion happened to level it. Heading, pitch and position are kept.
|
||||
levelRoll() {
|
||||
const p = pose();
|
||||
if (Math.abs(geo.math.negativePiToPi(p.roll)) <= 1e-5) return false;
|
||||
@@ -1390,17 +1233,13 @@ const ENGINE_RIG_MEMBERS = Object.freeze([
|
||||
|
||||
/**
|
||||
* Whether the camera verbs (move_camera, and fly_route over a route list) can
|
||||
* drive this target: a Cesium viewer, or a globe engine with every contract
|
||||
* member the engine rig reads. The voice gate asks this instead of "is there
|
||||
* a viewer".
|
||||
* @param {object|null|undefined} target A Cesium viewer or a globe engine.
|
||||
* drive this target: a globe engine with every contract member the rig
|
||||
* reads. The voice gate asks this instead of "is there a viewer".
|
||||
* @param {object|null|undefined} target A globe engine.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function cameraVerbsSupported(target) {
|
||||
if (!target) return false;
|
||||
if (!isGlobeEngine(target))
|
||||
return Boolean(target.camera && target.scene && target.clock?.onTick);
|
||||
if (target.isDestroyed?.()) return false;
|
||||
if (!isGlobeEngine(target) || target.isDestroyed?.()) return false;
|
||||
return ENGINE_RIG_MEMBERS.every(
|
||||
(path) =>
|
||||
typeof path
|
||||
@@ -1416,14 +1255,9 @@ export function cameraVerbsSupported(target) {
|
||||
|
||||
/** The rig new motions start on: the latest initCameraVerbs target. */
|
||||
let _rig = null;
|
||||
/** The viewer whose clock and input listeners are installed (viewer rig). */
|
||||
let _viewer = null;
|
||||
let _getTarget = null;
|
||||
let _active = null; // { kind, rig, direction, speed, mode, motionId, ...state }
|
||||
let _tickRemover = null;
|
||||
let _trackingRemover = null;
|
||||
let _inputRemovers = [];
|
||||
/** The engine listeners of the running engine-rig motion, or null. */
|
||||
/** The engine listeners of the running motion, or null. */
|
||||
let _engineBinding = null;
|
||||
|
||||
function unbindEngineMotion() {
|
||||
@@ -1451,8 +1285,9 @@ function bindEngineMotion(rig) {
|
||||
removers.push(() => canvas.removeEventListener(event, cancel));
|
||||
}
|
||||
}
|
||||
// As the viewer rig's trackedEntityChanged: a follow taking the camera
|
||||
// yields a globe rotation at once, before its next frame.
|
||||
// A follow taking the camera (Cockpit can acquire and clear one between
|
||||
// two frames) yields a globe rotation at once, before its next frame.
|
||||
// Other motions carry on.
|
||||
removers.push(
|
||||
engine.camera.followChanged.addEventListener((handle) => {
|
||||
if (handle && _active?.rig === rig && _active.kind === 'rotate_globe')
|
||||
@@ -1465,8 +1300,8 @@ function bindEngineMotion(rig) {
|
||||
if (_active?.rig === rig) engine.render.request('camera-verb');
|
||||
}),
|
||||
);
|
||||
// Its first frame takes the minimum step, as a viewer rig's first tick
|
||||
// does (a resumed flight must not jump across the time it was parked).
|
||||
// Its first frame takes the minimum step (a resumed flight must not jump
|
||||
// across the time it was parked).
|
||||
if (_active?.rig === rig) _active.lastMs = undefined;
|
||||
_engineBinding = {
|
||||
rig,
|
||||
@@ -1478,14 +1313,13 @@ function bindEngineMotion(rig) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Park the Directions route flight `id` (an engine-rig flight) for an engine
|
||||
* switch: its listeners go, its state is returned for resumeRouteFlight.
|
||||
* Park the Directions route flight `id` for an engine switch: its listeners
|
||||
* go, its state is returned for resumeRouteFlight.
|
||||
* @param {number} id The flight's motion id.
|
||||
* @returns {object|null} The parked state, or null when `id` is not running.
|
||||
*/
|
||||
export function suspendRouteFlight(id) {
|
||||
if (!id || _active?.motionId !== id || _active.rig?.kind !== 'engine')
|
||||
return null;
|
||||
if (!id || _active?.motionId !== id) return null;
|
||||
const state = _active;
|
||||
_active = null;
|
||||
unbindEngineMotion();
|
||||
@@ -1511,7 +1345,7 @@ let _motionSeq = 0;
|
||||
/**
|
||||
* Install a motion and stamp it with a fresh id. The id is what lets a caller
|
||||
* that started a flight stop THAT flight later without stopping whatever has
|
||||
* replaced it in the meantime. An engine-rig motion binds its listeners here.
|
||||
* replaced it in the meantime. The motion binds its engine listeners here.
|
||||
* @param {object} state Motion state (with its rig).
|
||||
* @returns {object} The installed state.
|
||||
*/
|
||||
@@ -1519,7 +1353,7 @@ function setActiveMotion(state) {
|
||||
_motionSeq += 1;
|
||||
state.motionId = _motionSeq;
|
||||
_active = state;
|
||||
if (state.rig?.kind === 'engine') bindEngineMotion(state.rig);
|
||||
bindEngineMotion(state.rig);
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1564,13 +1398,8 @@ export function interruptCameraMotion(reason = 'interrupt') {
|
||||
} catch {
|
||||
/* teardown race */
|
||||
}
|
||||
// An engine dolly only ever sets views; it never locks a transform.
|
||||
const engineRoute = motion.kind === 'route' && rig.kind === 'engine';
|
||||
if (
|
||||
!tracking &&
|
||||
!engineRoute &&
|
||||
(motion.kind === 'orbit' || motion.kind === 'route')
|
||||
) {
|
||||
// A route dolly only ever sets views; it never locks a transform.
|
||||
if (!tracking && motion.kind === 'orbit') {
|
||||
try {
|
||||
rig.release();
|
||||
} catch {
|
||||
@@ -1579,7 +1408,7 @@ export function interruptCameraMotion(reason = 'interrupt') {
|
||||
}
|
||||
// Instant, not eased: the user grabbed the camera, or the flight is over.
|
||||
// Either way the next thing they hold must be a level horizon.
|
||||
if (motion.kind === 'route' && (engineRoute || !tracking)) {
|
||||
if (motion.kind === 'route') {
|
||||
try {
|
||||
leveled = rig.levelRoll();
|
||||
} catch {
|
||||
@@ -1618,11 +1447,6 @@ export function getActiveCameraMotion() {
|
||||
return info;
|
||||
}
|
||||
|
||||
/** The viewer clock's tick: advance a viewer-rig motion. */
|
||||
function onTick() {
|
||||
if (_rig?.kind === 'viewer') tickMotion(_rig);
|
||||
}
|
||||
|
||||
/** Advance the running motion one frame, when it runs on `rig`. */
|
||||
function tickMotion(rig) {
|
||||
const m = _active;
|
||||
@@ -1894,35 +1718,20 @@ export function interruptGlobeRotation(reason = 'camera-intent') {
|
||||
return { wasActive: false, reason, leveled: false };
|
||||
}
|
||||
|
||||
function detachViewerListeners() {
|
||||
_tickRemover?.();
|
||||
_trackingRemover?.();
|
||||
for (const remove of _inputRemovers) remove();
|
||||
_inputRemovers = [];
|
||||
_tickRemover = _trackingRemover = _viewer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release what the verbs hold on a departing viewer or engine: a motion
|
||||
* running on it stops, and when it is the one new motions start on, its
|
||||
* listeners and getters go too.
|
||||
* @param {object} target The Cesium viewer or globe engine.
|
||||
* Release what the verbs hold on a departing engine: a motion running on it
|
||||
* stops, and when it is the one new motions start on, its getters go too.
|
||||
* @param {object} target The globe engine.
|
||||
*/
|
||||
export function releaseCameraVerbs(target) {
|
||||
if (target == null) return;
|
||||
if (target === _viewer) {
|
||||
if (_active) interruptCameraMotion('viewer-released');
|
||||
detachViewerListeners();
|
||||
} else if (_active?.rig?.owner === target) {
|
||||
interruptCameraMotion('engine-released');
|
||||
}
|
||||
if (_active?.rig?.owner === target) interruptCameraMotion('engine-released');
|
||||
if (_rig?.owner === target) _rig = _getTarget = _isCockpitActive = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the module to the camera once (idempotent). `target` is a Cesium
|
||||
* viewer (its own camera: the viewer rig) or a globe engine (the contract:
|
||||
* the engine rig; Navara, or Cesium through engineFromViewer). The
|
||||
* Wire the module to the camera once (idempotent). `target` is a globe
|
||||
* engine (Navara, or the Cesium engine over the app's viewer). The
|
||||
* view-target getter is optional so a non-voice caller (the Directions FLY
|
||||
* chip) can arm route flights before any voice session exists; a later call
|
||||
* with a real getter installs it, and a call without one, FOR THE SAME
|
||||
@@ -1948,18 +1757,14 @@ export function initCameraVerbs(
|
||||
_isCockpitActive = isCockpitActive;
|
||||
return;
|
||||
}
|
||||
if (!isGlobeEngine(target)) return;
|
||||
const previous = _rig;
|
||||
const next = isGlobeEngine(target)
|
||||
? engineRigFor(target)
|
||||
: createViewerRig(target);
|
||||
const next = engineRigFor(target);
|
||||
if (_active && (_active.rig === previous || previous === null)) {
|
||||
// A globe motion belongs to the camera whose world pose it captured. Any
|
||||
// other motion carries on with a new Cesium viewer (its state is world
|
||||
// geometry), but never crosses to another kind of camera.
|
||||
// A globe motion belongs to the camera whose world pose it captured, and
|
||||
// no motion crosses to another camera.
|
||||
if (_active.kind === 'rotate_globe')
|
||||
interruptCameraMotion('viewer-replaced');
|
||||
else if (_active.rig?.kind === 'viewer' && next.kind === 'viewer')
|
||||
_active.rig = next;
|
||||
else if (_active.rig === previous && previous !== null)
|
||||
interruptCameraMotion('camera-replaced');
|
||||
}
|
||||
@@ -1967,24 +1772,6 @@ export function initCameraVerbs(
|
||||
_isCockpitActive =
|
||||
typeof isCockpitActive === 'function' ? isCockpitActive : null;
|
||||
_rig = next;
|
||||
detachViewerListeners();
|
||||
if (next.kind !== 'viewer') return;
|
||||
const viewer = target;
|
||||
_viewer = viewer;
|
||||
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
|
||||
// Cockpit can acquire then clear tracking before the next clock tick.
|
||||
// Yield on acquisition itself without changing legacy camera motions.
|
||||
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
|
||||
if (entity && _active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
});
|
||||
const canvas = viewer.scene.canvas;
|
||||
// ANY manual camera input reclaims control — the cancelFlight reflex.
|
||||
for (const evt of MANUAL_INPUT_EVENTS) {
|
||||
const h = () => interruptCameraMotion('manual-input');
|
||||
canvas.addEventListener(evt, h, { passive: true });
|
||||
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The camera verbs on the engine-neutral globe contract (the engine rig), on
|
||||
// the fake engine: pose deltas and durations against a real Cesium camera
|
||||
// driven through the viewer rig, cancellation, supersession and the release
|
||||
// an engine switch makes.
|
||||
// driven through the Cesium engine, cancellation, supersession and the
|
||||
// release an engine switch makes.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
@@ -9,6 +9,7 @@ import * as Cesium from 'cesium';
|
||||
import * as geo from './geo/index.js';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import { createFakeEngine } from './globe/testing/fakeEngine.js';
|
||||
import { createCameraViewer } from './globe/cesium/testing/cameraViewer.mjs';
|
||||
import {
|
||||
activeCameraMotionId,
|
||||
cameraVerbsSupported,
|
||||
@@ -75,30 +76,19 @@ function engineHarness(t, pose = START, clock = installClock(t)) {
|
||||
return { engine, clock, frame, run, camera: () => engine.camera.pose() };
|
||||
}
|
||||
|
||||
/** A real Cesium camera on a stub scene (no renderer), driven as a viewer. */
|
||||
function viewerHarness(t, pose = START, clock = installClock(t)) {
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: WIDTH,
|
||||
clientHeight: HEIGHT,
|
||||
/** The Cesium engine over a real Cesium camera on a stub scene (no renderer). */
|
||||
function cesiumHarness(t, pose = START, clock = installClock(t)) {
|
||||
const {
|
||||
viewer,
|
||||
scene,
|
||||
camera,
|
||||
engine,
|
||||
frame: turn,
|
||||
} = createCameraViewer({
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
});
|
||||
const ellipsoid = Cesium.Ellipsoid.WGS84;
|
||||
const scene = {
|
||||
canvas,
|
||||
drawingBufferWidth: WIDTH,
|
||||
drawingBufferHeight: HEIGHT,
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
mapProjection: new Cesium.GeographicProjection(ellipsoid),
|
||||
globe: { ellipsoid },
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
tweens: [],
|
||||
requestRender() {},
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
scene.camera = camera;
|
||||
t.after(() => engine.destroy());
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
pose.lonDeg,
|
||||
@@ -111,16 +101,9 @@ function viewerHarness(t, pose = START, clock = installClock(t)) {
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
const viewer = {
|
||||
camera,
|
||||
scene,
|
||||
trackedEntity: undefined,
|
||||
trackedEntityChanged: new Cesium.Event(),
|
||||
clock: { onTick: new Cesium.Event() },
|
||||
};
|
||||
const frame = (seconds = 0.1) => {
|
||||
clock.ms += seconds * 1000;
|
||||
viewer.clock.onTick.raiseEvent(viewer.clock);
|
||||
turn();
|
||||
};
|
||||
const run = (seconds, step = 0.1) => {
|
||||
for (let s = 0; s < seconds - 1e-9; s += step) frame(step);
|
||||
@@ -133,7 +116,15 @@ function viewerHarness(t, pose = START, clock = installClock(t)) {
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
});
|
||||
return { viewer, camera: cameraPose, frame, run, cesium: camera };
|
||||
return {
|
||||
viewer,
|
||||
scene,
|
||||
engine,
|
||||
camera: cameraPose,
|
||||
frame,
|
||||
run,
|
||||
cesium: camera,
|
||||
};
|
||||
}
|
||||
|
||||
function near(actual, expected, tolerance, label) {
|
||||
@@ -162,20 +153,21 @@ function samePose(actual, expected, label, metres = 1e-3, unit = 1e-9) {
|
||||
const ORBIT_TARGET = geo.cartesian3.fromDegrees(10.02, 30.03, 0);
|
||||
|
||||
/**
|
||||
* Run one verb on the Cesium viewer rig and on the engine rig from the same
|
||||
* pose with the same frames; return both camera poses before and after.
|
||||
* Run one verb on the Cesium engine (a real Cesium camera) and on the fake
|
||||
* engine from the same pose with the same frames; return both camera poses
|
||||
* before and after.
|
||||
*/
|
||||
function runOnBoth(t, args, seconds, { pose = START, target = null } = {}) {
|
||||
const clock = installClock(t);
|
||||
const cesium = viewerHarness(t, pose, clock);
|
||||
initCameraVerbs(cesium.viewer, () => target);
|
||||
const cesium = cesiumHarness(t, pose, clock);
|
||||
initCameraVerbs(cesium.engine, () => target);
|
||||
const cesiumBefore = cesium.camera();
|
||||
const cesiumResult = moveCamera(args);
|
||||
assert.equal(cesiumResult.ok, true, JSON.stringify(cesiumResult));
|
||||
cesium.run(seconds);
|
||||
const cesiumAfter = cesium.camera();
|
||||
interruptCameraMotion('parity');
|
||||
releaseCameraVerbs(cesium.viewer);
|
||||
releaseCameraVerbs(cesium.engine);
|
||||
|
||||
const fake = engineHarness(t, pose, clock);
|
||||
initCameraVerbs(fake.engine, () => target);
|
||||
@@ -436,11 +428,11 @@ test('installing another camera stops a motion running on the previous one', (t)
|
||||
initCameraVerbs(first.engine, () => ORBIT_TARGET);
|
||||
moveCamera({ motion: 'orbit', mode: 'continuous' });
|
||||
first.run(0.3);
|
||||
const cesium = viewerHarness(t, START, first.clock);
|
||||
initCameraVerbs(cesium.viewer, () => ORBIT_TARGET);
|
||||
const cesium = cesiumHarness(t, START, first.clock);
|
||||
initCameraVerbs(cesium.engine, () => ORBIT_TARGET);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
assert.deepEqual(first.engine.camera.transform(), [...geo.matrix4.IDENTITY]);
|
||||
releaseCameraVerbs(cesium.viewer);
|
||||
releaseCameraVerbs(cesium.engine);
|
||||
});
|
||||
|
||||
test('globe rotation yields when a follow takes the engine camera', (t) => {
|
||||
@@ -569,8 +561,8 @@ test('cameraVerbsSupported names the targets the verbs can drive', () => {
|
||||
scene: {},
|
||||
clock: { onTick: new Cesium.Event() },
|
||||
}),
|
||||
true,
|
||||
'a Cesium viewer',
|
||||
false,
|
||||
'a Cesium viewer is driven through its engine',
|
||||
);
|
||||
const partial = createFakeEngine();
|
||||
partial.camera = { ...partial.camera, following: undefined };
|
||||
@@ -580,7 +572,7 @@ test('cameraVerbsSupported names the targets the verbs can drive', () => {
|
||||
});
|
||||
|
||||
test('a once orbit armed through a flight still turns its full nudge', (t) => {
|
||||
for (const kind of ['viewer', 'engine']) {
|
||||
for (const kind of ['cesium', 'engine']) {
|
||||
const clock = installClock(t);
|
||||
const target = ORBIT_TARGET;
|
||||
let h;
|
||||
@@ -596,15 +588,17 @@ test('a once orbit armed through a flight still turns its full nudge', (t) => {
|
||||
});
|
||||
flying = true;
|
||||
} else {
|
||||
h = viewerHarness(t, START, clock);
|
||||
initCameraVerbs(h.viewer, () => target);
|
||||
h.viewer.scene.tweens.push({});
|
||||
// A flight a layer started straight on viewer.camera: a scene tween
|
||||
// the engine did not start.
|
||||
h = cesiumHarness(t, START, clock);
|
||||
initCameraVerbs(h.engine, () => target);
|
||||
h.scene.tweens.push({});
|
||||
flying = true;
|
||||
}
|
||||
const before = h.camera();
|
||||
assert.equal(moveCamera({ motion: 'orbit' }).armed, 'waiting-for-arrival');
|
||||
h.run(1.6);
|
||||
if (kind === 'viewer' && flying) h.viewer.scene.tweens.length = 0;
|
||||
if (kind === 'cesium' && flying) h.scene.tweens.length = 0;
|
||||
h.run(1);
|
||||
const start = getActiveCameraMotion();
|
||||
assert.equal(start?.kind, 'orbit', `${kind}: the arm outlived 0.9 s`);
|
||||
@@ -621,6 +615,6 @@ test('a once orbit armed through a flight still turns its full nudge', (t) => {
|
||||
geo.math.negativePiToPi(after.heading - before.heading),
|
||||
);
|
||||
near(turn, 30, 0.05, `${kind}: heading (camera frame vs target frame)`);
|
||||
if (kind === 'viewer') releaseCameraVerbs(h.viewer);
|
||||
if (kind === 'cesium') releaseCameraVerbs(h.engine);
|
||||
}
|
||||
});
|
||||
@@ -122,7 +122,10 @@ test('civilian and military click handlers apply duration only at the deselect b
|
||||
assert.match(dispatcher, /if \(!isTrackingSelectionGesture\(gesture\)\) return;[\s\S]+onSelect/);
|
||||
assert.match(dispatcher, /if \(!isTrackingClickGesture\(gesture\)\) return;\s*for \(const owner of \[\.\.\.owners\.values\(\)\]\.filter\(plain\)\)\s*owner\.onEmptyClick/);
|
||||
for (const [layer, source] of [['flights', sources[0]], ['military', sources[1]]]) {
|
||||
assert.match(source, new RegExp(`dispatcher\\.register\\('${layer}', \\{[\\s\\S]+onEmptyClick: _onEmptyClick`));
|
||||
// Both layers register through the shared aircraft tracking, under their
|
||||
// own layer id.
|
||||
assert.match(source, /dispatcher\.register\(layerId, \{[\s\S]+onEmptyClick: _onEmptyClick/);
|
||||
assert.match(source, new RegExp(`layerId: '${layer}'`));
|
||||
assert.match(source, /function _onEmptyClick\(\) \{[\s\S]+?_clearTracking\(false, \{ origin: 'user' \}\)/);
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
|
||||
@@ -38,6 +38,41 @@ const cesiumDestination = (destination) =>
|
||||
|
||||
const cesiumEasing = (easing) => (easing ? easings()[easing] : undefined);
|
||||
|
||||
let scratchBasis;
|
||||
|
||||
/**
|
||||
* Write a world direction and up (right = direction × up, up made
|
||||
* perpendicular) as the camera's basis, in its current reference frame.
|
||||
* Cesium's setView turns direction/up into heading/pitch/roll, which is
|
||||
* singular looking straight down: within about 2.6° of nadir it drops the
|
||||
* roll and reads the heading off `up`. The exact basis is what Cesium's own
|
||||
* rotate/look/move leave. A degenerate pair (up along direction) keeps
|
||||
* Cesium's result.
|
||||
*/
|
||||
function setBasis(cesium, direction, up) {
|
||||
scratchBasis ??= {
|
||||
direction: new Cesium.Cartesian3(),
|
||||
up: new Cesium.Cartesian3(),
|
||||
right: new Cesium.Cartesian3(),
|
||||
};
|
||||
const right = Cesium.Cartesian3.cross(direction, up, scratchBasis.right);
|
||||
const rightLength = Cesium.Cartesian3.magnitude(right);
|
||||
const directionLength = Cesium.Cartesian3.magnitude(direction);
|
||||
if (!(rightLength > 1e-12 * directionLength) || !Number.isFinite(rightLength))
|
||||
return;
|
||||
Cesium.Cartesian3.divideByScalar(right, rightLength, right);
|
||||
const forward = Cesium.Cartesian3.divideByScalar(
|
||||
direction,
|
||||
directionLength,
|
||||
scratchBasis.direction,
|
||||
);
|
||||
const upright = Cesium.Cartesian3.cross(right, forward, scratchBasis.up);
|
||||
const inverse = cesium.inverseTransform;
|
||||
Cesium.Matrix4.multiplyByPointAsVector(inverse, forward, cesium.direction);
|
||||
Cesium.Matrix4.multiplyByPointAsVector(inverse, upright, cesium.up);
|
||||
Cesium.Matrix4.multiplyByPointAsVector(inverse, right, cesium.right);
|
||||
}
|
||||
|
||||
// A HeadingPitchRange or a Cartesian offset, as Cesium's lookAt takes them.
|
||||
const cesiumOffset = (offset) =>
|
||||
'heading' in offset
|
||||
@@ -167,10 +202,13 @@ export function createCesiumCamera(viewer) {
|
||||
setView(view) {
|
||||
if (!view?.destination)
|
||||
throw new TypeError('setView needs a destination');
|
||||
const orientation = cesiumOrientation(view.orientation);
|
||||
cesium.setView({
|
||||
destination: cesiumDestination(view.destination),
|
||||
orientation: cesiumOrientation(view.orientation),
|
||||
orientation,
|
||||
});
|
||||
if (orientation?.direction)
|
||||
setBasis(cesium, orientation.direction, orientation.up);
|
||||
},
|
||||
flyTo(options) {
|
||||
if (!options?.destination)
|
||||
@@ -209,7 +247,9 @@ export function createCesiumCamera(viewer) {
|
||||
),
|
||||
);
|
||||
},
|
||||
flightActive: () => flight != null,
|
||||
// Also a flight started straight on viewer.camera (layers' flyTo and
|
||||
// flyToBoundingSphere): Cesium runs every camera flight as a scene tween.
|
||||
flightActive: () => flight != null || viewer.scene.tweens?.length > 0,
|
||||
lookAt(target, offset) {
|
||||
cesium.lookAt(toCesium3(target), cesiumOffset(offset));
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as geo from '../../geo/index.js';
|
||||
import { isEngineEvent } from '../contract.js';
|
||||
import { createCesiumCamera } from './camera.js';
|
||||
import { createFakeViewer } from './testing/fakeViewer.mjs';
|
||||
import { createCameraViewer } from './testing/cameraViewer.mjs';
|
||||
|
||||
const notCesium = (value, label) => {
|
||||
assert.ok(value != null, `${label} is missing`);
|
||||
@@ -115,6 +116,95 @@ test('flyTo maps easing, duration and callbacks and tracks the flight', () => {
|
||||
assert.equal(viewer.callsOf('camera.cancelFlight').length, 1);
|
||||
});
|
||||
|
||||
test('flightActive also reports a flight started straight on the Cesium camera', () => {
|
||||
const { viewer, camera, cesium } = setup();
|
||||
assert.equal(camera.flightActive(), false);
|
||||
// Cesium runs every camera flight as a scene tween, so a layer's own
|
||||
// viewer.camera.flyTo counts as much as the engine's.
|
||||
const layerFlight = {};
|
||||
viewer.scene.tweens.push(layerFlight);
|
||||
assert.equal(camera.flightActive(), true);
|
||||
viewer.scene.tweens.splice(viewer.scene.tweens.indexOf(layerFlight), 1);
|
||||
assert.equal(camera.flightActive(), false);
|
||||
camera.flyTo({ destination: geo.cartesian3.fromDegrees(0, 0, 1e6) });
|
||||
assert.equal(camera.flightActive(), true);
|
||||
cesium.completeFlight();
|
||||
assert.equal(camera.flightActive(), false);
|
||||
});
|
||||
|
||||
test('setView with direction and up keeps the exact basis next to nadir', () => {
|
||||
const { camera, engine } = createCameraViewer();
|
||||
const unit = (x, y, z) =>
|
||||
Cesium.Cartesian3.normalize(
|
||||
new Cesium.Cartesian3(x, y, z),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
for (const locked of [false, true]) {
|
||||
// Nearly straight down at 15° N with a 30° roll: inside the band where
|
||||
// Cesium's heading/pitch/roll conversion drops the roll.
|
||||
const destination = Cesium.Cartesian3.fromDegrees(0, 15, 18_000_000);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(destination);
|
||||
const toWorld = (local) =>
|
||||
Cesium.Matrix4.multiplyByPointAsVector(
|
||||
enu,
|
||||
local,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const direction = toWorld(unit(0.01, 0, -1));
|
||||
const level = toWorld(unit(1, 0, 0.01));
|
||||
const right = Cesium.Cartesian3.cross(
|
||||
direction,
|
||||
level,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const roll = Cesium.Quaternion.fromAxisAngle(
|
||||
direction,
|
||||
Cesium.Math.toRadians(30),
|
||||
);
|
||||
const up = Cesium.Matrix3.multiplyByVector(
|
||||
Cesium.Matrix3.fromQuaternion(roll),
|
||||
Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
if (locked)
|
||||
camera.lookAtTransform(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||
Cesium.Cartesian3.fromDegrees(20, 10),
|
||||
),
|
||||
);
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.clone(destination),
|
||||
orientation: {
|
||||
direction: geo.cartesian3.clone(direction),
|
||||
up: geo.cartesian3.clone(up),
|
||||
},
|
||||
});
|
||||
const close = (actual, expected, tolerance, label) =>
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.distance(actual, expected) < tolerance,
|
||||
`${label}: ${Cesium.Cartesian3.distance(actual, expected)}`,
|
||||
);
|
||||
close(camera.positionWC, destination, 1e-6, 'position');
|
||||
close(camera.directionWC, direction, 1e-12, 'direction');
|
||||
close(camera.upWC, up, 1e-12, 'up');
|
||||
close(
|
||||
camera.rightWC,
|
||||
Cesium.Cartesian3.cross(direction, up, new Cesium.Cartesian3()),
|
||||
1e-12,
|
||||
'right',
|
||||
);
|
||||
assert.equal(
|
||||
Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY),
|
||||
!locked,
|
||||
'the reference frame is kept',
|
||||
);
|
||||
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
}
|
||||
});
|
||||
|
||||
test('flyTo with duration 0 completes before it returns and leaves no flight', () => {
|
||||
const { camera } = setup();
|
||||
let done = false;
|
||||
|
||||
@@ -8,12 +8,11 @@
|
||||
// drives Cesium's own lighting as before.
|
||||
//
|
||||
// The latch is attached when the engine is created (the app creates it with
|
||||
// the scene, before cameraVerbs and the layers add their own onTick
|
||||
// listeners), so it runs first in each tick; an onTick listener added before
|
||||
// the engine existed would read the previous frame's time. destroy removes
|
||||
// it. A viewer without a clock (tests, a bare scene) latches on the scene's
|
||||
// preUpdate instead, attached on first use so creating the engine adds no
|
||||
// scene listener.
|
||||
// the scene), so it runs before any onTick listener added later; one added
|
||||
// before the engine existed would read the previous frame's time. destroy
|
||||
// removes it. A viewer without a clock (tests, a bare scene) latches on the
|
||||
// scene's preUpdate instead, attached on first use so creating the engine
|
||||
// adds no scene listener.
|
||||
|
||||
import { appClock as pageClock } from '../../time/clock.js';
|
||||
|
||||
|
||||
@@ -31,8 +31,7 @@ test('the clock reads the app clock and latches on the viewer clock from creatio
|
||||
const viewer = createFakeViewer();
|
||||
const wall = manualAppClock();
|
||||
const { clock } = createCesiumClock(viewer, { appClock: wall.appClock });
|
||||
// Attached at creation, so it runs before onTick listeners added later
|
||||
// (cameraVerbs installs its own after the engine exists).
|
||||
// Attached at creation, so it runs before onTick listeners added later.
|
||||
assert.equal(viewer.clock.onTick.numberOfListeners, 1);
|
||||
const order = [];
|
||||
viewer.clock.onTick.addEventListener(() => order.push(clock.frameTime()));
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
// one exact tracked frame on preUpdate (applyTrackedCameraFrame) and the
|
||||
// zoom limits that keep the camera outside the minimum range.
|
||||
// following() also reports a trackedEntity the app set itself, and
|
||||
// followChanged is viewer.trackedEntityChanged.
|
||||
// followChanged is viewer.trackedEntityChanged. A handle on an Entity carries
|
||||
// its `name` (the Entity's name, else its id): what the camera verbs say they
|
||||
// are following.
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
import { toCesium3 } from './convert.js';
|
||||
|
||||
const MAX_FRAME_ATTEMPTS = 120;
|
||||
const entityName = (entity) => (entity ? entity.name || entity.id : undefined);
|
||||
const ZOOM_INERTIA_STATES = new WeakMap();
|
||||
|
||||
function acquireStableTrackedZoom(controller, owner, minRange) {
|
||||
@@ -188,6 +191,9 @@ export function createCesiumFollow(viewer, { commanded }) {
|
||||
if (!handle) {
|
||||
handle = {
|
||||
native: entity,
|
||||
get name() {
|
||||
return entityName(entity);
|
||||
},
|
||||
target: () =>
|
||||
displayPosition(entity, viewer.clock.currentTime, undefined),
|
||||
stop() {
|
||||
@@ -308,6 +314,9 @@ export function createCesiumFollow(viewer, { commanded }) {
|
||||
const state = { entity: null, proxy: null, options, handle: null };
|
||||
state.handle = {
|
||||
native: options.native,
|
||||
get name() {
|
||||
return entityName(state.handle.native);
|
||||
},
|
||||
target() {
|
||||
try {
|
||||
return state.options.position();
|
||||
|
||||
@@ -126,6 +126,33 @@ test('retarget switches the tracked entity without ending the follow', () => {
|
||||
assert.deepEqual(changes, [handle, handle]);
|
||||
});
|
||||
|
||||
test('a follow on an Entity names it, as the tracked camera is named', () => {
|
||||
const { viewer, camera } = setup();
|
||||
// An entity the app tracked itself: its name, else its id.
|
||||
viewer.trackedEntity = new Cesium.Entity({ id: 'N123AB', name: 'UAL 12' });
|
||||
assert.equal(camera.following().name, 'UAL 12');
|
||||
viewer.trackedEntity = new Cesium.Entity({ id: 'N456CD' });
|
||||
assert.equal(camera.following().name, 'N456CD');
|
||||
// The engine's own follow of a native entity; a proxy follow has none.
|
||||
const native = new Cesium.Entity({
|
||||
id: 'ISS',
|
||||
position: Cesium.Cartesian3.clone(TARGET),
|
||||
viewFrom: new Cesium.Cartesian3(0, -900, 400),
|
||||
});
|
||||
const handle = camera.follow({
|
||||
native,
|
||||
position: () => TARGET,
|
||||
viewFrom: { x: 0, y: -900, z: 400 },
|
||||
});
|
||||
assert.equal(handle.name, 'ISS');
|
||||
const proxy = camera.follow({
|
||||
position: () => TARGET,
|
||||
viewFrom: { x: 0, y: -900, z: 400 },
|
||||
});
|
||||
assert.equal(proxy.name, undefined);
|
||||
proxy.stop();
|
||||
});
|
||||
|
||||
test('destroy ends an engine follow and leaves an app-tracked entity alone', () => {
|
||||
const first = setup();
|
||||
first.camera.follow({
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// A Cesium viewer around a real Cesium.Camera on a stub scene (no renderer,
|
||||
// GPU or providers), with the Cesium engine over it: for tests that check
|
||||
// what the globe contract does to Cesium's own camera geometry.
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { createCesiumEngine } from '../createCesiumEngine.js';
|
||||
|
||||
/**
|
||||
* @param {{ width?: number, height?: number }} [options] The canvas size.
|
||||
* @returns {{ viewer: object, scene: object, camera: Cesium.Camera,
|
||||
* canvas: EventTarget, engine: import('../../contract.js').GlobeEngine,
|
||||
* frame: () => void }} `frame()` runs one turn of CesiumWidget's loop: the
|
||||
* viewer clock's tick, then the scene's frame events.
|
||||
*/
|
||||
export function createCameraViewer({ width = 1280, height = 800 } = {}) {
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: width,
|
||||
clientHeight: height,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
const ellipsoid = Cesium.Ellipsoid.WGS84;
|
||||
const scene = {
|
||||
canvas,
|
||||
drawingBufferWidth: width,
|
||||
drawingBufferHeight: height,
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
mapProjection: new Cesium.GeographicProjection(ellipsoid),
|
||||
globe: { ellipsoid },
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
// Cesium's running camera flights.
|
||||
tweens: [],
|
||||
preUpdate: new Cesium.Event(),
|
||||
preRender: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
requestRender() {},
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
scene.camera = camera;
|
||||
let trackedEntity;
|
||||
const viewer = {
|
||||
scene,
|
||||
camera,
|
||||
canvas,
|
||||
container: {},
|
||||
trackedEntityChanged: new Cesium.Event(),
|
||||
// Assigning raises trackedEntityChanged, as Cesium's Viewer does.
|
||||
get trackedEntity() {
|
||||
return trackedEntity;
|
||||
},
|
||||
set trackedEntity(value) {
|
||||
if (value === trackedEntity) return;
|
||||
trackedEntity = value;
|
||||
viewer.trackedEntityChanged.raiseEvent(value);
|
||||
},
|
||||
clock: {
|
||||
onTick: new Cesium.Event(),
|
||||
currentTime: Cesium.JulianDate.fromIso8601('2026-10-16T12:00:00Z'),
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
const engine = createCesiumEngine(viewer, { requestRender: () => {} });
|
||||
const frame = () => {
|
||||
const time = viewer.clock.currentTime;
|
||||
viewer.clock.onTick.raiseEvent(viewer.clock);
|
||||
scene.preUpdate.raiseEvent(scene, time);
|
||||
scene.preRender.raiseEvent(scene, time);
|
||||
scene.postRender.raiseEvent(scene, time);
|
||||
};
|
||||
return { viewer, scene, camera, canvas, engine, frame };
|
||||
}
|
||||
@@ -30,9 +30,11 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
|
||||
const fovy = 2 * Math.atan(Math.tan(Math.PI / 6) * (height / width));
|
||||
const camera = {
|
||||
positionWC: new Cesium.Cartesian3(7e6, 1e5, 2e5),
|
||||
// Position and direction in the camera's transform (identity: world).
|
||||
// Position and basis in the camera's transform (identity: world).
|
||||
position: new Cesium.Cartesian3(7e6, 1e5, 2e5),
|
||||
direction: new Cesium.Cartesian3(-1, 0, 0),
|
||||
up: new Cesium.Cartesian3(0, 0, 1),
|
||||
right: new Cesium.Cartesian3(0, 1, 0),
|
||||
directionWC: new Cesium.Cartesian3(-1, 0, 0),
|
||||
upWC: new Cesium.Cartesian3(0, 0, 1),
|
||||
rightWC: new Cesium.Cartesian3(0, 1, 0),
|
||||
@@ -48,6 +50,12 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
|
||||
},
|
||||
viewMatrix: Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(1, 2, 3)),
|
||||
transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
|
||||
get inverseTransform() {
|
||||
return Cesium.Matrix4.inverseTransformation(
|
||||
camera.transform,
|
||||
new Cesium.Matrix4(),
|
||||
);
|
||||
},
|
||||
defaultZoomAmount: 100000,
|
||||
percentageChanged: 0.5,
|
||||
changed: new Cesium.Event(),
|
||||
@@ -132,6 +140,8 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
|
||||
postRender: new Cesium.Event(),
|
||||
requestRenderMode: true,
|
||||
maximumRenderTimeChange: Infinity,
|
||||
// Cesium's running camera flights (a flight on viewer.camera is one).
|
||||
tweens: [],
|
||||
requestRender: record('scene.requestRender'),
|
||||
pick: record('scene.pick', () => undefined),
|
||||
drillPick: record('scene.drillPick', () => []),
|
||||
|
||||
@@ -154,6 +154,8 @@
|
||||
* target is now (what the follow centres).
|
||||
* @property {any} [native]
|
||||
* @property {object} [label] FollowOptions.label.
|
||||
* @property {string} [name] Cesium only: the followed Entity's name, else its
|
||||
* id (a follow without an Entity has none).
|
||||
*
|
||||
* @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType
|
||||
* @typedef {'ctrl'|'shift'|'alt'} InputModifier
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Navara 0.2.1 never takes a global batch id back: its core keeps every id
|
||||
// ctx.genGlobalBatchId() hands out (about 20 bytes of WASM memory each,
|
||||
// never freed). A PickableInstancedMeshWrapper takes one id per instance of
|
||||
// its capacity, and a layer builds its mover sets again each time it
|
||||
// attaches to the engine, so every switch back to Navara with 10 000
|
||||
// flights took some 32 000 new ids (about 650 KiB). This pool gives the ids
|
||||
// of a wrapper that is gone to the next wrapper on the same view. Upstream
|
||||
// ask: release a batch id, or a wrapper's ids when it is unregistered.
|
||||
|
||||
/** A pool of one view's released global batch ids. */
|
||||
export function createBatchIdPool() {
|
||||
const free = [];
|
||||
const released = new WeakSet();
|
||||
return {
|
||||
/**
|
||||
* The view's `ctx` to build a pickable wrapper with, whose batch ids
|
||||
* come from the pool first; everything else is the view's own.
|
||||
*/
|
||||
source(ctx) {
|
||||
const take = () => (free.length ? free.pop() : ctx.genGlobalBatchId());
|
||||
return new Proxy(ctx, {
|
||||
get: (target, key, receiver) =>
|
||||
key === 'genGlobalBatchId'
|
||||
? take
|
||||
: Reflect.get(target, key, receiver),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Takes back the ids of a wrapper no longer drawn or registered for
|
||||
* picking; a wrapper's ids come back once.
|
||||
*/
|
||||
release(wrapper) {
|
||||
if (!wrapper || released.has(wrapper)) return;
|
||||
released.add(wrapper);
|
||||
for (const id of wrapper.batchIds) if (id > 0) free.push(id);
|
||||
},
|
||||
/** The ids waiting for a wrapper. */
|
||||
size: () => free.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBatchIdPool } from './batchIds.js';
|
||||
|
||||
// A view context that never takes an id back, as Navara 0.2.1's core.
|
||||
function fakeContext() {
|
||||
let next = 0;
|
||||
return {
|
||||
issued: () => next,
|
||||
genGlobalBatchId: () => ++next,
|
||||
};
|
||||
}
|
||||
|
||||
// The wrapper's part that matters here: one id per instance, from its ctx.
|
||||
const wrapper = (ctx, count) => ({
|
||||
batchIds: Array.from({ length: count }, () => ctx.genGlobalBatchId() ?? 0),
|
||||
});
|
||||
|
||||
test('a rebuilt set takes the ids of the set it replaces', () => {
|
||||
const ctx = fakeContext();
|
||||
const pool = createBatchIdPool();
|
||||
let set = wrapper(pool.source(ctx), 1000);
|
||||
for (let attach = 0; attach < 50; attach++) {
|
||||
pool.release(set);
|
||||
set = wrapper(pool.source(ctx), 1000);
|
||||
}
|
||||
assert.equal(ctx.issued(), 1000);
|
||||
assert.equal(pool.size(), 0);
|
||||
assert.equal(new Set(set.batchIds).size, 1000);
|
||||
});
|
||||
|
||||
test('live sets never share an id, and a set gives its ids back once', () => {
|
||||
const ctx = fakeContext();
|
||||
const pool = createBatchIdPool();
|
||||
const a = wrapper(pool.source(ctx), 3);
|
||||
pool.release(a);
|
||||
pool.release(a);
|
||||
assert.equal(pool.size(), 3);
|
||||
const b = wrapper(pool.source(ctx), 2);
|
||||
const c = wrapper(pool.source(ctx), 2);
|
||||
const ids = [...b.batchIds, ...c.batchIds];
|
||||
assert.equal(new Set(ids).size, 4);
|
||||
assert.equal(ctx.issued(), 4);
|
||||
});
|
||||
|
||||
test('ids Navara could not give (0) are not pooled', () => {
|
||||
const pool = createBatchIdPool();
|
||||
pool.release({ batchIds: [0, 0, 7] });
|
||||
pool.release(null);
|
||||
assert.equal(pool.size(), 1);
|
||||
const none = { genGlobalBatchId: () => undefined };
|
||||
assert.equal(pool.source(none).genGlobalBatchId(), 7);
|
||||
assert.equal(pool.source(none).genGlobalBatchId(), undefined);
|
||||
});
|
||||
|
||||
test('everything else reaches the view context itself', () => {
|
||||
const ctx = {
|
||||
_meshes: new Map(),
|
||||
genGlobalBatchId: () => 1,
|
||||
registerPickableMesh(key, wrapper) {
|
||||
this._meshes.set(key, wrapper);
|
||||
},
|
||||
};
|
||||
const source = createBatchIdPool().source(ctx);
|
||||
source.registerPickableMesh('a', 'wrapper');
|
||||
assert.equal(ctx._meshes.get('a'), 'wrapper');
|
||||
assert.equal(source._meshes, ctx._meshes);
|
||||
});
|
||||
@@ -5,7 +5,8 @@
|
||||
// (modelMover.js). In the cinematic look, depth stays in that scene and
|
||||
// lit model colour resolves in the display pass. Pickable sets register a
|
||||
// PickableInstancedMeshWrapper so Navara's pick pass sees each instance;
|
||||
// pickIds.js turns its batch ids back into instances. commit() uploads and
|
||||
// pickIds.js turns its batch ids back into instances, and batchIds.js gives
|
||||
// a removed set's ids to the next one. commit() uploads and
|
||||
// asks for one frame (view.forceUpdate); per-frame work runs only on frames
|
||||
// the view draws anyway, so an idle view stays idle.
|
||||
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from '@navaramap/three';
|
||||
import { Group } from 'three';
|
||||
import { createMoverRegistry } from '../../movers.js';
|
||||
import { createBatchIdPool } from './batchIds.js';
|
||||
import { createModelMover } from './modelMover.js';
|
||||
import { createMoverMesh } from './moverMesh.js';
|
||||
import { createPickIndex } from './pickIds.js';
|
||||
@@ -34,16 +36,16 @@ class GevMoverSetDesc extends withFrameUpdates(MeshDesc) {
|
||||
}
|
||||
|
||||
createMesh() {
|
||||
const { mover, pickable, onPickable } = this.gev;
|
||||
const { mover, pickable, onPickable, batchIds } = this.gev;
|
||||
const register = (mesh, key) => {
|
||||
if (!pickable) return;
|
||||
const wrapper = new PickableInstancedMeshWrapper(
|
||||
mesh,
|
||||
mesh.instanceMatrix.count,
|
||||
this.ctx,
|
||||
batchIds.source(this.ctx),
|
||||
);
|
||||
this.ctx.registerPickableMesh(key, wrapper);
|
||||
this.wrappers.push(key);
|
||||
this.wrappers.push({ key, wrapper });
|
||||
onPickable(wrapper);
|
||||
};
|
||||
const group = new Group();
|
||||
@@ -66,8 +68,9 @@ class GevMoverSetDesc extends withFrameUpdates(MeshDesc) {
|
||||
|
||||
onDestroy() {
|
||||
this.destroyedByGev = true;
|
||||
for (const key of this.wrappers) this.ctx.unregisterPickableMesh(key);
|
||||
for (const { key } of this.wrappers) this.ctx.unregisterPickableMesh(key);
|
||||
this.gev.mover.dispose();
|
||||
for (const { wrapper } of this.wrappers) this.gev.batchIds.release(wrapper);
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -90,6 +93,7 @@ export function createNavaraMovers(view, { overlay, aces = false }) {
|
||||
registered.add(view);
|
||||
}
|
||||
const pickIndex = createPickIndex();
|
||||
const batchIds = createBatchIdPool();
|
||||
const readFrameCamera = createMoverFrameCamera(view);
|
||||
let loader = null;
|
||||
const loadGltf = (url) => {
|
||||
@@ -111,11 +115,12 @@ export function createNavaraMovers(view, { overlay, aces = false }) {
|
||||
overlayUniforms: targetOverlay.uniforms,
|
||||
});
|
||||
const key = `gevMovers:${options.id}`;
|
||||
let wrapper = null;
|
||||
if (options.pickable) {
|
||||
const wrapper = new PickableInstancedMeshWrapper(
|
||||
wrapper = new PickableInstancedMeshWrapper(
|
||||
mover.mesh,
|
||||
options.capacity,
|
||||
targetOverlay.ctx,
|
||||
batchIds.source(targetOverlay.ctx),
|
||||
);
|
||||
targetOverlay.registerPickable(key, wrapper);
|
||||
pickIndex.add(options.id, wrapper, (index) => store.pickIds[index]);
|
||||
@@ -134,6 +139,7 @@ export function createNavaraMovers(view, { overlay, aces = false }) {
|
||||
if (options.pickable) targetOverlay.unregisterPickable(key);
|
||||
targetOverlay.remove(mover.mesh);
|
||||
mover.dispose();
|
||||
batchIds.release(wrapper);
|
||||
if (options.sharpen && --preSharpenUsers === 0) {
|
||||
preSharpenOverlay.destroy();
|
||||
preSharpenOverlay = null;
|
||||
@@ -155,6 +161,7 @@ export function createNavaraMovers(view, { overlay, aces = false }) {
|
||||
readFrameCamera,
|
||||
kind: options.kind,
|
||||
pickable: options.pickable,
|
||||
batchIds,
|
||||
onPickable: (wrapper) =>
|
||||
pickIndex.add(options.id, wrapper, (index) => store.pickIds[index]),
|
||||
},
|
||||
|
||||
@@ -34,10 +34,9 @@ function stationFeature(reading, radius) {
|
||||
|
||||
/** One native GeoJSON layer per AQHI colour, independent of station count. */
|
||||
export function createAirQualityNavaraRenderer(engine, { view }) {
|
||||
const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({
|
||||
const display = createNavaraPolygonDisplay(engine, view, (color) => ({
|
||||
polygon: {
|
||||
color,
|
||||
show,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
clampToGround: true,
|
||||
|
||||
@@ -31,7 +31,7 @@ function observedShell(t) {
|
||||
}
|
||||
|
||||
// What the tracking modules do around follow.start on a resume or a share
|
||||
// restore (flights, military and satellites tracking.js).
|
||||
// restore (the shared aircraft tracking.js and satellites tracking.js).
|
||||
function follow(trackedEntityChanged, entity, restoring) {
|
||||
if (restoring) entity.gevRestoring = true;
|
||||
try {
|
||||
@@ -150,8 +150,7 @@ test('an engine switch resumes the aircraft with the origin it was selected by',
|
||||
|
||||
test('every follow of a resumed or share-restored subject is marked while it starts', () => {
|
||||
for (const [file, resume] of [
|
||||
['flights/tracking.js', 'resume'],
|
||||
['military/tracking.js', 'resume'],
|
||||
['aircraft/tracking.js', 'resume'],
|
||||
['satellites/tracking.js', 'resuming'],
|
||||
]) {
|
||||
const source = readLayerSource(new URL(`../${file}`, import.meta.url));
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,157 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { createTracking as createFlightsTracking } from '../flights/tracking.js';
|
||||
import { createTracking as createMilitaryTracking } from '../military/tracking.js';
|
||||
|
||||
// Both observed aircraft layers build their tracking from the shared aircraft
|
||||
// tracking; each must still reach it with its own identity and rules.
|
||||
const LAYERS = [
|
||||
{
|
||||
name: 'flights',
|
||||
createTracking: createFlightsTracking,
|
||||
layerId: 'flights',
|
||||
layerName: 'Live Flights',
|
||||
focusId: 'flights',
|
||||
floorsTrail: false,
|
||||
backfillLeadAltM: 10000,
|
||||
thinsBackfill: false,
|
||||
},
|
||||
{
|
||||
name: 'military',
|
||||
createTracking: createMilitaryTracking,
|
||||
layerId: 'military',
|
||||
layerName: 'Military Flights',
|
||||
focusId: 'militaryFlights',
|
||||
floorsTrail: true,
|
||||
backfillLeadAltM: 50,
|
||||
thinsBackfill: true,
|
||||
},
|
||||
];
|
||||
|
||||
const GROUND_M = 300;
|
||||
|
||||
function harness(layer, { groundM = null, track = [] } = {}) {
|
||||
const calls = { registered: [], focusCleared: [] };
|
||||
const flightState = {
|
||||
enabled: true,
|
||||
feed: {
|
||||
_lastSource: 'Fixture aircraft',
|
||||
_source: { getTrack: async () => ({ records: track }) },
|
||||
},
|
||||
records: { data: new Map(), missingPolls: new Map() },
|
||||
lifetime: new AbortController(),
|
||||
_billboards: new Map(),
|
||||
_trackedIcao: null,
|
||||
_trailPositions: [],
|
||||
_trailBackfillToken: 0,
|
||||
_renderer: {
|
||||
engine: {},
|
||||
follow: { onChanged: () => () => {} },
|
||||
},
|
||||
};
|
||||
const services = {
|
||||
context: {},
|
||||
aircraftPresentation: { tr3bTypeLabel: (id, type) => type },
|
||||
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
|
||||
groundFloor: {
|
||||
resolveGroundFloorCellsBounded: async () => {},
|
||||
cachedGroundFloor: () => groundM,
|
||||
// The production floor rule: lift onto a known floor, else pass through.
|
||||
floorAltitudeM: (altM, floorM) => {
|
||||
const hasAlt = Number.isFinite(altM);
|
||||
const hasFloor = Number.isFinite(floorM);
|
||||
if (hasAlt && hasFloor) return Math.max(altM, floorM);
|
||||
if (hasAlt) return altM;
|
||||
if (hasFloor) return floorM;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
focus: { clearFocusTarget: (id) => calls.focusCleared.push(id) },
|
||||
militaryRegistry: {},
|
||||
readout: {},
|
||||
selection: {
|
||||
selectionDispatcherFor: () => ({
|
||||
register: (id) => {
|
||||
calls.registered.push(id);
|
||||
return () => {};
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
const parts = {
|
||||
queries: {
|
||||
_describeFlight: () => ({ callsign: 'ABC1', track: 90 }),
|
||||
_contactLabel: () => 'ABC1',
|
||||
_toCleanText: (value) => value || '',
|
||||
_formatAltitude: () => '',
|
||||
},
|
||||
};
|
||||
const tracking = layer.createTracking({
|
||||
flightState,
|
||||
services,
|
||||
parts,
|
||||
layer: {},
|
||||
});
|
||||
return { tracking, flightState, calls };
|
||||
}
|
||||
|
||||
const heightOf = (position) => geo.cartographic.fromCartesian(position).height;
|
||||
|
||||
for (const layer of LAYERS) {
|
||||
test(`${layer.name} tracking publishes its own layer identity`, () => {
|
||||
const { tracking, calls } = harness(layer);
|
||||
const context = tracking._contextSubjectMetadata('abc123');
|
||||
assert.equal(context.layerId, layer.layerId);
|
||||
assert.equal(context.layerName, layer.layerName);
|
||||
// The route line is the civil layer's: military contexts carry none.
|
||||
assert.equal('route' in context.properties, layer.name === 'flights');
|
||||
tracking._installClickHandler();
|
||||
assert.deepEqual(calls.registered, [layer.layerId]);
|
||||
tracking._clearTracking();
|
||||
assert.deepEqual(calls.focusCleared, [layer.focusId]);
|
||||
});
|
||||
|
||||
test(`${layer.name} trail ${layer.floorsTrail ? 'lifts' : 'keeps'} a fix below the warm ground`, () => {
|
||||
const { tracking, flightState } = harness(layer, { groundM: GROUND_M });
|
||||
assert.equal('_trailFloorPosition' in tracking, layer.floorsTrail);
|
||||
tracking._appendTrailFix(geo.cartesian3.fromDegrees(-106.6, 35.05, 10));
|
||||
const height = heightOf(flightState._trailPositions[0]);
|
||||
assert.ok(
|
||||
Math.abs(height - (layer.floorsTrail ? GROUND_M : 10)) < 0.01,
|
||||
`trail height ${height}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`${layer.name} backfill places and thins history by its own rules`, async () => {
|
||||
const leading = harness(layer, {
|
||||
track: [{ observedAtMs: 1000, latitude: 35, longitude: -106 }],
|
||||
});
|
||||
leading.flightState._trackedIcao = 'abc123';
|
||||
await leading.tracking._backfillTrail('abc123', 0, Infinity);
|
||||
assert.equal(leading.flightState._trailPositions.length, 1);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
heightOf(leading.flightState._trailPositions[0]) -
|
||||
layer.backfillLeadAltM,
|
||||
) < 0.01,
|
||||
);
|
||||
|
||||
const long = harness(layer, {
|
||||
track: Array.from({ length: 1000 }, (_, index) => ({
|
||||
observedAtMs: 1000 + index * 1000,
|
||||
latitude: 35 + index * 0.001,
|
||||
longitude: -106,
|
||||
baroAltitudeM: 3000,
|
||||
})),
|
||||
});
|
||||
long.flightState._trackedIcao = 'abc123';
|
||||
await long.tracking._backfillTrail('abc123', 0, Infinity);
|
||||
// 1000 points into a 400-vertex trail: stride-thinned (every 3rd) or
|
||||
// only the newest 400 kept.
|
||||
assert.equal(
|
||||
long.flightState._trailPositions.length,
|
||||
layer.thinsBackfill ? 334 : 400,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -22,11 +22,10 @@ const zeroCounts = () => ({
|
||||
const world = ([lon, lat]) => geo.cartesian3.fromDegrees(lon, lat);
|
||||
|
||||
export function createCyclonesNavaraRenderer(engine, view, { overlayHost }) {
|
||||
const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({
|
||||
polyline: { color, show, width: 2.5, clampToGround: true },
|
||||
const display = createNavaraPolygonDisplay(engine, view, (color) => ({
|
||||
polyline: { color, width: 2.5, clampToGround: true },
|
||||
polygon: {
|
||||
color,
|
||||
show,
|
||||
opacity: 0.16,
|
||||
transparent: true,
|
||||
clampToGround: true,
|
||||
|
||||
@@ -1401,9 +1401,8 @@ export function createDirectionsLayer({
|
||||
}
|
||||
|
||||
function flyCurrentRoute() {
|
||||
if (!_route || (!_viewer && !_engine)) return false;
|
||||
if (!_route || !_engine) return false;
|
||||
cancelOwnedFlight('directions-refly');
|
||||
if (_viewer) services.camera.initCameraVerbs(_viewer);
|
||||
const seams = _shellSeams;
|
||||
const result = services.camera.flyRoute(
|
||||
[
|
||||
|
||||
+56
-1063
File diff suppressed because it is too large.
Load diff
@@ -31,23 +31,18 @@ export function createInfrastructureNavaraRenderer(
|
||||
sourceId: id,
|
||||
host: services.overlayHost,
|
||||
});
|
||||
const display = createNavaraPolygonDisplay(
|
||||
engine,
|
||||
native.view,
|
||||
(tint, show) => ({
|
||||
polyline: { color: tint, show, width: 2, clampToGround: true },
|
||||
polygon: {
|
||||
color: tint,
|
||||
show,
|
||||
opacity: 0.3,
|
||||
transparent: true,
|
||||
clampToGround: true,
|
||||
outline: true,
|
||||
outlineColor: tint,
|
||||
outlineWidth: 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const display = createNavaraPolygonDisplay(engine, native.view, (tint) => ({
|
||||
polyline: { color: tint, width: 2, clampToGround: true },
|
||||
polygon: {
|
||||
color: tint,
|
||||
opacity: 0.3,
|
||||
transparent: true,
|
||||
clampToGround: true,
|
||||
outline: true,
|
||||
outlineColor: tint,
|
||||
outlineWidth: 2,
|
||||
},
|
||||
}));
|
||||
let dataset = null,
|
||||
records = new Map(),
|
||||
active = new Set(),
|
||||
|
||||
+56
-1079
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,13 @@
|
||||
/** GeoJSON features grouped by paint. Swap complete batches so a failed
|
||||
* source or layer allocation cannot wipe the last rendered result. */
|
||||
* source or layer allocation cannot wipe the last rendered result.
|
||||
*
|
||||
* Hidden means no layers at all: Navara 0.2.1 sometimes drops the first
|
||||
* `layer.update({ show: false })` after a layer is added (a zone turned off
|
||||
* right after an engine switch stayed drawn), while a deleted layer always
|
||||
* goes. Showing again rebuilds the batches from the last groups. */
|
||||
export function createNavaraPolygonDisplay(engine, view, style) {
|
||||
let visible = false;
|
||||
let groups = new Map();
|
||||
let batches = [];
|
||||
function release(items) {
|
||||
for (const { layer, source } of items) {
|
||||
@@ -9,43 +15,50 @@ export function createNavaraPolygonDisplay(engine, view, style) {
|
||||
source.delete();
|
||||
}
|
||||
}
|
||||
function build(next) {
|
||||
const built = [];
|
||||
try {
|
||||
for (const [css, features] of next) {
|
||||
const color = Number.parseInt(css.slice(1), 16);
|
||||
const source = view.addSource({
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features },
|
||||
});
|
||||
const batch = { source };
|
||||
built.push(batch);
|
||||
batch.layer = view.addLayer({
|
||||
type: 'vector',
|
||||
source,
|
||||
...style(color),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
release(built);
|
||||
throw error;
|
||||
}
|
||||
release(batches);
|
||||
batches = built;
|
||||
}
|
||||
return {
|
||||
setVisible(show) {
|
||||
if (visible === show) return;
|
||||
visible = show;
|
||||
for (const { color, source, layer } of batches) {
|
||||
layer.update({ type: 'vector', source, ...style(color, show) });
|
||||
if (show) build(groups);
|
||||
else {
|
||||
release(batches);
|
||||
batches = [];
|
||||
}
|
||||
visible = show;
|
||||
engine.render.request('polygon-visibility');
|
||||
},
|
||||
replace(groups) {
|
||||
const next = [];
|
||||
try {
|
||||
for (const [css, features] of groups) {
|
||||
const color = Number.parseInt(css.slice(1), 16);
|
||||
const source = view.addSource({
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features },
|
||||
});
|
||||
const batch = { source, color };
|
||||
next.push(batch);
|
||||
batch.layer = view.addLayer({
|
||||
type: 'vector',
|
||||
source,
|
||||
...style(color, visible),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
release(next);
|
||||
throw error;
|
||||
}
|
||||
release(batches);
|
||||
batches = next;
|
||||
replace(next) {
|
||||
if (visible) build(next);
|
||||
groups = next;
|
||||
engine.render.request('polygon-data');
|
||||
},
|
||||
destroy() {
|
||||
release(batches);
|
||||
batches = [];
|
||||
groups = new Map();
|
||||
engine.render.request('polygon-destroy');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNavaraPolygonDisplay } from './navaraPolygons.js';
|
||||
|
||||
function fakeView({ failOn = null } = {}) {
|
||||
const live = new Set();
|
||||
let added = 0;
|
||||
const handle = (kind, description) => {
|
||||
const item = {
|
||||
kind,
|
||||
description,
|
||||
delete: () => live.delete(item),
|
||||
update: () => assert.fail('the display never updates a layer in place'),
|
||||
};
|
||||
live.add(item);
|
||||
return item;
|
||||
};
|
||||
return {
|
||||
live,
|
||||
layers: () => [...live].filter((item) => item.kind === 'layer'),
|
||||
addSource: (description) => handle('source', description),
|
||||
addLayer: (description) => {
|
||||
if (++added === failOn) throw new Error('layer allocation failed');
|
||||
return handle('layer', description);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const engine = { render: { request() {} } };
|
||||
const groups = (...colors) =>
|
||||
new Map(colors.map((css) => [css, [{ type: 'Feature', id: css }]]));
|
||||
|
||||
test('a hidden display holds no Navara layers, and showing it rebuilds them', () => {
|
||||
const view = fakeView();
|
||||
const display = createNavaraPolygonDisplay(engine, view, (color) => ({
|
||||
polygon: { color },
|
||||
}));
|
||||
display.replace(groups('#ff0000', '#00ff00'));
|
||||
assert.equal(view.live.size, 0, 'nothing is built while hidden');
|
||||
display.setVisible(true);
|
||||
assert.deepEqual(
|
||||
view.layers().map((layer) => layer.description.polygon.color),
|
||||
[0xff0000, 0x00ff00],
|
||||
);
|
||||
// Navara 0.2.1 may drop a show:false update on a new layer: hiding deletes.
|
||||
display.setVisible(false);
|
||||
assert.equal(view.live.size, 0);
|
||||
display.setVisible(true);
|
||||
assert.equal(view.layers().length, 2);
|
||||
display.destroy();
|
||||
assert.equal(view.live.size, 0);
|
||||
});
|
||||
|
||||
test('a visible display swaps whole batches and keeps the last on a failure', () => {
|
||||
const view = fakeView({ failOn: 3 });
|
||||
const display = createNavaraPolygonDisplay(engine, view, (color) => ({
|
||||
polygon: { color },
|
||||
}));
|
||||
display.setVisible(true);
|
||||
display.replace(groups('#ff0000'));
|
||||
display.replace(groups('#0000ff'));
|
||||
assert.deepEqual(
|
||||
view.layers().map((layer) => layer.description.polygon.color),
|
||||
[0x0000ff],
|
||||
);
|
||||
assert.throws(
|
||||
() => display.replace(groups('#00ff00')),
|
||||
/layer allocation failed/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
view.layers().map((layer) => layer.description.polygon.color),
|
||||
[0x0000ff],
|
||||
);
|
||||
assert.equal(view.live.size, 2, 'the failed batch released its source');
|
||||
// The failed rows were not adopted: hiding and showing redraws the last.
|
||||
display.setVisible(false);
|
||||
display.setVisible(true);
|
||||
assert.deepEqual(
|
||||
view.layers().map((layer) => layer.description.polygon.color),
|
||||
[0x0000ff],
|
||||
);
|
||||
});
|
||||
@@ -14,17 +14,15 @@ export function createZonesNavaraRenderer(
|
||||
{ idPrefix, colorOf },
|
||||
) {
|
||||
let polygons = [];
|
||||
const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({
|
||||
const display = createNavaraPolygonDisplay(engine, view, (color) => ({
|
||||
polygon: {
|
||||
color,
|
||||
show,
|
||||
clampToGround: true,
|
||||
transparent: true,
|
||||
opacity: 0.25,
|
||||
},
|
||||
polyline: {
|
||||
color,
|
||||
show,
|
||||
clampToGround: true,
|
||||
width: 2,
|
||||
transparent: true,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
routeSpeedProfile,
|
||||
signedTurnRad,
|
||||
} from './cameraVerbs.js';
|
||||
import { createCameraViewer } from './globe/cesium/testing/cameraViewer.mjs';
|
||||
|
||||
const FRAME_S = 1 / 60;
|
||||
const CRUISE_M_S = { slow: 20, normal: 40, fast: 90 };
|
||||
@@ -58,68 +59,43 @@ function rollDegOf(eye, direction, up) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A viewer whose camera actually MODELS orientation: setView with a
|
||||
* direction/up pair updates heading/pitch/roll the way Cesium's own
|
||||
* decomposition does, and setView with an explicit roll writes it back. Without
|
||||
* that, a pin can only prove the code called setView — not that the horizon
|
||||
* the user is left holding is level.
|
||||
* The Cesium engine over a real Cesium camera, whose setView calls are
|
||||
* recorded. Its heading/pitch/roll are Cesium's own decomposition of the
|
||||
* camera it is left with, so a pin proves the horizon the user is left
|
||||
* holding is level, not only that the code called setView.
|
||||
*/
|
||||
function createTickableViewer({ heightM = 700 } = {}) {
|
||||
const listeners = [];
|
||||
const setViews = [];
|
||||
// The tick derives dt from the wall clock, so a synchronous test loop would
|
||||
// advance the flight by ~0 no matter how many times it ticked. Stub the
|
||||
// clock and step it explicitly.
|
||||
const realPerformance = globalThis.performance;
|
||||
let nowMs = 0;
|
||||
globalThis.performance = { now: () => nowMs };
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.76, 30.26, heightM);
|
||||
const camera = {
|
||||
positionWC: position,
|
||||
positionCartographic: Cesium.Cartographic.fromCartesian(position),
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(-45),
|
||||
roll: 0,
|
||||
cancelFlight() {},
|
||||
lookAtTransform() {},
|
||||
setView(options) {
|
||||
setViews.push(options);
|
||||
const orientation = options?.orientation;
|
||||
// A destination ALWAYS moves the camera, whatever orientation form came
|
||||
// with it. Branching on the orientation shape instead would let a
|
||||
// levelling call that also passed a destination slip past the pose pin.
|
||||
if (options?.destination) camera.positionWC = options.destination;
|
||||
if (orientation?.direction) {
|
||||
camera.roll = Cesium.Math.toRadians(rollDegOf(
|
||||
options?.destination || camera.positionWC, orientation.direction, orientation.up,
|
||||
));
|
||||
}
|
||||
if (orientation && Number.isFinite(orientation.roll)) camera.roll = orientation.roll;
|
||||
if (orientation && Number.isFinite(orientation.heading)) camera.heading = orientation.heading;
|
||||
if (orientation && Number.isFinite(orientation.pitch)) camera.pitch = orientation.pitch;
|
||||
},
|
||||
};
|
||||
const viewer = {
|
||||
trackedEntity: undefined,
|
||||
clock: { onTick: { addEventListener: (fn) => { listeners.push(fn); return () => {}; } } },
|
||||
scene: {
|
||||
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||
tweens: [],
|
||||
requestRender() {},
|
||||
// A live app has a rendered surface under the route; the mesh probe reads
|
||||
// it, so these flights start immediately rather than arming.
|
||||
sampleHeight: () => 0,
|
||||
},
|
||||
camera,
|
||||
const { viewer, scene, camera, engine, frame } = createCameraViewer();
|
||||
// A live app has a rendered surface under the route; the mesh probe reads
|
||||
// it, so these flights start immediately rather than arming.
|
||||
scene.sampleHeightSupported = true;
|
||||
scene.sampleHeight = () => 0;
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(-97.76, 30.26, heightM),
|
||||
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45), roll: 0 },
|
||||
});
|
||||
const setViews = [];
|
||||
const setView = camera.setView;
|
||||
camera.setView = function (options) {
|
||||
setViews.push(options);
|
||||
return setView.call(this, options);
|
||||
};
|
||||
return {
|
||||
viewer,
|
||||
engine,
|
||||
setViews,
|
||||
tick: (dtS = FRAME_S) => {
|
||||
nowMs += dtS * 1000;
|
||||
for (const fn of listeners) fn();
|
||||
frame();
|
||||
},
|
||||
rollDeg: () => Cesium.Math.toDegrees(camera.roll),
|
||||
rollDeg: () =>
|
||||
Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(camera.roll)),
|
||||
restore: () => { globalThis.performance = realPerformance; },
|
||||
};
|
||||
}
|
||||
@@ -789,7 +765,7 @@ test('the reduced-motion PREFERENCE reaches a real flight, not just the flag', (
|
||||
assert.ok(queries.some((q) => /prefers-reduced-motion:\s*reduce/.test(q)), `queried ${queries}`);
|
||||
|
||||
const viewer = createTickableViewer();
|
||||
initCameraVerbs(viewer.viewer, () => null);
|
||||
initCameraVerbs(viewer.engine, () => null);
|
||||
const started = flyRoute([{
|
||||
type: 'route', label: 'reduced', path: TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 })),
|
||||
}]);
|
||||
@@ -886,7 +862,7 @@ test('approachValue is frame-rate independent and adopts a cold value', () => {
|
||||
|
||||
test('an interrupt MID-BANK stops the dolly and puts the horizon back level', () => {
|
||||
const viewer = createTickableViewer();
|
||||
initCameraVerbs(viewer.viewer, () => null);
|
||||
initCameraVerbs(viewer.engine, () => null);
|
||||
|
||||
const started = flyRoute([{
|
||||
type: 'route',
|
||||
@@ -923,17 +899,23 @@ test('an interrupt MID-BANK stops the dolly and puts the horizon back level', ()
|
||||
`the horizon was left tilted at ${viewer.rollDeg().toFixed(3)}° after the cut`,
|
||||
);
|
||||
// Levelling preserves where the camera was looking — it is not a re-frame.
|
||||
// Asserted NUMERICALLY, not just by the omitted destination: position,
|
||||
// heading and pitch must come out the far side unchanged.
|
||||
// Asserted NUMERICALLY: position, heading and pitch must come out the far
|
||||
// side unchanged.
|
||||
const levelling = viewer.setViews.at(-1);
|
||||
assert.equal(levelling.orientation.roll, 0);
|
||||
assert.equal(levelling.destination, undefined, 'levelling must not move the camera');
|
||||
assert.equal(
|
||||
Cesium.Cartesian3.distance(viewer.viewer.camera.positionWC, poseAtCut.position), 0,
|
||||
'the camera moved while being levelled',
|
||||
);
|
||||
assert.equal(viewer.viewer.camera.heading, poseAtCut.heading, 'heading changed while levelling');
|
||||
assert.equal(viewer.viewer.camera.pitch, poseAtCut.pitch, 'pitch changed while levelling');
|
||||
// Cesium re-derives heading and pitch from the basis: equal to rounding.
|
||||
assert.ok(
|
||||
Math.abs(viewer.viewer.camera.heading - poseAtCut.heading) < 1e-12,
|
||||
'heading changed while levelling',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(viewer.viewer.camera.pitch - poseAtCut.pitch) < 1e-12,
|
||||
'pitch changed while levelling',
|
||||
);
|
||||
|
||||
const writesAtCut = viewer.setViews.length;
|
||||
viewer.tick();
|
||||
@@ -948,7 +930,7 @@ test('an interrupt MID-BANK stops the dolly and puts the horizon back level', ()
|
||||
|
||||
test('a completed dolly lands wings level and releases the slot', () => {
|
||||
const viewer = createTickableViewer();
|
||||
initCameraVerbs(viewer.viewer, () => null);
|
||||
initCameraVerbs(viewer.engine, () => null);
|
||||
// A route that is still turning as it ends — the case where a bank would
|
||||
// otherwise be frozen into the final frame.
|
||||
const started = flyRoute([{
|
||||
@@ -1010,7 +992,7 @@ test('a flight can be stopped by its owner, and only while it is still theirs',
|
||||
// somewhere, a tracked aircraft took over — and stopping that would be a
|
||||
// feature reaching across the app to cancel a stranger's motion.
|
||||
const viewer = createTickableViewer();
|
||||
initCameraVerbs(viewer.viewer, () => null);
|
||||
initCameraVerbs(viewer.engine, () => null);
|
||||
const path = TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 }));
|
||||
|
||||
assert.equal(activeCameraMotionId(), 0, 'an idle camera has no motion id');
|
||||
@@ -1053,7 +1035,7 @@ test('a new viewer lifetime drops the previous view-target getter', () => {
|
||||
const first = createTickableViewer();
|
||||
const target = Cesium.Cartesian3.fromDegrees(-97.76, 30.26, 0);
|
||||
let asked = 0;
|
||||
initCameraVerbs(first.viewer, () => { asked += 1; return target; });
|
||||
initCameraVerbs(first.engine, () => { asked += 1; return target; });
|
||||
const orbit = moveCamera({ motion: 'orbit', mode: 'continuous' });
|
||||
assert.equal(orbit.ok, true);
|
||||
assert.equal(orbit.armed, undefined, 'the installed getter supplied a target');
|
||||
@@ -1061,7 +1043,7 @@ test('a new viewer lifetime drops the previous view-target getter', () => {
|
||||
interruptCameraMotion('test-cleanup');
|
||||
|
||||
// Same viewer, no getter: the installed one stays.
|
||||
initCameraVerbs(first.viewer);
|
||||
initCameraVerbs(first.engine);
|
||||
const again = moveCamera({ motion: 'orbit', mode: 'continuous' });
|
||||
assert.equal(again.armed, undefined, 'the getter survives a call without one');
|
||||
interruptCameraMotion('test-cleanup');
|
||||
@@ -1070,7 +1052,7 @@ test('a new viewer lifetime drops the previous view-target getter', () => {
|
||||
// New viewer, no getter: the old one is gone, so the orbit arms instead of
|
||||
// capturing a target from a viewer that no longer exists.
|
||||
const second = createTickableViewer();
|
||||
initCameraVerbs(second.viewer);
|
||||
initCameraVerbs(second.engine);
|
||||
const askedBefore = asked;
|
||||
const armed = moveCamera({ motion: 'orbit', mode: 'continuous' });
|
||||
assert.equal(armed.armed, 'waiting-for-arrival');
|
||||
@@ -1081,7 +1063,7 @@ test('a new viewer lifetime drops the previous view-target getter', () => {
|
||||
|
||||
test('a route hidden from Draw is not flown; the newest visible one is', () => {
|
||||
const viewer = createTickableViewer();
|
||||
initCameraVerbs(viewer.viewer, () => null);
|
||||
initCameraVerbs(viewer.engine, () => null);
|
||||
const route = (label, hidden) => ({
|
||||
type: 'route',
|
||||
label,
|
||||
|
||||
@@ -46,18 +46,23 @@ export function readLayerSource(file) {
|
||||
: '../layers/vessels',
|
||||
);
|
||||
// A layer's code includes its renderers (render/*.js) and, for
|
||||
// the aircraft layers, the shared aircraft renderer they draw through:
|
||||
// checks on what the layer does keep finding it where it moved.
|
||||
// the aircraft layers, the shared aircraft renderer and tracking they draw
|
||||
// through: checks on what the layer does keep finding it where it moved.
|
||||
const directories = [directory, join(directory, 'render')];
|
||||
if (['flights.js', 'militaryFlights.js'].includes(basename(path)))
|
||||
const shared = [];
|
||||
if (['flights.js', 'militaryFlights.js'].includes(basename(path))) {
|
||||
directories.push(join(directory, '../aircraft/render'));
|
||||
return directories
|
||||
.filter((dir) => existsSync(dir))
|
||||
.flatMap((dir) =>
|
||||
readdirSync(dir)
|
||||
.filter((name) => name.endsWith('.js'))
|
||||
.sort()
|
||||
.map((name) => readFileSync(join(dir, name), 'utf8')),
|
||||
)
|
||||
.join('\n');
|
||||
shared.push(join(directory, '../aircraft/tracking.js'));
|
||||
}
|
||||
return [
|
||||
...directories
|
||||
.filter((dir) => existsSync(dir))
|
||||
.flatMap((dir) =>
|
||||
readdirSync(dir)
|
||||
.filter((name) => name.endsWith('.js'))
|
||||
.sort()
|
||||
.map((name) => readFileSync(join(dir, name), 'utf8')),
|
||||
),
|
||||
...shared.map((file) => readFileSync(file, 'utf8')),
|
||||
].join('\n');
|
||||
}
|
||||
@@ -43,6 +43,12 @@ function createViewer({ cameraPosition, target, pickPosition = null } = {}) {
|
||||
positionWC: position,
|
||||
directionWC: new Cesium.Cartesian3(-1, 0, 0),
|
||||
upWC: new Cesium.Cartesian3(0, 0, 1),
|
||||
// The basis in the camera's reference frame (the world: identity), which
|
||||
// the engine's setView writes.
|
||||
direction: new Cesium.Cartesian3(-1, 0, 0),
|
||||
up: new Cesium.Cartesian3(0, 0, 1),
|
||||
right: new Cesium.Cartesian3(0, 1, 0),
|
||||
inverseTransform: Cesium.Matrix4.IDENTITY,
|
||||
heading: Cesium.Math.toRadians(90),
|
||||
pitch: Cesium.Math.toRadians(-90),
|
||||
roll: 0,
|
||||
|
||||
+27
-43
@@ -3,7 +3,6 @@ import * as Cesium from 'cesium';
|
||||
import { guardCameraAboveGround } from '../cameraGroundGuard.js';
|
||||
import { rangeForBoundingSphere } from '../data/cameraFraming.js';
|
||||
import { cancelCameraArrival } from '../data/cameraArrival.js';
|
||||
import { isGlobeEngine } from '../globe/contract.js';
|
||||
|
||||
/** All visible positions, path vertices, polygon rings, walls and overlay bounds. */
|
||||
export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) {
|
||||
@@ -72,17 +71,17 @@ export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) {
|
||||
|
||||
/**
|
||||
* Fly synchronously to the footprint; later navigation always owns its
|
||||
* camera. `viewer` is a Cesium viewer or a globe engine (Navara), which flies
|
||||
* the same sphere through the contract's flyToSphere.
|
||||
* camera. `engine` is the globe engine (Cesium or Navara), which flies the
|
||||
* sphere through the contract's flyToSphere.
|
||||
*/
|
||||
export function flyToDataSource(
|
||||
viewer,
|
||||
engine,
|
||||
dataSource,
|
||||
{ duration = 1.5, ground = null } = {},
|
||||
) {
|
||||
const points = dataSourcePoints(dataSource);
|
||||
if (!points.length) return false;
|
||||
cancelCameraArrival(viewer);
|
||||
cancelCameraArrival(engine);
|
||||
const positions = points.map(([lon, lat, height]) =>
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat, height),
|
||||
);
|
||||
@@ -92,12 +91,9 @@ export function flyToDataSource(
|
||||
Cesium.Cartographic.fromDegrees(points[0][0], points[0][1]);
|
||||
const lat = Cesium.Math.toDegrees(center.latitude),
|
||||
lon = Cesium.Math.toDegrees(center.longitude);
|
||||
const engine = isGlobeEngine(viewer) ? viewer : null;
|
||||
const terrain = engine
|
||||
? engine.surface.terrainHeightSupported
|
||||
? engine.surface.terrainHeight(center)
|
||||
: undefined
|
||||
: viewer.scene?.globe?.getHeight?.(center);
|
||||
const terrain = engine.surface.terrainHeightSupported
|
||||
? engine.surface.terrainHeight(center)
|
||||
: undefined;
|
||||
const cached = ground?.cachedGroundFloor?.(lat, lon);
|
||||
const height =
|
||||
Number.isFinite(terrain) && terrain > 0
|
||||
@@ -119,38 +115,26 @@ export function flyToDataSource(
|
||||
sphere.radius,
|
||||
Cesium.Cartesian3.distance(sphere.center, position),
|
||||
);
|
||||
if (engine) {
|
||||
const pose = engine.camera.pose();
|
||||
const aspect = pose.aspectRatio ?? 1;
|
||||
// Cesium's frustum fov: the wider side's angle.
|
||||
const fov =
|
||||
aspect > 1 ? 2 * Math.atan(Math.tan(pose.fovy / 2) * aspect) : pose.fovy;
|
||||
engine.camera.flyToSphere(
|
||||
{ center: sphere.center, radius: sphere.radius },
|
||||
{
|
||||
heading: pose.heading || 0,
|
||||
pitch: -Math.PI / 2,
|
||||
range: Math.max(
|
||||
800,
|
||||
rangeForBoundingSphere(
|
||||
{ camera: { frustum: { fov, aspectRatio: aspect } } },
|
||||
sphere.radius,
|
||||
),
|
||||
const pose = engine.camera.pose();
|
||||
const aspect = pose.aspectRatio ?? 1;
|
||||
// Cesium's frustum fov: the wider side's angle.
|
||||
const fov =
|
||||
aspect > 1 ? 2 * Math.atan(Math.tan(pose.fovy / 2) * aspect) : pose.fovy;
|
||||
engine.camera.flyToSphere(
|
||||
{ center: sphere.center, radius: sphere.radius },
|
||||
{
|
||||
heading: pose.heading || 0,
|
||||
pitch: -Math.PI / 2,
|
||||
range: Math.max(
|
||||
800,
|
||||
rangeForBoundingSphere(
|
||||
{ camera: { frustum: { fov, aspectRatio: aspect } } },
|
||||
sphere.radius,
|
||||
),
|
||||
duration,
|
||||
complete: () => guardCameraAboveGround(engine, { lat, lon }),
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
viewer.camera?.flyToBoundingSphere?.(sphere, {
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
viewer.camera.heading || 0,
|
||||
-Math.PI / 2,
|
||||
Math.max(800, rangeForBoundingSphere(viewer, sphere.radius)),
|
||||
),
|
||||
duration,
|
||||
complete: () => guardCameraAboveGround(viewer, { lat, lon }),
|
||||
});
|
||||
),
|
||||
duration,
|
||||
complete: () => guardCameraAboveGround(engine, { lat, lon }),
|
||||
},
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -2,8 +2,21 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { cancelCameraArrival } from '../data/cameraArrival.js';
|
||||
import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js';
|
||||
import { createFakeViewer } from '../globe/cesium/testing/fakeViewer.mjs';
|
||||
import { dataSourcePoints, flyToDataSource } from './flyToDataSource.js';
|
||||
|
||||
/** The Cesium engine over a recording viewer, and the sphere flights it made. */
|
||||
function cesiumEngine() {
|
||||
const viewer = createFakeViewer();
|
||||
const engine = createCesiumEngine(viewer, { requestRender() {} });
|
||||
const calls = () =>
|
||||
viewer
|
||||
.callsOf('camera.flyToBoundingSphere')
|
||||
.map(({ args: [sphere, options] }) => ({ sphere, options }));
|
||||
return { viewer, engine, calls };
|
||||
}
|
||||
|
||||
function source() {
|
||||
const ds = new Cesium.CustomDataSource('t');
|
||||
ds.entities.add({
|
||||
@@ -35,29 +48,23 @@ test('points come from positions, lines and polygons, as lon/lat only', () => {
|
||||
});
|
||||
|
||||
test('the camera frames the footprint at the surface, never a buried center', () => {
|
||||
const calls = [];
|
||||
const viewer = {
|
||||
camera: {
|
||||
heading: 0,
|
||||
flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }),
|
||||
},
|
||||
flyTo: () => calls.push('viewer.flyTo'),
|
||||
};
|
||||
assert.equal(flyToDataSource(viewer, source()), true);
|
||||
const center = Cesium.Cartographic.fromCartesian(calls[0].sphere.center);
|
||||
const { engine, calls } = cesiumEngine();
|
||||
assert.equal(flyToDataSource(engine, source()), true);
|
||||
const [flight] = calls();
|
||||
const center = Cesium.Cartographic.fromCartesian(flight.sphere.center);
|
||||
assert.ok(Math.abs(center.height) < 50, `center height ${center.height}`);
|
||||
assert.ok(calls[0].options.offset.range >= 800);
|
||||
assert.ok(flight.options.offset.range >= 800);
|
||||
assert.equal(
|
||||
typeof calls[0].options.complete,
|
||||
typeof flight.options.complete,
|
||||
'function',
|
||||
'the ground guard runs on landing',
|
||||
);
|
||||
assert.equal(
|
||||
flyToDataSource(viewer, new Cesium.CustomDataSource('empty')),
|
||||
flyToDataSource(engine, new Cesium.CustomDataSource('empty')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
calls.length,
|
||||
calls().length,
|
||||
1,
|
||||
'empty sources never start deferred navigation',
|
||||
);
|
||||
@@ -112,24 +119,24 @@ test('portrait framing accounts for FOV, high ground and symmetric extents', ()
|
||||
positions: Cesium.Cartesian3.fromDegreesArray([-105, 39, -104.9, 39]),
|
||||
},
|
||||
});
|
||||
const calls = [];
|
||||
const viewer = {
|
||||
scene: { globe: { getHeight: () => 2600 } },
|
||||
camera: {
|
||||
frustum: { fov: Math.PI / 3, aspectRatio: 390 / 844 },
|
||||
flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }),
|
||||
},
|
||||
};
|
||||
flyToDataSource(viewer, ds);
|
||||
const { viewer, engine, calls } = cesiumEngine();
|
||||
viewer.scene.globe.getHeight = () => 2600;
|
||||
// A 60° field of view on the narrower side, as Cesium's frustum keeps it:
|
||||
// vertical in portrait, horizontal in landscape.
|
||||
const { frustum } = viewer.camera;
|
||||
frustum.aspectRatio = 390 / 844;
|
||||
frustum.fovy = Math.PI / 3;
|
||||
flyToDataSource(engine, ds);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(calls[0].sphere.center).height - 2600,
|
||||
Cesium.Cartographic.fromCartesian(calls()[0].sphere.center).height - 2600,
|
||||
) < 1e-6,
|
||||
);
|
||||
assert.equal(calls[0].options.offset.pitch, -Math.PI / 2);
|
||||
viewer.camera.frustum.aspectRatio = 16 / 9;
|
||||
flyToDataSource(viewer, ds);
|
||||
assert.ok(calls[0].options.offset.range > calls[1].options.offset.range);
|
||||
assert.equal(calls()[0].options.offset.pitch, -Math.PI / 2);
|
||||
frustum.aspectRatio = 16 / 9;
|
||||
frustum.fovy = 2 * Math.atan(Math.tan(Math.PI / 6) / frustum.aspectRatio);
|
||||
flyToDataSource(engine, ds);
|
||||
assert.ok(calls()[0].options.offset.range > calls()[1].options.offset.range);
|
||||
const symmetric = {
|
||||
entities: {
|
||||
values: [
|
||||
@@ -138,10 +145,10 @@ test('portrait framing accounts for FOV, high ground and symmetric extents', ()
|
||||
],
|
||||
},
|
||||
};
|
||||
assert.doesNotThrow(() => flyToDataSource(viewer, symmetric));
|
||||
assert.ok(Number.isFinite(calls.at(-1).options.offset.range));
|
||||
assert.doesNotThrow(() => calls.at(-1).options.complete());
|
||||
cancelCameraArrival(viewer);
|
||||
assert.doesNotThrow(() => flyToDataSource(engine, symmetric));
|
||||
assert.ok(Number.isFinite(calls().at(-1).options.offset.range));
|
||||
assert.doesNotThrow(() => calls().at(-1).options.complete());
|
||||
cancelCameraArrival(engine);
|
||||
});
|
||||
|
||||
test('non-finite positions and hidden features never enter the camera footprint', () => {
|
||||
|
||||
@@ -30,7 +30,8 @@ function localizedValues(values) {
|
||||
* and returns their disconnect.
|
||||
*
|
||||
* The imported files belong to the session; their drawing belongs to the
|
||||
* attached globe. `attachScene({ viewer })` draws them on a Cesium viewer;
|
||||
* attached globe. `attachScene({ viewer, engine })` draws them on a Cesium
|
||||
* viewer (its engine flies the camera to them);
|
||||
* `attachScene({ engine, draw })` on a globe engine without one (Navara),
|
||||
* where `draw(dataSource)` resolves with that engine's drawing of a file
|
||||
* ({ report, setShow, refresh, destroy }: src/layers/shared/render/navaraStory.js).
|
||||
@@ -67,7 +68,8 @@ export function createGeoFileImport({
|
||||
let hint = '';
|
||||
const lifetime = new AbortController();
|
||||
// The attached globe ({ viewer, engine, draw, controller }): a Cesium
|
||||
// viewer, or an engine and its drawing; null while none is attached.
|
||||
// viewer and its engine, or an engine and its drawing; null while none is
|
||||
// attached.
|
||||
let scene = null;
|
||||
|
||||
const listen = (target, type, listener, options) => {
|
||||
@@ -186,9 +188,9 @@ export function createGeoFileImport({
|
||||
}
|
||||
|
||||
const fly = (item) => {
|
||||
if (!scene || !item.dataSource) return false;
|
||||
if (!scene?.engine || !item.dataSource) return false;
|
||||
if (!item.visible) setVisible(item.id, true);
|
||||
flyToSource(scene.viewer ?? scene.engine, item.dataSource, { ground });
|
||||
flyToSource(scene.engine, item.dataSource, { ground });
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -471,7 +473,7 @@ export function createGeoFileImport({
|
||||
if (typeof offMapSource === 'function') groundRemovers.push(offMapSource);
|
||||
const attached = (scene = {
|
||||
viewer,
|
||||
engine: viewer ? null : engine,
|
||||
engine,
|
||||
draw: viewer ? null : drawOnEngine,
|
||||
controller: new AbortController(),
|
||||
});
|
||||
|
||||
@@ -90,6 +90,9 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
|
||||
},
|
||||
flyTo: (target) => flights.push(target),
|
||||
};
|
||||
// The viewer's globe engine, which flies the camera to a file.
|
||||
const engine = { kind: 'cesium' };
|
||||
const flownOn = [];
|
||||
const revoked = [];
|
||||
const loads = [];
|
||||
const defaultLoad = async (file, opts) => {
|
||||
@@ -110,7 +113,12 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
|
||||
document,
|
||||
...(navigation ? { navigation } : {}),
|
||||
...(subscribeMapSource ? { subscribeMapSource } : {}),
|
||||
flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)),
|
||||
flyToSource:
|
||||
flyToSource ||
|
||||
((target, ds) => {
|
||||
flownOn.push(target);
|
||||
flights.push(ds);
|
||||
}),
|
||||
publish: (view) => {
|
||||
shown = view;
|
||||
},
|
||||
@@ -119,15 +127,17 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
|
||||
return () => (actions = null);
|
||||
},
|
||||
});
|
||||
tool?.attachScene({ viewer });
|
||||
tool?.attachScene({ viewer, engine });
|
||||
return {
|
||||
tool,
|
||||
els,
|
||||
document,
|
||||
viewer,
|
||||
engine,
|
||||
added,
|
||||
removed,
|
||||
flights,
|
||||
flownOn,
|
||||
revoked,
|
||||
loads,
|
||||
renders: () => renders,
|
||||
@@ -166,6 +176,7 @@ test('a pick imports each file', async () => {
|
||||
);
|
||||
assert.equal(rows(t).length, 2);
|
||||
assert.equal(t.flights.length, 1, 'the first file of a batch is flown to');
|
||||
assert.equal(t.flownOn[0], t.engine, "the viewer's engine flies there");
|
||||
assert.equal(t.shown().hint, 'Added 2 files.');
|
||||
assert.deepEqual(
|
||||
t.tool.list().map((f) => f.name),
|
||||
|
||||
@@ -375,9 +375,8 @@ export function createGevActionRunner({
|
||||
{ memory: analystMemory },
|
||||
));
|
||||
installViewTargetPrewarm(engine);
|
||||
// Keep Cesium's viewer rig; viewerless engines use the contract rig.
|
||||
if (cameraVerbsSupported(viewer ?? engine))
|
||||
initCameraVerbs(viewer ?? engine, () => getViewTargetCartesian(engine), {
|
||||
if (cameraVerbsSupported(engine))
|
||||
initCameraVerbs(engine, () => getViewTargetCartesian(engine), {
|
||||
isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active),
|
||||
});
|
||||
const annotationsAvailable = (method) =>
|
||||
@@ -398,11 +397,9 @@ export function createGevActionRunner({
|
||||
case 'clear_annotations':
|
||||
return annotationsAvailable('clear');
|
||||
case 'move_camera':
|
||||
return cameraVerbsSupported(viewer ?? engine);
|
||||
return cameraVerbsSupported(engine);
|
||||
case 'fly_route':
|
||||
return (
|
||||
cameraVerbsSupported(viewer ?? engine) && annotationsAvailable('list')
|
||||
);
|
||||
return cameraVerbsSupported(engine) && annotationsAvailable('list');
|
||||
case 'set_visual_style':
|
||||
return renderSupported('visualStyles', 'setStyle');
|
||||
case 'set_cyber_sonar':
|
||||
|
||||
@@ -163,6 +163,8 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) {
|
||||
},
|
||||
globe: { getHeight: () => 0 },
|
||||
tweens: [],
|
||||
// The frame event a camera verb advances on (through the engine).
|
||||
preRender: new Cesium.Event(),
|
||||
},
|
||||
camera: {
|
||||
moveEnd: { addEventListener() {} },
|
||||
|
||||
@@ -93,7 +93,7 @@ function fixture(t, kind, { native = kind === 'cesium' } = {}) {
|
||||
...options,
|
||||
floorServices: { cachedGroundFloor: () => 0, warmGroundFloor: () => {} },
|
||||
});
|
||||
t.after(() => releaseCameraVerbs(viewer ?? engine));
|
||||
t.after(() => releaseCameraVerbs(engine));
|
||||
return { engine, viewer, annotations, styleManager, calls, build };
|
||||
}
|
||||
|
||||
@@ -129,12 +129,9 @@ for (const kind of ['cesium', 'navara']) {
|
||||
const result = await f.build()(action, args);
|
||||
assert.equal(result.ok, true, JSON.stringify(result));
|
||||
assert.equal(result.engine, kind);
|
||||
// The camera verbs drive the engine on both.
|
||||
if (call) assert.equal(f.calls[0][0], call);
|
||||
else
|
||||
assert.equal(
|
||||
getActiveCameraMotion().rig,
|
||||
f.viewer ? 'viewer' : 'engine',
|
||||
);
|
||||
else assert.equal(getActiveCameraMotion().rig, 'engine');
|
||||
if (action === 'set_post_processing') {
|
||||
assert.deepEqual(
|
||||
f.calls.map(([name]) => name),
|
||||
@@ -154,10 +151,7 @@ for (const kind of ['cesium', 'navara']) {
|
||||
if (action === 'annotate_map') delete f.annotations.annotate;
|
||||
if (action === 'clear_annotations') delete f.annotations.clear;
|
||||
if (action === 'fly_route') delete f.annotations.list;
|
||||
if (action === 'move_camera') {
|
||||
if (f.viewer) delete f.viewer.clock.onTick;
|
||||
else delete f.engine.camera.lookAt;
|
||||
}
|
||||
if (action === 'move_camera') delete f.engine.camera.lookAt;
|
||||
if (action === 'set_visual_style') {
|
||||
if (f.viewer) delete f.styleManager.setStyle;
|
||||
else delete f.engine.render.setStyle;
|
||||
@@ -203,8 +197,7 @@ for (const kind of ['cesium', 'navara']) {
|
||||
|
||||
test(`${kind}: fly_route also requires camera verbs`, async (t) => {
|
||||
const f = fixture(t, kind);
|
||||
if (f.viewer) delete f.viewer.clock.onTick;
|
||||
else delete f.engine.camera.lookAt;
|
||||
delete f.engine.camera.lookAt;
|
||||
assert.equal((await f.build()('fly_route')).code, UNAVAILABLE_ON_ENGINE);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user