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

+5
View File
@@ -0,0 +1,5 @@
- Add Navara, an experimental three.js/WASM globe, as a second engine next to
Cesium, switchable live under Display → Globe Engine or with
`?engine=navara`. Layers keep their data across a switch and draw on both
engines; Cesium loads only when a Cesium globe or a Cesium-only feature
needs it (German, @gekh, #1555).
+64 -1
View File
@@ -98,6 +98,69 @@ across the antimeridian, so they cannot use the strict ±180 check as-is, and
`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
implementations.
## Navara globe engine — November 5, 2026
Navara, an experimental three.js/WASM globe (`@navaramap/three` 0.2.1), runs
next to Cesium. It is picked under Display → Globe Engine or with
`?engine=navara`; Cesium stays the default. Switching is live: the page keeps
one application, and only the globe-bound parts move to the next engine.
- **The engine contract** is `src/globe/contract.js`. `src/globe/cesium/*` and
`src/globe/navara/*` implement it, `src/globe/testing/fakeEngine.js` stands
in for tests, and `src/globe/testing/contractSuite.mjs` runs the same checks
on every engine. `movers` and `lines` are optional capabilities: only
Navara has them, and the suite skips their checks elsewhere.
- **The session owns the state.** Catalog, layer models, sources, selection,
camera mode and URL state outlive a switch. A layer that is a session model
gets `attachRenderer(engine)` / `detachRenderer()` instead of being rebuilt,
so its data is not fetched again. The outgoing globe is parked
(`src/standalone/rendererCache.js`), so switching back is warm.
- **Renderers per engine.** Each layer keeps its model and display policy in
engine-neutral files and draws through `render/cesium*.js` or
`render/navara*.js`. Cesium renderers are listed in
`src/app/layers/renderers.js` (`CESIUM_LAYER_RENDERERS`), Navara ones are
loaded by `registerNavaraLayerRenderers` in `src/app/navaraMode.js`. Navara
ground effects (heat, pulses, trails, pads, drapes) are declared with
`defineGroundEffect` (`src/layers/shared/render/navaraGroundEffect.js`);
their insert order is pinned by `navaraGroundOrder.test.mjs`.
- **Cesium only on demand.** The production build loads `/cesium/Cesium.js`
when a Cesium globe starts or a Navara user turns on a feature that still
uses Cesium's maths or entity model (traffic, Street Level, the Bhote Koshi
event and locator, geo import; `src/app/loadCesium.js`). App code does its maths with `src/geo`, a Cesium-free
port of the vector, ellipsoid, transform and time functions that
`bitExact.test.mjs` compares with Cesium bit for bit.
**Invariant: no module reads Cesium while it loads**
(`scripts/check-cesium-load-time.mjs`, run by `npm test`).
- **Boundaries.** Cesium is imported only from `render/cesium*.js` and
`src/globe/cesium/*`; three, postprocessing and Navara only from `navara*`
modules, which load through `import()`. `build/initialChunkGuard.js` fails
the build when one of them reaches an initial chunk, and
`npm run check:main-chunk` holds the main chunk to its gzip budget
(`scripts/main-chunk-baseline.json`). Only `src/globe/navara/internals.js`
touches Navara's private members.
- **Navara 0.2.1 workaround.** The SDK keeps feature-change events of deleted
vector layers in its queue, so the view never reaches idle. They are dropped
each frame (`src/globe/navara/featureQueue.js`).
- **Strict acceptance.** `node scripts/qa-all-layer-switches.mjs <origin> <out>
--serve` builds the commit keyless in a clean worktree and runs every layer
and switch scenario under a deny-by-default network policy, with free map
tiles from a disk cache and a trap that fails any read of Cesium before it
has loaded. Every catalog layer must be asserted on both engines. Point
`PUPPETEER_EXECUTABLE_PATH` at an installed Chrome if Puppeteer's pinned
build is missing. With an OSM base map Navara lights the globe by the real
sun, so scenarios over Austin or San Francisco see a dark frame at local
night and fail their pixel checks; run them in local daylight.
- **Navara 0.2.1 workaround.** The SDK never frees pick batch ids, and the
mover banks rebuild their instanced meshes on each attach, so every switch
leaked one id per instance of capacity (about 260 KiB of WebAssembly memory).
A per-view pool hands released ids back out
(`src/globe/navara/movers/batchIds.js`); the long soak asserts both JS
objects and native memory stay flat.
- **Navara 0.2.1 workaround.** The first `layer.update({ show: false })` after
a vector layer is added is sometimes dropped, so a zone turned off right
after a switch stayed drawn. Polygon displays hide by deleting their layers
and rebuild them when shown (`src/layers/shared/render/navaraPolygons.js`).
## Prohibited & Restricted Airspace — FAA special-use airspace
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
@@ -5585,7 +5648,7 @@ and unreachable upstream (502/504) separately from road geometry.
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per frame of the globe engine (Cesium or Navara) through the globe contract. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, the unchanged Globe View destination is checked against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry fails closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee.
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
- **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped.
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
+3
View File
@@ -368,8 +368,10 @@
"src/layers/military/index.js",
"src/layers/aircraft/index.js",
"src/layers/aircraft/classification.js",
"src/layers/aircraft/tracking.js",
"src/layers/military/ownership.test.mjs",
"src/layers/aircraft/classification.test.mjs",
"src/layers/aircraft/tracking.test.mjs",
"src/data/aisLiveVessels.js",
"src/layers/vessels/cards.js",
"src/layers/vessels/evidence.js",
@@ -1297,6 +1299,7 @@
"src/globe/cesium/engineFromViewer.js",
"src/globe/cesium/engineFromViewer.test.mjs",
"src/globe/cesium/testing/fakeViewer.mjs",
"src/globe/cesium/testing/cameraViewer.mjs",
"src/app/globeEngine.js",
"src/app/globeEngine.test.mjs",
"src/layers/transit/render/cesium.js",
+5
View File
@@ -1352,6 +1352,7 @@
"src/layers/flights/ingestion.js",
"src/layers/flights/lifecycle.js",
"src/layers/aircraft/sceneBinding.js",
"src/layers/aircraft/tracking.js",
"src/layers/flights/motion.js",
"src/layers/flights/policy.js",
"src/layers/flights/queries.js",
@@ -1419,6 +1420,7 @@
"src/layers/military/index.js",
"src/layers/military/ingestion.js",
"src/layers/aircraft/sceneBinding.js",
"src/layers/aircraft/tracking.js",
"src/layers/military/lifecycle.js",
"src/layers/military/motion.js",
"src/layers/military/policy.js",
@@ -2650,6 +2652,7 @@
"src/layers/flights/ingestion.js",
"src/layers/flights/lifecycle.js",
"src/layers/aircraft/sceneBinding.js",
"src/layers/aircraft/tracking.js",
"src/layers/flights/motion.js",
"src/layers/flights/policy.js",
"src/layers/flights/queries.js",
@@ -3414,6 +3417,7 @@
"src/globe/follow.js",
"src/globe/lines.js",
"src/globe/navara/movers/pickIds.js",
"src/globe/navara/movers/batchIds.js",
"src/globe/movers.js",
"src/globe/cesium/follow.js",
"src/globe/cpuPicks.js",
@@ -3789,6 +3793,7 @@
"src/layers/flights/ingestion.js",
"src/layers/flights/lifecycle.js",
"src/layers/aircraft/sceneBinding.js",
"src/layers/aircraft/tracking.js",
"src/layers/flights/motion.js",
"src/layers/flights/policy.js",
"src/layers/flights/queries.js",
+2 -2
View File
@@ -176,8 +176,8 @@ await strict.run(
rows: document.querySelectorAll('#geo-import-list .geo-import-row')
.length,
hint: document.getElementById('geo-import-hint')?.textContent || '',
pins: window.__godsEyeView.engine.movers
.list()
// Movers are a Navara capability; Cesium draws the pins as entities.
pins: (window.__godsEyeView.engine.movers?.list() ?? [])
.filter(({ id }) => id.startsWith('geo-import-'))
.reduce((n, set) => n + set.count, 0),
}));
+12 -10
View File
@@ -18,11 +18,12 @@
// below), then after the warm-up (switch 20): live WebGL
// objects per engine context grow by at most 0.1 per switch (least-squares
// slope over the checkpoints) and by at most 64 overall; no new WebGL
// context; the JS objects grow by at most 10 KiB per switch (heap snapshots
// at the warm-up and the end, V8's compiled code and native memory left out
// and reported apart, with the used heap's slope); no new document; the
// webglcontextlost/restored listener counts on both engine canvases stay as
// they were (each canvas keeps its webglcontextlost listener).
// context; the JS objects grow by at most 10 KiB per switch and native
// memory by at most 32 KiB (heap snapshots at the warm-up and the end, V8's
// compiled code left out and reported apart, with the used heap's slope); no
// new document; the webglcontextlost/restored listener counts on both engine
// canvases stay as they were (each canvas keeps its webglcontextlost
// listener).
//
// node scripts/qa-strict-soak.mjs http://127.0.0.1:4208 --runs=6 --swaps=50
// node scripts/qa-strict-soak.mjs http://127.0.0.1:4208 --runs=1 --swaps=500 --assert
@@ -396,21 +397,22 @@ if (strictMode) {
const perSwitch = (mib) =>
Math.round(((mib * 1024) / (to.swap - from.swap)) * 100) / 100;
const objectGrowth = perSwitch(objects(to) - objects(from));
const nativeGrowth = perSwitch(
(to.typesMiB.native ?? 0) - (from.typesMiB.native ?? 0),
);
checks.push({
check: 'JS heap KiB per switch',
objectsKiBPerSwitch: objectGrowth,
fromMiB: Math.round(objects(from) * 100) / 100,
toMiB: Math.round(objects(to) * 100) / 100,
// Reported, not asserted: the used heap's slope (compiled code
// included), compiled code's and native memory's growth.
// included) and compiled code's growth.
usedSlopePerSwap: Math.round(slope(heap) * 100) / 100,
codeKiBPerSwitch: perSwitch(
(to.typesMiB.code ?? 0) - (from.typesMiB.code ?? 0),
),
nativeKiBPerSwitch: perSwitch(
(to.typesMiB.native ?? 0) - (from.typesMiB.native ?? 0),
),
pass: objectGrowth <= 10,
nativeKiBPerSwitch: nativeGrowth,
pass: objectGrowth <= 10 && nativeGrowth <= 32,
});
checks.push({
check: 'documents',
+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);
});