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:
German Khokhlov committed 2026-11-05 21:18:23 +04:00
1 parent 7abf74518f
commit a6c64713ee
46 files changed
+2367 -2894

No files matched your search

+2 -2
View File
@@ -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;
},
+1
View File
@@ -270,6 +270,7 @@ function attachViewerTools(
});
geoFileImport?.attachScene({
viewer,
engine,
navigation: styleManager,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
});
+48 -184
View File
@@ -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
View File
@@ -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));
}
}
/**
+42 -48
View File
@@ -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);
}
});
+4 -1
View File
@@ -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(
+42 -2
View File
@@ -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));
},
+90
View File
@@ -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;
+5 -6
View File
@@ -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';
+1 -2
View File
@@ -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()));
+10 -1
View File
@@ -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();
+27
View File
@@ -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({
+73
View File
@@ -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 };
}
+11 -1
View File
@@ -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', () => []),
+2
View File
@@ -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
+40
View File
@@ -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,
};
}
+68
View File
@@ -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);
});
+14 -7
View File
@@ -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]),
},
+1 -2
View File
@@ -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,
+2 -3
View File
@@ -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
+157
View File
@@ -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,
);
});
}
+2 -3
View File
@@ -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,
+1 -2
View File
@@ -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(
[
File diff suppressed because it is too large. Load diff
+12 -17
View File
@@ -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(),
File diff suppressed because it is too large. Load diff
+40 -27
View File
@@ -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],
);
});
+1 -3
View File
@@ -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,
+42 -60
View File
@@ -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,
+17 -12
View File
@@ -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
View File
@@ -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;
}
+39 -32
View File
@@ -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', () => {
+7 -5
View File
@@ -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(),
});
+13 -2
View File
@@ -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),
+4 -7
View File
@@ -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':
+2
View File
@@ -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() {} },
+5 -12
View File
@@ -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);
});