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
+2291 -2818

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 `src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
implementations. 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 ## Prohibited & Restricted Airspace — FAA special-use airspace
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-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 > 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. > `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. - **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. - **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). - 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/military/index.js",
"src/layers/aircraft/index.js", "src/layers/aircraft/index.js",
"src/layers/aircraft/classification.js", "src/layers/aircraft/classification.js",
"src/layers/aircraft/tracking.js",
"src/layers/military/ownership.test.mjs", "src/layers/military/ownership.test.mjs",
"src/layers/aircraft/classification.test.mjs", "src/layers/aircraft/classification.test.mjs",
"src/layers/aircraft/tracking.test.mjs",
"src/data/aisLiveVessels.js", "src/data/aisLiveVessels.js",
"src/layers/vessels/cards.js", "src/layers/vessels/cards.js",
"src/layers/vessels/evidence.js", "src/layers/vessels/evidence.js",
@@ -1297,6 +1299,7 @@
"src/globe/cesium/engineFromViewer.js", "src/globe/cesium/engineFromViewer.js",
"src/globe/cesium/engineFromViewer.test.mjs", "src/globe/cesium/engineFromViewer.test.mjs",
"src/globe/cesium/testing/fakeViewer.mjs", "src/globe/cesium/testing/fakeViewer.mjs",
"src/globe/cesium/testing/cameraViewer.mjs",
"src/app/globeEngine.js", "src/app/globeEngine.js",
"src/app/globeEngine.test.mjs", "src/app/globeEngine.test.mjs",
"src/layers/transit/render/cesium.js", "src/layers/transit/render/cesium.js",
+5
View File
@@ -1352,6 +1352,7 @@
"src/layers/flights/ingestion.js", "src/layers/flights/ingestion.js",
"src/layers/flights/lifecycle.js", "src/layers/flights/lifecycle.js",
"src/layers/aircraft/sceneBinding.js", "src/layers/aircraft/sceneBinding.js",
"src/layers/aircraft/tracking.js",
"src/layers/flights/motion.js", "src/layers/flights/motion.js",
"src/layers/flights/policy.js", "src/layers/flights/policy.js",
"src/layers/flights/queries.js", "src/layers/flights/queries.js",
@@ -1419,6 +1420,7 @@
"src/layers/military/index.js", "src/layers/military/index.js",
"src/layers/military/ingestion.js", "src/layers/military/ingestion.js",
"src/layers/aircraft/sceneBinding.js", "src/layers/aircraft/sceneBinding.js",
"src/layers/aircraft/tracking.js",
"src/layers/military/lifecycle.js", "src/layers/military/lifecycle.js",
"src/layers/military/motion.js", "src/layers/military/motion.js",
"src/layers/military/policy.js", "src/layers/military/policy.js",
@@ -2650,6 +2652,7 @@
"src/layers/flights/ingestion.js", "src/layers/flights/ingestion.js",
"src/layers/flights/lifecycle.js", "src/layers/flights/lifecycle.js",
"src/layers/aircraft/sceneBinding.js", "src/layers/aircraft/sceneBinding.js",
"src/layers/aircraft/tracking.js",
"src/layers/flights/motion.js", "src/layers/flights/motion.js",
"src/layers/flights/policy.js", "src/layers/flights/policy.js",
"src/layers/flights/queries.js", "src/layers/flights/queries.js",
@@ -3414,6 +3417,7 @@
"src/globe/follow.js", "src/globe/follow.js",
"src/globe/lines.js", "src/globe/lines.js",
"src/globe/navara/movers/pickIds.js", "src/globe/navara/movers/pickIds.js",
"src/globe/navara/movers/batchIds.js",
"src/globe/movers.js", "src/globe/movers.js",
"src/globe/cesium/follow.js", "src/globe/cesium/follow.js",
"src/globe/cpuPicks.js", "src/globe/cpuPicks.js",
@@ -3789,6 +3793,7 @@
"src/layers/flights/ingestion.js", "src/layers/flights/ingestion.js",
"src/layers/flights/lifecycle.js", "src/layers/flights/lifecycle.js",
"src/layers/aircraft/sceneBinding.js", "src/layers/aircraft/sceneBinding.js",
"src/layers/aircraft/tracking.js",
"src/layers/flights/motion.js", "src/layers/flights/motion.js",
"src/layers/flights/policy.js", "src/layers/flights/policy.js",
"src/layers/flights/queries.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') rows: document.querySelectorAll('#geo-import-list .geo-import-row')
.length, .length,
hint: document.getElementById('geo-import-hint')?.textContent || '', hint: document.getElementById('geo-import-hint')?.textContent || '',
pins: window.__godsEyeView.engine.movers // Movers are a Navara capability; Cesium draws the pins as entities.
.list() pins: (window.__godsEyeView.engine.movers?.list() ?? [])
.filter(({ id }) => id.startsWith('geo-import-')) .filter(({ id }) => id.startsWith('geo-import-'))
.reduce((n, set) => n + set.count, 0), .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 // below), then after the warm-up (switch 20): live WebGL
// objects per engine context grow by at most 0.1 per switch (least-squares // 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 // 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 // context; the JS objects grow by at most 10 KiB per switch and native
// at the warm-up and the end, V8's compiled code and native memory left out // memory by at most 32 KiB (heap snapshots at the warm-up and the end, V8's
// and reported apart, with the used heap's slope); no new document; the // compiled code left out and reported apart, with the used heap's slope); no
// webglcontextlost/restored listener counts on both engine canvases stay as // new document; the webglcontextlost/restored listener counts on both engine
// they were (each canvas keeps its webglcontextlost listener). // 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=6 --swaps=50
// node scripts/qa-strict-soak.mjs http://127.0.0.1:4208 --runs=1 --swaps=500 --assert // 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) => const perSwitch = (mib) =>
Math.round(((mib * 1024) / (to.swap - from.swap)) * 100) / 100; Math.round(((mib * 1024) / (to.swap - from.swap)) * 100) / 100;
const objectGrowth = perSwitch(objects(to) - objects(from)); const objectGrowth = perSwitch(objects(to) - objects(from));
const nativeGrowth = perSwitch(
(to.typesMiB.native ?? 0) - (from.typesMiB.native ?? 0),
);
checks.push({ checks.push({
check: 'JS heap KiB per switch', check: 'JS heap KiB per switch',
objectsKiBPerSwitch: objectGrowth, objectsKiBPerSwitch: objectGrowth,
fromMiB: Math.round(objects(from) * 100) / 100, fromMiB: Math.round(objects(from) * 100) / 100,
toMiB: Math.round(objects(to) * 100) / 100, toMiB: Math.round(objects(to) * 100) / 100,
// Reported, not asserted: the used heap's slope (compiled code // 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, usedSlopePerSwap: Math.round(slope(heap) * 100) / 100,
codeKiBPerSwitch: perSwitch( codeKiBPerSwitch: perSwitch(
(to.typesMiB.code ?? 0) - (from.typesMiB.code ?? 0), (to.typesMiB.code ?? 0) - (from.typesMiB.code ?? 0),
), ),
nativeKiBPerSwitch: perSwitch( nativeKiBPerSwitch: nativeGrowth,
(to.typesMiB.native ?? 0) - (from.typesMiB.native ?? 0), pass: objectGrowth <= 10 && nativeGrowth <= 32,
),
pass: objectGrowth <= 10,
}); });
checks.push({ checks.push({
check: 'documents', check: 'documents',
+2 -2
View File
@@ -153,7 +153,6 @@ async function createRenderer({
creditContainer, creditContainer,
}); });
defer(() => { defer(() => {
releaseCameraVerbs(viewer);
uninstallRenderGovernor(viewer); uninstallRenderGovernor(viewer);
if (!viewer.isDestroyed()) viewer.destroy(); if (!viewer.isDestroyed()) viewer.destroy();
}); });
@@ -161,6 +160,8 @@ async function createRenderer({
// once it loads. // once it loads.
const tilesetRef = { current: null }; const tilesetRef = { current: null };
const engine = createSceneEngine(viewer, { tilesetRef, defer }); const engine = createSceneEngine(viewer, { tilesetRef, defer });
// As the Navara scene above: a released engine keeps no camera verb.
defer(() => releaseCameraVerbs(engine));
defer(installTrackpadPinchZoom(viewer)); defer(installTrackpadPinchZoom(viewer));
registerDataCredits(engine, credits); registerDataCredits(engine, credits);
configureCreditKeyboardAccess(document); configureCreditKeyboardAccess(document);
@@ -243,7 +244,6 @@ async function createRenderer({
suspend() { suspend() {
uninstallRenderGovernor(viewer); uninstallRenderGovernor(viewer);
engine.camera.cancelFlight(); engine.camera.cancelFlight();
releaseCameraVerbs(viewer);
releaseCameraVerbs(engine); releaseCameraVerbs(engine);
viewer.useDefaultRenderLoop = false; viewer.useDefaultRenderLoop = false;
}, },
+1
View File
@@ -270,6 +270,7 @@ function attachViewerTools(
}); });
geoFileImport?.attachScene({ geoFileImport?.attachScene({
viewer, viewer,
engine,
navigation: styleManager, navigation: styleManager,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener), subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
}); });
+38 -174
View File
@@ -13,6 +13,8 @@ import {
moveCamera, moveCamera,
} from './cameraVerbs.js'; } from './cameraVerbs.js';
import { getRenderGovernorDiagnostics } from './renderGovernor.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 radians = Cesium.Math.toRadians;
const clone = (value) => Cesium.Cartesian3.clone(value); 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( function harness(
t, t,
{ {
@@ -104,7 +109,6 @@ function harness(
heading = 0, heading = 0,
pitch = -90, pitch = -90,
roll = 0, roll = 0,
ellipsoid = Cesium.Ellipsoid.WGS84,
viewportWidth = 1280, viewportWidth = 1280,
viewportHeight = 800, viewportHeight = 800,
} = {}, } = {},
@@ -112,127 +116,33 @@ function harness(
const realPerformance = globalThis.performance; const realPerformance = globalThis.performance;
let nowMs = 0; let nowMs = 0;
globalThis.performance = { now: () => nowMs }; globalThis.performance = { now: () => nowMs };
const canvas = Object.assign(new EventTarget(), { const { viewer, scene, camera, canvas, engine, frame } = createCameraViewer({
clientWidth: viewportWidth,
clientHeight: viewportHeight,
width: viewportWidth, width: viewportWidth,
height: viewportHeight, 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({ camera.setView({
destination: Cesium.Cartesian3.fromDegrees( destination: Cesium.Cartesian3.fromDegrees(longitudeDeg, latitude, height),
longitudeDeg,
latitude,
height,
ellipsoid,
),
orientation: { orientation: {
heading: radians(heading), heading: radians(heading),
pitch: radians(pitch), pitch: radians(pitch),
roll: radians(roll), roll: radians(roll),
}, },
}); });
const viewer = { const target = Cesium.Cartesian3.fromDegrees(longitudeDeg, latitude, 0);
camera, initCameraVerbs(engine, () => target);
scene,
trackedEntity: undefined,
trackedEntityChanged: new Cesium.Event(),
clock: { onTick: new Cesium.Event() },
};
const target = Cesium.Cartesian3.fromDegrees(
longitudeDeg,
latitude,
0,
ellipsoid,
);
initCameraVerbs(viewer, () => target);
t.after(() => { t.after(() => {
interruptCameraMotion('test-cleanup'); interruptCameraMotion('test-cleanup');
globalThis.performance = realPerformance; globalThis.performance = realPerformance;
}); });
const tick = (seconds = 0.1) => { const tick = (seconds = 0.1) => {
nowMs += seconds * 1000; nowMs += seconds * 1000;
viewer.clock.onTick.raiseEvent(viewer.clock); frame();
}; };
const finishOnce = () => { const finishOnce = () => {
for (let i = 0; i < 20 && getActiveCameraMotion(); i += 1) tick(); for (let i = 0; i < 20 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'once motion must finish'); assert.equal(getActiveCameraMotion(), null, 'once motion must finish');
}; };
return { viewer, camera, canvas, target, tick, finishOnce }; return { viewer, scene, camera, canvas, engine, 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);
});
} }
test('globe rotation yields to tracking acquired and cleared before the next tick', (t) => { 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, true,
); );
const before = pose(camera); const before = pose(camera);
const entity = { id: 'cockpit-aircraft', position: {} }; // Assigning the tracked entity raises trackedEntityChanged (the follow).
viewer.trackedEntity = entity; viewer.trackedEntity = { id: 'cockpit-aircraft', position: {} };
viewer.trackedEntityChanged.raiseEvent(entity);
assert.equal( assert.equal(
getActiveCameraMotion(), getActiveCameraMotion(),
null, null,
'tracking acquisition must synchronously yield', 'tracking acquisition must synchronously yield',
); );
viewer.trackedEntity = undefined; viewer.trackedEntity = undefined;
viewer.trackedEntityChanged.raiseEvent(undefined);
tick(); tick();
samePose(camera, before); samePose(camera, before);
assert.equal(hasRenderHold(), false); assert.equal(hasRenderHold(), false);
@@ -264,8 +172,9 @@ test('tracking event keeps legacy yaw and an untracked globe rotation active', (
true, true,
); );
const yawId = activeCameraMotionId(); const yawId = activeCameraMotionId();
viewer.trackedEntityChanged.raiseEvent({ id: 'aircraft' }); viewer.trackedEntity = { id: 'aircraft' };
assert.equal(activeCameraMotionId(), yawId); assert.equal(activeCameraMotionId(), yawId);
viewer.trackedEntity = undefined;
assert.equal( assert.equal(
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
true, true,
@@ -275,24 +184,24 @@ test('tracking event keeps legacy yaw and an untracked globe rotation active', (
assert.equal(activeCameraMotionId(), globeId); 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); const first = harness(t);
assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1); initCameraVerbs(first.engine);
initCameraVerbs(first.viewer);
assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1);
const next = harness(t); const next = harness(t);
assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 0);
assert.equal(next.viewer.trackedEntityChanged.numberOfListeners, 1);
assert.equal( assert.equal(
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
true, 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(); const id = activeCameraMotionId();
first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' }); first.viewer.trackedEntity = { id: 'stale-aircraft' };
assert.equal(activeCameraMotionId(), id); assert.equal(activeCameraMotionId(), id);
next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' }); next.viewer.trackedEntity = { id: 'new-aircraft' };
assert.equal(getActiveCameraMotion(), null); assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false); assert.equal(hasRenderHold(), false);
assert.equal(next.scene.preRender.numberOfListeners, 0);
}); });
for (const latitude of [0, 30, -60, 89.5]) { 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); assert.equal(hasRenderHold(), false);
}); });
test('replacing the viewer retires globe motion and its old clock listener', (t) => { test('replacing the engine retires globe motion and its frame listener', (t) => {
const { viewer, camera, tick } = harness(t); const { viewer, scene, camera, tick } = harness(t);
assert.equal( assert.equal(
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok,
true, true,
); );
tick(); tick();
const before = pose(camera); const before = pose(camera);
const nextViewer = { // Another engine over the same Cesium camera.
...viewer, const nextEngine = createCesiumEngine(viewer, { requestRender() {} });
clock: { onTick: new Cesium.Event() }, t.after(() => nextEngine.destroy());
}; initCameraVerbs(nextEngine);
initCameraVerbs(nextViewer);
assert.equal(getActiveCameraMotion(), null); assert.equal(getActiveCameraMotion(), null);
assert.equal(hasRenderHold(), false); assert.equal(hasRenderHold(), false);
assert.equal(scene.preRender.numberOfListeners, 0);
tick(); tick();
nextViewer.clock.onTick.raiseEvent(nextViewer.clock);
samePose(camera, before); samePose(camera, before);
}); });
@@ -683,8 +591,8 @@ test('navigation callback never releases tracking for globe rotation', (t) => {
samePose(camera, before); samePose(camera, before);
}); });
test('direct globe request refuses Cockpit before navigation mutation', (t) => { test('direct globe request refuses Cockpit before navigation mutation', (t) => {
const { viewer, camera } = harness(t); const { engine, camera } = harness(t);
initCameraVerbs(viewer, null, { isCockpitActive: () => true }); initCameraVerbs(engine, null, { isCockpitActive: () => true });
const before = pose(camera); const before = pose(camera);
let calls = 0; let calls = 0;
assert.equal( 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) => { test(`actual voice runner refuses ${owner} without releasing or repositioning`, async (t) => {
const { createGevActionRunner } = await import('./voice/gevActions.js'); const { createGevActionRunner } = await import('./voice/gevActions.js');
const { runExplicitNavigation } = await import('./navigationPolicy.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, height: owner === 'close-up' ? 5000 : GLOBE_VIEW.heightM,
}); });
const before = pose(camera); const before = pose(camera);
@@ -893,6 +801,7 @@ for (const owner of ['tracking', 'cockpit', 'close-up'])
}; };
const runner = createGevActionRunner({ const runner = createGevActionRunner({
viewer, viewer,
engine,
styleManager, styleManager,
dataManager: { dataManager: {
layers: new Map(), 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) => { test('an external radius change retires globe rotation without another write', (t) => {
const { camera, tick } = harness(t); const { camera, tick } = harness(t);
moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); moveCamera({ motion: 'rotate_globe', mode: 'continuous' });
@@ -952,16 +838,6 @@ test('an external radius change retires globe rotation without another write', (
samePose(camera, changed); 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) => { test('untouched polar framing permits repeated direction and speed changes below preset altitude', (t) => {
const { camera, finishOnce, tick } = harness(t, { latitude: 90 }); const { camera, finishOnce, tick } = harness(t, { latitude: 90 });
const radius = Cesium.Cartesian3.magnitude(camera.positionWC); 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 [ for (const invalid of ['orthographic', 'behind', 'zero-css-width'])
'orthographic',
'behind',
'zero-css-width',
'offset-frustum',
])
test(`unsupported ${invalid} fails without a reset offer`, (t) => { test(`unsupported ${invalid} fails without a reset offer`, (t) => {
const h = harness(t); const h = harness(t);
if (invalid === 'orthographic') if (invalid === 'orthographic')
@@ -1079,7 +950,6 @@ for (const invalid of [
}); });
if (invalid === 'behind') h.camera.lookRight(Math.PI); if (invalid === 'behind') h.camera.lookRight(Math.PI);
if (invalid === 'zero-css-width') h.canvas.clientWidth = 0; 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' }); const result = moveCamera({ motion: 'rotate_globe' });
assert.equal(result.ok, false); assert.equal(result.ok, false);
assert.equal(result.reason, 'unsupported-view'); assert.equal(result.reason, 'unsupported-view');
@@ -1203,15 +1073,11 @@ test('keyhole overlap after resize stops rotation before another pose write', (t
samePose(h.camera, before); samePose(h.camera, before);
}); });
for (const clipped of ['near', 'far', 'entirely-beyond-far']) test('a depth-near globe is unsupported without a reset offer', (t) => {
test(`depth-${clipped} globe is unsupported without a reset offer`, (t) => {
const h = harness(t, { latitude: 0 }); const h = harness(t, { latitude: 0 });
const radius = Cesium.Ellipsoid.WGS84.maximumRadius; const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC); const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC);
if (clipped === 'near') h.camera.frustum.near = distance - radius + 100; 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 before = pose(h.camera);
const result = moveCamera({ motion: 'rotate_globe' }); const result = moveCamera({ motion: 'rotate_globe' });
assert.equal(result.ok, false); assert.equal(result.ok, false);
@@ -1231,11 +1097,9 @@ test('fully depth-contained globe remains eligible at actual near/far limits', (
for (const [near, far] of [ for (const [near, far] of [
[0, 1e9], [0, 1e9],
[-1, 1e9],
[NaN, 1e9], [NaN, 1e9],
[1, Infinity], [1, Infinity],
[10, 10], [10, 10],
[10, 9],
]) ])
test(`invalid depth limits ${near}/${far} are unsupported`, (t) => { test(`invalid depth limits ${near}/${far} are unsupported`, (t) => {
const h = harness(t); const h = harness(t);
+42 -255
View File
@@ -18,14 +18,9 @@
* globe motion, opposite to the camera's travel around Earth. * globe motion, opposite to the camera's travel around Earth.
*/ */
import * as Cesium from 'cesium';
import * as geo from './geo/index.js'; import * as geo from './geo/index.js';
import { GLOBE_VIEW } from './locations.js'; import { GLOBE_VIEW } from './locations.js';
import { import { earthDiscScreenRadius, getKeyholeGeometry } from './celestialRing.js';
projectEarthDiscToViewport,
earthDiscScreenRadius,
getKeyholeGeometry,
} from './celestialRing.js';
import { import {
holdContinuousRender, holdContinuousRender,
releaseContinuousRender, releaseContinuousRender,
@@ -955,21 +950,17 @@ export function advanceRouteFlight(state, dt) {
}; };
} }
/* ── Camera rigs ──────────────────────────────────────────────────────────── /* ── Camera rig ─────────────────────────────────────────────────────────────
* A motion drives one camera through a rig, and remembers which one. * 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 * The rig speaks only the engine-neutral globe contract
* 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
* (src/globe/contract.js): camera.pose/height/setView/lookAt/release/ * (src/globe/contract.js): camera.pose/height/setView/lookAt/release/
* transform/flightActive/following/followChanged/cancelFlight, frames.preRender, * transform/flightActive/following/followChanged/cancelFlight, frames.preRender,
* host.canvas/size, projector.worldToWindow, surface and render.request. The * host.canvas/size, projector.worldToWindow, surface and render.request. The
* same verbs therefore run on Navara, on the fake engine and on Cesium through * same verbs therefore run on Navara, on Cesium (src/globe/cesium) and on the
* engineFromViewer. Its listeners exist only while one of its motions runs, so * fake engine. Its listeners exist only while one of its motions runs, so an
* an engine that is parked or destroyed holds nothing of ours. * engine that is parked or destroyed holds nothing of ours.
* -------------------------------------------------------------------------- */ * -------------------------------------------------------------------------- */
/** Manual camera input that reclaims control (the cancelFlight reflex). */ /** 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. */ /** The keyhole admission's view of a contract camera pose. */
function engineKeyholeView(pose) { function engineKeyholeView(pose) {
return { 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). */ /** The globe contract's camera (see the block comment above). */
function createEngineRig(engine) { function createEngineRig(engine) {
const camera = engine.camera; const camera = engine.camera;
@@ -1220,7 +1059,6 @@ function createEngineRig(engine) {
return { return {
kind: 'engine', kind: 'engine',
owner: engine, owner: engine,
viewer: null,
engine, engine,
position: () => pose().position, position: () => pose().position,
pitch: () => pose().pitch, pitch: () => pose().pitch,
@@ -1230,7 +1068,9 @@ function createEngineRig(engine) {
tracking: () => Boolean(camera.following()), tracking: () => Boolean(camera.following()),
trackedLabel() { trackedLabel() {
const handle = camera.following(); 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. // Both engines draw only the 3D globe.
is3D: () => true, is3D: () => true,
@@ -1292,6 +1132,9 @@ function createEngineRig(engine) {
); );
}, },
setFrame: (eye, direction, up) => place(eye, direction, up), 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() { levelRoll() {
const p = pose(); const p = pose();
if (Math.abs(geo.math.negativePiToPi(p.roll)) <= 1e-5) return false; 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 * 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 * drive this target: a globe engine with every contract member the rig
* member the engine rig reads. The voice gate asks this instead of "is there * reads. The voice gate asks this instead of "is there a viewer".
* a viewer". * @param {object|null|undefined} target A globe engine.
* @param {object|null|undefined} target A Cesium viewer or a globe engine.
* @returns {boolean} * @returns {boolean}
*/ */
export function cameraVerbsSupported(target) { export function cameraVerbsSupported(target) {
if (!target) return false; if (!isGlobeEngine(target) || target.isDestroyed?.()) return false;
if (!isGlobeEngine(target))
return Boolean(target.camera && target.scene && target.clock?.onTick);
if (target.isDestroyed?.()) return false;
return ENGINE_RIG_MEMBERS.every( return ENGINE_RIG_MEMBERS.every(
(path) => (path) =>
typeof path typeof path
@@ -1416,14 +1255,9 @@ export function cameraVerbsSupported(target) {
/** The rig new motions start on: the latest initCameraVerbs target. */ /** The rig new motions start on: the latest initCameraVerbs target. */
let _rig = null; let _rig = null;
/** The viewer whose clock and input listeners are installed (viewer rig). */
let _viewer = null;
let _getTarget = null; let _getTarget = null;
let _active = null; // { kind, rig, direction, speed, mode, motionId, ...state } let _active = null; // { kind, rig, direction, speed, mode, motionId, ...state }
let _tickRemover = null; /** The engine listeners of the running motion, or null. */
let _trackingRemover = null;
let _inputRemovers = [];
/** The engine listeners of the running engine-rig motion, or null. */
let _engineBinding = null; let _engineBinding = null;
function unbindEngineMotion() { function unbindEngineMotion() {
@@ -1451,8 +1285,9 @@ function bindEngineMotion(rig) {
removers.push(() => canvas.removeEventListener(event, cancel)); removers.push(() => canvas.removeEventListener(event, cancel));
} }
} }
// As the viewer rig's trackedEntityChanged: a follow taking the camera // A follow taking the camera (Cockpit can acquire and clear one between
// yields a globe rotation at once, before its next frame. // two frames) yields a globe rotation at once, before its next frame.
// Other motions carry on.
removers.push( removers.push(
engine.camera.followChanged.addEventListener((handle) => { engine.camera.followChanged.addEventListener((handle) => {
if (handle && _active?.rig === rig && _active.kind === 'rotate_globe') 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'); if (_active?.rig === rig) engine.render.request('camera-verb');
}), }),
); );
// Its first frame takes the minimum step, as a viewer rig's first tick // Its first frame takes the minimum step (a resumed flight must not jump
// does (a resumed flight must not jump across the time it was parked). // across the time it was parked).
if (_active?.rig === rig) _active.lastMs = undefined; if (_active?.rig === rig) _active.lastMs = undefined;
_engineBinding = { _engineBinding = {
rig, rig,
@@ -1478,14 +1313,13 @@ function bindEngineMotion(rig) {
} }
/** /**
* Park the Directions route flight `id` (an engine-rig flight) for an engine * Park the Directions route flight `id` for an engine switch: its listeners
* switch: its listeners go, its state is returned for resumeRouteFlight. * go, its state is returned for resumeRouteFlight.
* @param {number} id The flight's motion id. * @param {number} id The flight's motion id.
* @returns {object|null} The parked state, or null when `id` is not running. * @returns {object|null} The parked state, or null when `id` is not running.
*/ */
export function suspendRouteFlight(id) { export function suspendRouteFlight(id) {
if (!id || _active?.motionId !== id || _active.rig?.kind !== 'engine') if (!id || _active?.motionId !== id) return null;
return null;
const state = _active; const state = _active;
_active = null; _active = null;
unbindEngineMotion(); 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 * 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 * 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). * @param {object} state Motion state (with its rig).
* @returns {object} The installed state. * @returns {object} The installed state.
*/ */
@@ -1519,7 +1353,7 @@ function setActiveMotion(state) {
_motionSeq += 1; _motionSeq += 1;
state.motionId = _motionSeq; state.motionId = _motionSeq;
_active = state; _active = state;
if (state.rig?.kind === 'engine') bindEngineMotion(state.rig); bindEngineMotion(state.rig);
return state; return state;
} }
@@ -1564,13 +1398,8 @@ export function interruptCameraMotion(reason = 'interrupt') {
} catch { } catch {
/* teardown race */ /* teardown race */
} }
// An engine dolly only ever sets views; it never locks a transform. // A route dolly only ever sets views; it never locks a transform.
const engineRoute = motion.kind === 'route' && rig.kind === 'engine'; if (!tracking && motion.kind === 'orbit') {
if (
!tracking &&
!engineRoute &&
(motion.kind === 'orbit' || motion.kind === 'route')
) {
try { try {
rig.release(); rig.release();
} catch { } catch {
@@ -1579,7 +1408,7 @@ export function interruptCameraMotion(reason = 'interrupt') {
} }
// Instant, not eased: the user grabbed the camera, or the flight is over. // 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. // Either way the next thing they hold must be a level horizon.
if (motion.kind === 'route' && (engineRoute || !tracking)) { if (motion.kind === 'route') {
try { try {
leveled = rig.levelRoll(); leveled = rig.levelRoll();
} catch { } catch {
@@ -1618,11 +1447,6 @@ export function getActiveCameraMotion() {
return info; 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`. */ /** Advance the running motion one frame, when it runs on `rig`. */
function tickMotion(rig) { function tickMotion(rig) {
const m = _active; const m = _active;
@@ -1894,35 +1718,20 @@ export function interruptGlobeRotation(reason = 'camera-intent') {
return { wasActive: false, reason, leveled: false }; 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 * Release what the verbs hold on a departing engine: a motion running on it
* running on it stops, and when it is the one new motions start on, its * stops, and when it is the one new motions start on, its getters go too.
* listeners and getters go too. * @param {object} target The globe engine.
* @param {object} target The Cesium viewer or globe engine.
*/ */
export function releaseCameraVerbs(target) { export function releaseCameraVerbs(target) {
if (target == null) return; if (target == null) return;
if (target === _viewer) { if (_active?.rig?.owner === target) interruptCameraMotion('engine-released');
if (_active) interruptCameraMotion('viewer-released');
detachViewerListeners();
} else if (_active?.rig?.owner === target) {
interruptCameraMotion('engine-released');
}
if (_rig?.owner === target) _rig = _getTarget = _isCockpitActive = null; if (_rig?.owner === target) _rig = _getTarget = _isCockpitActive = null;
} }
/** /**
* Wire the module to the camera once (idempotent). `target` is a Cesium * Wire the module to the camera once (idempotent). `target` is a globe
* viewer (its own camera: the viewer rig) or a globe engine (the contract: * engine (Navara, or the Cesium engine over the app's viewer). The
* the engine rig; Navara, or Cesium through engineFromViewer). The
* view-target getter is optional so a non-voice caller (the Directions FLY * 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 * 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 * with a real getter installs it, and a call without one, FOR THE SAME
@@ -1948,18 +1757,14 @@ export function initCameraVerbs(
_isCockpitActive = isCockpitActive; _isCockpitActive = isCockpitActive;
return; return;
} }
if (!isGlobeEngine(target)) return;
const previous = _rig; const previous = _rig;
const next = isGlobeEngine(target) const next = engineRigFor(target);
? engineRigFor(target)
: createViewerRig(target);
if (_active && (_active.rig === previous || previous === null)) { if (_active && (_active.rig === previous || previous === null)) {
// A globe motion belongs to the camera whose world pose it captured. Any // A globe motion belongs to the camera whose world pose it captured, and
// other motion carries on with a new Cesium viewer (its state is world // no motion crosses to another camera.
// geometry), but never crosses to another kind of camera.
if (_active.kind === 'rotate_globe') if (_active.kind === 'rotate_globe')
interruptCameraMotion('viewer-replaced'); interruptCameraMotion('viewer-replaced');
else if (_active.rig?.kind === 'viewer' && next.kind === 'viewer')
_active.rig = next;
else if (_active.rig === previous && previous !== null) else if (_active.rig === previous && previous !== null)
interruptCameraMotion('camera-replaced'); interruptCameraMotion('camera-replaced');
} }
@@ -1967,24 +1772,6 @@ export function initCameraVerbs(
_isCockpitActive = _isCockpitActive =
typeof isCockpitActive === 'function' ? isCockpitActive : null; typeof isCockpitActive === 'function' ? isCockpitActive : null;
_rig = next; _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 camera verbs on the engine-neutral globe contract (the engine rig), on
// the fake engine: pose deltas and durations against a real Cesium camera // the fake engine: pose deltas and durations against a real Cesium camera
// driven through the viewer rig, cancellation, supersession and the release // driven through the Cesium engine, cancellation, supersession and the
// an engine switch makes. // release an engine switch makes.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
@@ -9,6 +9,7 @@ import * as Cesium from 'cesium';
import * as geo from './geo/index.js'; import * as geo from './geo/index.js';
import { GLOBE_VIEW } from './locations.js'; import { GLOBE_VIEW } from './locations.js';
import { createFakeEngine } from './globe/testing/fakeEngine.js'; import { createFakeEngine } from './globe/testing/fakeEngine.js';
import { createCameraViewer } from './globe/cesium/testing/cameraViewer.mjs';
import { import {
activeCameraMotionId, activeCameraMotionId,
cameraVerbsSupported, cameraVerbsSupported,
@@ -75,30 +76,19 @@ function engineHarness(t, pose = START, clock = installClock(t)) {
return { engine, clock, frame, run, camera: () => engine.camera.pose() }; return { engine, clock, frame, run, camera: () => engine.camera.pose() };
} }
/** A real Cesium camera on a stub scene (no renderer), driven as a viewer. */ /** The Cesium engine over a real Cesium camera on a stub scene (no renderer). */
function viewerHarness(t, pose = START, clock = installClock(t)) { function cesiumHarness(t, pose = START, clock = installClock(t)) {
const canvas = Object.assign(new EventTarget(), { const {
clientWidth: WIDTH, viewer,
clientHeight: HEIGHT, scene,
camera,
engine,
frame: turn,
} = createCameraViewer({
width: WIDTH, width: WIDTH,
height: HEIGHT, height: HEIGHT,
}); });
const ellipsoid = Cesium.Ellipsoid.WGS84; t.after(() => engine.destroy());
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;
camera.setView({ camera.setView({
destination: Cesium.Cartesian3.fromDegrees( destination: Cesium.Cartesian3.fromDegrees(
pose.lonDeg, pose.lonDeg,
@@ -111,16 +101,9 @@ function viewerHarness(t, pose = START, clock = installClock(t)) {
roll: 0, roll: 0,
}, },
}); });
const viewer = {
camera,
scene,
trackedEntity: undefined,
trackedEntityChanged: new Cesium.Event(),
clock: { onTick: new Cesium.Event() },
};
const frame = (seconds = 0.1) => { const frame = (seconds = 0.1) => {
clock.ms += seconds * 1000; clock.ms += seconds * 1000;
viewer.clock.onTick.raiseEvent(viewer.clock); turn();
}; };
const run = (seconds, step = 0.1) => { const run = (seconds, step = 0.1) => {
for (let s = 0; s < seconds - 1e-9; s += step) frame(step); 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, pitch: camera.pitch,
roll: camera.roll, 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) { 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); 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 * Run one verb on the Cesium engine (a real Cesium camera) and on the fake
* pose with the same frames; return both camera poses before and after. * 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 } = {}) { function runOnBoth(t, args, seconds, { pose = START, target = null } = {}) {
const clock = installClock(t); const clock = installClock(t);
const cesium = viewerHarness(t, pose, clock); const cesium = cesiumHarness(t, pose, clock);
initCameraVerbs(cesium.viewer, () => target); initCameraVerbs(cesium.engine, () => target);
const cesiumBefore = cesium.camera(); const cesiumBefore = cesium.camera();
const cesiumResult = moveCamera(args); const cesiumResult = moveCamera(args);
assert.equal(cesiumResult.ok, true, JSON.stringify(cesiumResult)); assert.equal(cesiumResult.ok, true, JSON.stringify(cesiumResult));
cesium.run(seconds); cesium.run(seconds);
const cesiumAfter = cesium.camera(); const cesiumAfter = cesium.camera();
interruptCameraMotion('parity'); interruptCameraMotion('parity');
releaseCameraVerbs(cesium.viewer); releaseCameraVerbs(cesium.engine);
const fake = engineHarness(t, pose, clock); const fake = engineHarness(t, pose, clock);
initCameraVerbs(fake.engine, () => target); 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); initCameraVerbs(first.engine, () => ORBIT_TARGET);
moveCamera({ motion: 'orbit', mode: 'continuous' }); moveCamera({ motion: 'orbit', mode: 'continuous' });
first.run(0.3); first.run(0.3);
const cesium = viewerHarness(t, START, first.clock); const cesium = cesiumHarness(t, START, first.clock);
initCameraVerbs(cesium.viewer, () => ORBIT_TARGET); initCameraVerbs(cesium.engine, () => ORBIT_TARGET);
assert.equal(getActiveCameraMotion(), null); assert.equal(getActiveCameraMotion(), null);
assert.deepEqual(first.engine.camera.transform(), [...geo.matrix4.IDENTITY]); 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) => { 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: {}, scene: {},
clock: { onTick: new Cesium.Event() }, clock: { onTick: new Cesium.Event() },
}), }),
true, false,
'a Cesium viewer', 'a Cesium viewer is driven through its engine',
); );
const partial = createFakeEngine(); const partial = createFakeEngine();
partial.camera = { ...partial.camera, following: undefined }; 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) => { 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 clock = installClock(t);
const target = ORBIT_TARGET; const target = ORBIT_TARGET;
let h; let h;
@@ -596,15 +588,17 @@ test('a once orbit armed through a flight still turns its full nudge', (t) => {
}); });
flying = true; flying = true;
} else { } else {
h = viewerHarness(t, START, clock); // A flight a layer started straight on viewer.camera: a scene tween
initCameraVerbs(h.viewer, () => target); // the engine did not start.
h.viewer.scene.tweens.push({}); h = cesiumHarness(t, START, clock);
initCameraVerbs(h.engine, () => target);
h.scene.tweens.push({});
flying = true; flying = true;
} }
const before = h.camera(); const before = h.camera();
assert.equal(moveCamera({ motion: 'orbit' }).armed, 'waiting-for-arrival'); assert.equal(moveCamera({ motion: 'orbit' }).armed, 'waiting-for-arrival');
h.run(1.6); 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); h.run(1);
const start = getActiveCameraMotion(); const start = getActiveCameraMotion();
assert.equal(start?.kind, 'orbit', `${kind}: the arm outlived 0.9 s`); 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), geo.math.negativePiToPi(after.heading - before.heading),
); );
near(turn, 30, 0.05, `${kind}: heading (camera frame vs target frame)`); 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 \(!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/); 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]]]) { 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.match(source, /function _onEmptyClick\(\) \{[\s\S]+?_clearTracking\(false, \{ origin: 'user' \}\)/);
} }
assert.doesNotMatch( assert.doesNotMatch(
+42 -2
View File
@@ -38,6 +38,41 @@ const cesiumDestination = (destination) =>
const cesiumEasing = (easing) => (easing ? easings()[easing] : undefined); 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. // A HeadingPitchRange or a Cartesian offset, as Cesium's lookAt takes them.
const cesiumOffset = (offset) => const cesiumOffset = (offset) =>
'heading' in offset 'heading' in offset
@@ -167,10 +202,13 @@ export function createCesiumCamera(viewer) {
setView(view) { setView(view) {
if (!view?.destination) if (!view?.destination)
throw new TypeError('setView needs a destination'); throw new TypeError('setView needs a destination');
const orientation = cesiumOrientation(view.orientation);
cesium.setView({ cesium.setView({
destination: cesiumDestination(view.destination), destination: cesiumDestination(view.destination),
orientation: cesiumOrientation(view.orientation), orientation,
}); });
if (orientation?.direction)
setBasis(cesium, orientation.direction, orientation.up);
}, },
flyTo(options) { flyTo(options) {
if (!options?.destination) 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) { lookAt(target, offset) {
cesium.lookAt(toCesium3(target), cesiumOffset(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 { isEngineEvent } from '../contract.js';
import { createCesiumCamera } from './camera.js'; import { createCesiumCamera } from './camera.js';
import { createFakeViewer } from './testing/fakeViewer.mjs'; import { createFakeViewer } from './testing/fakeViewer.mjs';
import { createCameraViewer } from './testing/cameraViewer.mjs';
const notCesium = (value, label) => { const notCesium = (value, label) => {
assert.ok(value != null, `${label} is missing`); 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); 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', () => { test('flyTo with duration 0 completes before it returns and leaves no flight', () => {
const { camera } = setup(); const { camera } = setup();
let done = false; let done = false;
+5 -6
View File
@@ -8,12 +8,11 @@
// drives Cesium's own lighting as before. // drives Cesium's own lighting as before.
// //
// The latch is attached when the engine is created (the app creates it with // 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 // the scene), so it runs before any onTick listener added later; one added
// listeners), so it runs first in each tick; an onTick listener added before // before the engine existed would read the previous frame's time. destroy
// the engine existed would read the previous frame's time. destroy removes // removes it. A viewer without a clock (tests, a bare scene) latches on the
// it. A viewer without a clock (tests, a bare scene) latches on the scene's // scene's preUpdate instead, attached on first use so creating the engine
// preUpdate instead, attached on first use so creating the engine adds no // adds no scene listener.
// scene listener.
import { appClock as pageClock } from '../../time/clock.js'; 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 viewer = createFakeViewer();
const wall = manualAppClock(); const wall = manualAppClock();
const { clock } = createCesiumClock(viewer, { appClock: wall.appClock }); const { clock } = createCesiumClock(viewer, { appClock: wall.appClock });
// Attached at creation, so it runs before onTick listeners added later // Attached at creation, so it runs before onTick listeners added later.
// (cameraVerbs installs its own after the engine exists).
assert.equal(viewer.clock.onTick.numberOfListeners, 1); assert.equal(viewer.clock.onTick.numberOfListeners, 1);
const order = []; const order = [];
viewer.clock.onTick.addEventListener(() => order.push(clock.frameTime())); 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 // one exact tracked frame on preUpdate (applyTrackedCameraFrame) and the
// zoom limits that keep the camera outside the minimum range. // zoom limits that keep the camera outside the minimum range.
// following() also reports a trackedEntity the app set itself, and // 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 Cesium from 'cesium';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
@@ -18,6 +20,7 @@ import {
import { toCesium3 } from './convert.js'; import { toCesium3 } from './convert.js';
const MAX_FRAME_ATTEMPTS = 120; const MAX_FRAME_ATTEMPTS = 120;
const entityName = (entity) => (entity ? entity.name || entity.id : undefined);
const ZOOM_INERTIA_STATES = new WeakMap(); const ZOOM_INERTIA_STATES = new WeakMap();
function acquireStableTrackedZoom(controller, owner, minRange) { function acquireStableTrackedZoom(controller, owner, minRange) {
@@ -188,6 +191,9 @@ export function createCesiumFollow(viewer, { commanded }) {
if (!handle) { if (!handle) {
handle = { handle = {
native: entity, native: entity,
get name() {
return entityName(entity);
},
target: () => target: () =>
displayPosition(entity, viewer.clock.currentTime, undefined), displayPosition(entity, viewer.clock.currentTime, undefined),
stop() { stop() {
@@ -308,6 +314,9 @@ export function createCesiumFollow(viewer, { commanded }) {
const state = { entity: null, proxy: null, options, handle: null }; const state = { entity: null, proxy: null, options, handle: null };
state.handle = { state.handle = {
native: options.native, native: options.native,
get name() {
return entityName(state.handle.native);
},
target() { target() {
try { try {
return state.options.position(); 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]); 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', () => { test('destroy ends an engine follow and leaves an app-tracked entity alone', () => {
const first = setup(); const first = setup();
first.camera.follow({ 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 fovy = 2 * Math.atan(Math.tan(Math.PI / 6) * (height / width));
const camera = { const camera = {
positionWC: new Cesium.Cartesian3(7e6, 1e5, 2e5), 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), position: new Cesium.Cartesian3(7e6, 1e5, 2e5),
direction: new Cesium.Cartesian3(-1, 0, 0), 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), directionWC: new Cesium.Cartesian3(-1, 0, 0),
upWC: new Cesium.Cartesian3(0, 0, 1), upWC: new Cesium.Cartesian3(0, 0, 1),
rightWC: new Cesium.Cartesian3(0, 1, 0), 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)), viewMatrix: Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(1, 2, 3)),
transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY), transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
get inverseTransform() {
return Cesium.Matrix4.inverseTransformation(
camera.transform,
new Cesium.Matrix4(),
);
},
defaultZoomAmount: 100000, defaultZoomAmount: 100000,
percentageChanged: 0.5, percentageChanged: 0.5,
changed: new Cesium.Event(), changed: new Cesium.Event(),
@@ -132,6 +140,8 @@ export function createFakeViewer({ width = 1024, height = 768 } = {}) {
postRender: new Cesium.Event(), postRender: new Cesium.Event(),
requestRenderMode: true, requestRenderMode: true,
maximumRenderTimeChange: Infinity, maximumRenderTimeChange: Infinity,
// Cesium's running camera flights (a flight on viewer.camera is one).
tweens: [],
requestRender: record('scene.requestRender'), requestRender: record('scene.requestRender'),
pick: record('scene.pick', () => undefined), pick: record('scene.pick', () => undefined),
drillPick: record('scene.drillPick', () => []), drillPick: record('scene.drillPick', () => []),
+2
View File
@@ -154,6 +154,8 @@
* target is now (what the follow centres). * target is now (what the follow centres).
* @property {any} [native] * @property {any} [native]
* @property {object} [label] FollowOptions.label. * @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 {'click'|'doubleClick'|'move'|'down'|'up'} InputType
* @typedef {'ctrl'|'shift'|'alt'} InputModifier * @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 // (modelMover.js). In the cinematic look, depth stays in that scene and
// lit model colour resolves in the display pass. Pickable sets register a // lit model colour resolves in the display pass. Pickable sets register a
// PickableInstancedMeshWrapper so Navara's pick pass sees each instance; // 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 // asks for one frame (view.forceUpdate); per-frame work runs only on frames
// the view draws anyway, so an idle view stays idle. // the view draws anyway, so an idle view stays idle.
@@ -17,6 +18,7 @@ import {
} from '@navaramap/three'; } from '@navaramap/three';
import { Group } from 'three'; import { Group } from 'three';
import { createMoverRegistry } from '../../movers.js'; import { createMoverRegistry } from '../../movers.js';
import { createBatchIdPool } from './batchIds.js';
import { createModelMover } from './modelMover.js'; import { createModelMover } from './modelMover.js';
import { createMoverMesh } from './moverMesh.js'; import { createMoverMesh } from './moverMesh.js';
import { createPickIndex } from './pickIds.js'; import { createPickIndex } from './pickIds.js';
@@ -34,16 +36,16 @@ class GevMoverSetDesc extends withFrameUpdates(MeshDesc) {
} }
createMesh() { createMesh() {
const { mover, pickable, onPickable } = this.gev; const { mover, pickable, onPickable, batchIds } = this.gev;
const register = (mesh, key) => { const register = (mesh, key) => {
if (!pickable) return; if (!pickable) return;
const wrapper = new PickableInstancedMeshWrapper( const wrapper = new PickableInstancedMeshWrapper(
mesh, mesh,
mesh.instanceMatrix.count, mesh.instanceMatrix.count,
this.ctx, batchIds.source(this.ctx),
); );
this.ctx.registerPickableMesh(key, wrapper); this.ctx.registerPickableMesh(key, wrapper);
this.wrappers.push(key); this.wrappers.push({ key, wrapper });
onPickable(wrapper); onPickable(wrapper);
}; };
const group = new Group(); const group = new Group();
@@ -66,8 +68,9 @@ class GevMoverSetDesc extends withFrameUpdates(MeshDesc) {
onDestroy() { onDestroy() {
this.destroyedByGev = true; 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(); this.gev.mover.dispose();
for (const { wrapper } of this.wrappers) this.gev.batchIds.release(wrapper);
super.onDestroy(); super.onDestroy();
} }
} }
@@ -90,6 +93,7 @@ export function createNavaraMovers(view, { overlay, aces = false }) {
registered.add(view); registered.add(view);
} }
const pickIndex = createPickIndex(); const pickIndex = createPickIndex();
const batchIds = createBatchIdPool();
const readFrameCamera = createMoverFrameCamera(view); const readFrameCamera = createMoverFrameCamera(view);
let loader = null; let loader = null;
const loadGltf = (url) => { const loadGltf = (url) => {
@@ -111,11 +115,12 @@ export function createNavaraMovers(view, { overlay, aces = false }) {
overlayUniforms: targetOverlay.uniforms, overlayUniforms: targetOverlay.uniforms,
}); });
const key = `gevMovers:${options.id}`; const key = `gevMovers:${options.id}`;
let wrapper = null;
if (options.pickable) { if (options.pickable) {
const wrapper = new PickableInstancedMeshWrapper( wrapper = new PickableInstancedMeshWrapper(
mover.mesh, mover.mesh,
options.capacity, options.capacity,
targetOverlay.ctx, batchIds.source(targetOverlay.ctx),
); );
targetOverlay.registerPickable(key, wrapper); targetOverlay.registerPickable(key, wrapper);
pickIndex.add(options.id, wrapper, (index) => store.pickIds[index]); 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); if (options.pickable) targetOverlay.unregisterPickable(key);
targetOverlay.remove(mover.mesh); targetOverlay.remove(mover.mesh);
mover.dispose(); mover.dispose();
batchIds.release(wrapper);
if (options.sharpen && --preSharpenUsers === 0) { if (options.sharpen && --preSharpenUsers === 0) {
preSharpenOverlay.destroy(); preSharpenOverlay.destroy();
preSharpenOverlay = null; preSharpenOverlay = null;
@@ -155,6 +161,7 @@ export function createNavaraMovers(view, { overlay, aces = false }) {
readFrameCamera, readFrameCamera,
kind: options.kind, kind: options.kind,
pickable: options.pickable, pickable: options.pickable,
batchIds,
onPickable: (wrapper) => onPickable: (wrapper) =>
pickIndex.add(options.id, wrapper, (index) => store.pickIds[index]), 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. */ /** One native GeoJSON layer per AQHI colour, independent of station count. */
export function createAirQualityNavaraRenderer(engine, { view }) { export function createAirQualityNavaraRenderer(engine, { view }) {
const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({ const display = createNavaraPolygonDisplay(engine, view, (color) => ({
polygon: { polygon: {
color, color,
show,
transparent: true, transparent: true,
opacity: 0.35, opacity: 0.35,
clampToGround: true, 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 // 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) { function follow(trackedEntityChanged, entity, restoring) {
if (restoring) entity.gevRestoring = true; if (restoring) entity.gevRestoring = true;
try { 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', () => { test('every follow of a resumed or share-restored subject is marked while it starts', () => {
for (const [file, resume] of [ for (const [file, resume] of [
['flights/tracking.js', 'resume'], ['aircraft/tracking.js', 'resume'],
['military/tracking.js', 'resume'],
['satellites/tracking.js', 'resuming'], ['satellites/tracking.js', 'resuming'],
]) { ]) {
const source = readLayerSource(new URL(`../${file}`, import.meta.url)); 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); const world = ([lon, lat]) => geo.cartesian3.fromDegrees(lon, lat);
export function createCyclonesNavaraRenderer(engine, view, { overlayHost }) { export function createCyclonesNavaraRenderer(engine, view, { overlayHost }) {
const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({ const display = createNavaraPolygonDisplay(engine, view, (color) => ({
polyline: { color, show, width: 2.5, clampToGround: true }, polyline: { color, width: 2.5, clampToGround: true },
polygon: { polygon: {
color, color,
show,
opacity: 0.16, opacity: 0.16,
transparent: true, transparent: true,
clampToGround: true, clampToGround: true,
+1 -2
View File
@@ -1401,9 +1401,8 @@ export function createDirectionsLayer({
} }
function flyCurrentRoute() { function flyCurrentRoute() {
if (!_route || (!_viewer && !_engine)) return false; if (!_route || !_engine) return false;
cancelOwnedFlight('directions-refly'); cancelOwnedFlight('directions-refly');
if (_viewer) services.camera.initCameraVerbs(_viewer);
const seams = _shellSeams; const seams = _shellSeams;
const result = services.camera.flyRoute( const result = services.camera.flyRoute(
[ [
File diff suppressed because it is too large. Load diff
+3 -8
View File
@@ -31,14 +31,10 @@ export function createInfrastructureNavaraRenderer(
sourceId: id, sourceId: id,
host: services.overlayHost, host: services.overlayHost,
}); });
const display = createNavaraPolygonDisplay( const display = createNavaraPolygonDisplay(engine, native.view, (tint) => ({
engine, polyline: { color: tint, width: 2, clampToGround: true },
native.view,
(tint, show) => ({
polyline: { color: tint, show, width: 2, clampToGround: true },
polygon: { polygon: {
color: tint, color: tint,
show,
opacity: 0.3, opacity: 0.3,
transparent: true, transparent: true,
clampToGround: true, clampToGround: true,
@@ -46,8 +42,7 @@ export function createInfrastructureNavaraRenderer(
outlineColor: tint, outlineColor: tint,
outlineWidth: 2, outlineWidth: 2,
}, },
}), }));
);
let dataset = null, let dataset = null,
records = new Map(), records = new Map(),
active = new Set(), active = new Set(),
File diff suppressed because it is too large. Load diff
+31 -18
View File
@@ -1,7 +1,13 @@
/** GeoJSON features grouped by paint. Swap complete batches so a failed /** 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) { export function createNavaraPolygonDisplay(engine, view, style) {
let visible = false; let visible = false;
let groups = new Map();
let batches = []; let batches = [];
function release(items) { function release(items) {
for (const { layer, source } of items) { for (const { layer, source } of items) {
@@ -9,43 +15,50 @@ export function createNavaraPolygonDisplay(engine, view, style) {
source.delete(); source.delete();
} }
} }
return { function build(next) {
setVisible(show) { const built = [];
if (visible === show) return;
visible = show;
for (const { color, source, layer } of batches) {
layer.update({ type: 'vector', source, ...style(color, show) });
}
engine.render.request('polygon-visibility');
},
replace(groups) {
const next = [];
try { try {
for (const [css, features] of groups) { for (const [css, features] of next) {
const color = Number.parseInt(css.slice(1), 16); const color = Number.parseInt(css.slice(1), 16);
const source = view.addSource({ const source = view.addSource({
type: 'geojson', type: 'geojson',
data: { type: 'FeatureCollection', features }, data: { type: 'FeatureCollection', features },
}); });
const batch = { source, color }; const batch = { source };
next.push(batch); built.push(batch);
batch.layer = view.addLayer({ batch.layer = view.addLayer({
type: 'vector', type: 'vector',
source, source,
...style(color, visible), ...style(color),
}); });
} }
} catch (error) { } catch (error) {
release(next); release(built);
throw error; throw error;
} }
release(batches); release(batches);
batches = next; batches = built;
}
return {
setVisible(show) {
if (visible === show) return;
if (show) build(groups);
else {
release(batches);
batches = [];
}
visible = show;
engine.render.request('polygon-visibility');
},
replace(next) {
if (visible) build(next);
groups = next;
engine.render.request('polygon-data'); engine.render.request('polygon-data');
}, },
destroy() { destroy() {
release(batches); release(batches);
batches = []; batches = [];
groups = new Map();
engine.render.request('polygon-destroy'); 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 }, { idPrefix, colorOf },
) { ) {
let polygons = []; let polygons = [];
const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({ const display = createNavaraPolygonDisplay(engine, view, (color) => ({
polygon: { polygon: {
color, color,
show,
clampToGround: true, clampToGround: true,
transparent: true, transparent: true,
opacity: 0.25, opacity: 0.25,
}, },
polyline: { polyline: {
color, color,
show,
clampToGround: true, clampToGround: true,
width: 2, width: 2,
transparent: true, transparent: true,
+40 -58
View File
@@ -34,6 +34,7 @@ import {
routeSpeedProfile, routeSpeedProfile,
signedTurnRad, signedTurnRad,
} from './cameraVerbs.js'; } from './cameraVerbs.js';
import { createCameraViewer } from './globe/cesium/testing/cameraViewer.mjs';
const FRAME_S = 1 / 60; const FRAME_S = 1 / 60;
const CRUISE_M_S = { slow: 20, normal: 40, fast: 90 }; 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 * The Cesium engine over a real Cesium camera, whose setView calls are
* direction/up pair updates heading/pitch/roll the way Cesium's own * recorded. Its heading/pitch/roll are Cesium's own decomposition of the
* decomposition does, and setView with an explicit roll writes it back. Without * camera it is left with, so a pin proves the horizon the user is left
* that, a pin can only prove the code called setView — not that the horizon * holding is level, not only that the code called setView.
* the user is left holding is level.
*/ */
function createTickableViewer({ heightM = 700 } = {}) { function createTickableViewer({ heightM = 700 } = {}) {
const listeners = [];
const setViews = [];
// The tick derives dt from the wall clock, so a synchronous test loop would // 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 // advance the flight by ~0 no matter how many times it ticked. Stub the
// clock and step it explicitly. // clock and step it explicitly.
const realPerformance = globalThis.performance; const realPerformance = globalThis.performance;
let nowMs = 0; let nowMs = 0;
globalThis.performance = { now: () => nowMs }; globalThis.performance = { now: () => nowMs };
const position = Cesium.Cartesian3.fromDegrees(-97.76, 30.26, heightM); const { viewer, scene, camera, engine, frame } = createCameraViewer();
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 // A live app has a rendered surface under the route; the mesh probe reads
// it, so these flights start immediately rather than arming. // it, so these flights start immediately rather than arming.
sampleHeight: () => 0, scene.sampleHeightSupported = true;
}, scene.sampleHeight = () => 0;
camera, 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 { return {
viewer, viewer,
engine,
setViews, setViews,
tick: (dtS = FRAME_S) => { tick: (dtS = FRAME_S) => {
nowMs += dtS * 1000; 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; }, 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}`); assert.ok(queries.some((q) => /prefers-reduced-motion:\s*reduce/.test(q)), `queried ${queries}`);
const viewer = createTickableViewer(); const viewer = createTickableViewer();
initCameraVerbs(viewer.viewer, () => null); initCameraVerbs(viewer.engine, () => null);
const started = flyRoute([{ const started = flyRoute([{
type: 'route', label: 'reduced', path: TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 })), 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', () => { test('an interrupt MID-BANK stops the dolly and puts the horizon back level', () => {
const viewer = createTickableViewer(); const viewer = createTickableViewer();
initCameraVerbs(viewer.viewer, () => null); initCameraVerbs(viewer.engine, () => null);
const started = flyRoute([{ const started = flyRoute([{
type: 'route', 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`, `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. // Levelling preserves where the camera was looking — it is not a re-frame.
// Asserted NUMERICALLY, not just by the omitted destination: position, // Asserted NUMERICALLY: position, heading and pitch must come out the far
// heading and pitch must come out the far side unchanged. // side unchanged.
const levelling = viewer.setViews.at(-1); const levelling = viewer.setViews.at(-1);
assert.equal(levelling.orientation.roll, 0); assert.equal(levelling.orientation.roll, 0);
assert.equal(levelling.destination, undefined, 'levelling must not move the camera');
assert.equal( assert.equal(
Cesium.Cartesian3.distance(viewer.viewer.camera.positionWC, poseAtCut.position), 0, Cesium.Cartesian3.distance(viewer.viewer.camera.positionWC, poseAtCut.position), 0,
'the camera moved while being levelled', 'the camera moved while being levelled',
); );
assert.equal(viewer.viewer.camera.heading, poseAtCut.heading, 'heading changed while levelling'); // Cesium re-derives heading and pitch from the basis: equal to rounding.
assert.equal(viewer.viewer.camera.pitch, poseAtCut.pitch, 'pitch changed while levelling'); 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; const writesAtCut = viewer.setViews.length;
viewer.tick(); 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', () => { test('a completed dolly lands wings level and releases the slot', () => {
const viewer = createTickableViewer(); 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 // A route that is still turning as it ends — the case where a bank would
// otherwise be frozen into the final frame. // otherwise be frozen into the final frame.
const started = flyRoute([{ 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 // somewhere, a tracked aircraft took over — and stopping that would be a
// feature reaching across the app to cancel a stranger's motion. // feature reaching across the app to cancel a stranger's motion.
const viewer = createTickableViewer(); const viewer = createTickableViewer();
initCameraVerbs(viewer.viewer, () => null); initCameraVerbs(viewer.engine, () => null);
const path = TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 })); const path = TWO_TURN_ROUTE.map(([lon, lat]) => ({ lon, lat, height: 0 }));
assert.equal(activeCameraMotionId(), 0, 'an idle camera has no motion id'); 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 first = createTickableViewer();
const target = Cesium.Cartesian3.fromDegrees(-97.76, 30.26, 0); const target = Cesium.Cartesian3.fromDegrees(-97.76, 30.26, 0);
let asked = 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' }); const orbit = moveCamera({ motion: 'orbit', mode: 'continuous' });
assert.equal(orbit.ok, true); assert.equal(orbit.ok, true);
assert.equal(orbit.armed, undefined, 'the installed getter supplied a target'); 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'); interruptCameraMotion('test-cleanup');
// Same viewer, no getter: the installed one stays. // Same viewer, no getter: the installed one stays.
initCameraVerbs(first.viewer); initCameraVerbs(first.engine);
const again = moveCamera({ motion: 'orbit', mode: 'continuous' }); const again = moveCamera({ motion: 'orbit', mode: 'continuous' });
assert.equal(again.armed, undefined, 'the getter survives a call without one'); assert.equal(again.armed, undefined, 'the getter survives a call without one');
interruptCameraMotion('test-cleanup'); 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 // 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. // capturing a target from a viewer that no longer exists.
const second = createTickableViewer(); const second = createTickableViewer();
initCameraVerbs(second.viewer); initCameraVerbs(second.engine);
const askedBefore = asked; const askedBefore = asked;
const armed = moveCamera({ motion: 'orbit', mode: 'continuous' }); const armed = moveCamera({ motion: 'orbit', mode: 'continuous' });
assert.equal(armed.armed, 'waiting-for-arrival'); 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', () => { test('a route hidden from Draw is not flown; the newest visible one is', () => {
const viewer = createTickableViewer(); const viewer = createTickableViewer();
initCameraVerbs(viewer.viewer, () => null); initCameraVerbs(viewer.engine, () => null);
const route = (label, hidden) => ({ const route = (label, hidden) => ({
type: 'route', type: 'route',
label, label,
+11 -6
View File
@@ -46,18 +46,23 @@ export function readLayerSource(file) {
: '../layers/vessels', : '../layers/vessels',
); );
// A layer's code includes its renderers (render/*.js) and, for // A layer's code includes its renderers (render/*.js) and, for
// the aircraft layers, the shared aircraft renderer they draw through: // the aircraft layers, the shared aircraft renderer and tracking they draw
// checks on what the layer does keep finding it where it moved. // through: checks on what the layer does keep finding it where it moved.
const directories = [directory, join(directory, 'render')]; 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')); directories.push(join(directory, '../aircraft/render'));
return directories shared.push(join(directory, '../aircraft/tracking.js'));
}
return [
...directories
.filter((dir) => existsSync(dir)) .filter((dir) => existsSync(dir))
.flatMap((dir) => .flatMap((dir) =>
readdirSync(dir) readdirSync(dir)
.filter((name) => name.endsWith('.js')) .filter((name) => name.endsWith('.js'))
.sort() .sort()
.map((name) => readFileSync(join(dir, name), 'utf8')), .map((name) => readFileSync(join(dir, name), 'utf8')),
) ),
.join('\n'); ...shared.map((file) => readFileSync(file, 'utf8')),
].join('\n');
} }
@@ -43,6 +43,12 @@ function createViewer({ cameraPosition, target, pickPosition = null } = {}) {
positionWC: position, positionWC: position,
directionWC: new Cesium.Cartesian3(-1, 0, 0), directionWC: new Cesium.Cartesian3(-1, 0, 0),
upWC: new Cesium.Cartesian3(0, 0, 1), 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), heading: Cesium.Math.toRadians(90),
pitch: Cesium.Math.toRadians(-90), pitch: Cesium.Math.toRadians(-90),
roll: 0, roll: 0,
+6 -22
View File
@@ -3,7 +3,6 @@ import * as Cesium from 'cesium';
import { guardCameraAboveGround } from '../cameraGroundGuard.js'; import { guardCameraAboveGround } from '../cameraGroundGuard.js';
import { rangeForBoundingSphere } from '../data/cameraFraming.js'; import { rangeForBoundingSphere } from '../data/cameraFraming.js';
import { cancelCameraArrival } from '../data/cameraArrival.js'; import { cancelCameraArrival } from '../data/cameraArrival.js';
import { isGlobeEngine } from '../globe/contract.js';
/** All visible positions, path vertices, polygon rings, walls and overlay bounds. */ /** All visible positions, path vertices, polygon rings, walls and overlay bounds. */
export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) { 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 * Fly synchronously to the footprint; later navigation always owns its
* camera. `viewer` is a Cesium viewer or a globe engine (Navara), which flies * camera. `engine` is the globe engine (Cesium or Navara), which flies the
* the same sphere through the contract's flyToSphere. * sphere through the contract's flyToSphere.
*/ */
export function flyToDataSource( export function flyToDataSource(
viewer, engine,
dataSource, dataSource,
{ duration = 1.5, ground = null } = {}, { duration = 1.5, ground = null } = {},
) { ) {
const points = dataSourcePoints(dataSource); const points = dataSourcePoints(dataSource);
if (!points.length) return false; if (!points.length) return false;
cancelCameraArrival(viewer); cancelCameraArrival(engine);
const positions = points.map(([lon, lat, height]) => const positions = points.map(([lon, lat, height]) =>
Cesium.Cartesian3.fromDegrees(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]); Cesium.Cartographic.fromDegrees(points[0][0], points[0][1]);
const lat = Cesium.Math.toDegrees(center.latitude), const lat = Cesium.Math.toDegrees(center.latitude),
lon = Cesium.Math.toDegrees(center.longitude); lon = Cesium.Math.toDegrees(center.longitude);
const engine = isGlobeEngine(viewer) ? viewer : null; const terrain = engine.surface.terrainHeightSupported
const terrain = engine
? engine.surface.terrainHeightSupported
? engine.surface.terrainHeight(center) ? engine.surface.terrainHeight(center)
: undefined : undefined;
: viewer.scene?.globe?.getHeight?.(center);
const cached = ground?.cachedGroundFloor?.(lat, lon); const cached = ground?.cachedGroundFloor?.(lat, lon);
const height = const height =
Number.isFinite(terrain) && terrain > 0 Number.isFinite(terrain) && terrain > 0
@@ -119,7 +115,6 @@ export function flyToDataSource(
sphere.radius, sphere.radius,
Cesium.Cartesian3.distance(sphere.center, position), Cesium.Cartesian3.distance(sphere.center, position),
); );
if (engine) {
const pose = engine.camera.pose(); const pose = engine.camera.pose();
const aspect = pose.aspectRatio ?? 1; const aspect = pose.aspectRatio ?? 1;
// Cesium's frustum fov: the wider side's angle. // Cesium's frustum fov: the wider side's angle.
@@ -143,14 +138,3 @@ export function flyToDataSource(
); );
return true; 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 }),
});
return true;
}
+39 -32
View File
@@ -2,8 +2,21 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { cancelCameraArrival } from '../data/cameraArrival.js'; 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'; 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() { function source() {
const ds = new Cesium.CustomDataSource('t'); const ds = new Cesium.CustomDataSource('t');
ds.entities.add({ 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', () => { test('the camera frames the footprint at the surface, never a buried center', () => {
const calls = []; const { engine, calls } = cesiumEngine();
const viewer = { assert.equal(flyToDataSource(engine, source()), true);
camera: { const [flight] = calls();
heading: 0, const center = Cesium.Cartographic.fromCartesian(flight.sphere.center);
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);
assert.ok(Math.abs(center.height) < 50, `center height ${center.height}`); 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( assert.equal(
typeof calls[0].options.complete, typeof flight.options.complete,
'function', 'function',
'the ground guard runs on landing', 'the ground guard runs on landing',
); );
assert.equal( assert.equal(
flyToDataSource(viewer, new Cesium.CustomDataSource('empty')), flyToDataSource(engine, new Cesium.CustomDataSource('empty')),
false, false,
); );
assert.equal( assert.equal(
calls.length, calls().length,
1, 1,
'empty sources never start deferred navigation', '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]), positions: Cesium.Cartesian3.fromDegreesArray([-105, 39, -104.9, 39]),
}, },
}); });
const calls = []; const { viewer, engine, calls } = cesiumEngine();
const viewer = { viewer.scene.globe.getHeight = () => 2600;
scene: { globe: { getHeight: () => 2600 } }, // A 60° field of view on the narrower side, as Cesium's frustum keeps it:
camera: { // vertical in portrait, horizontal in landscape.
frustum: { fov: Math.PI / 3, aspectRatio: 390 / 844 }, const { frustum } = viewer.camera;
flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }), frustum.aspectRatio = 390 / 844;
}, frustum.fovy = Math.PI / 3;
}; flyToDataSource(engine, ds);
flyToDataSource(viewer, ds);
assert.ok( assert.ok(
Math.abs( Math.abs(
Cesium.Cartographic.fromCartesian(calls[0].sphere.center).height - 2600, Cesium.Cartographic.fromCartesian(calls()[0].sphere.center).height - 2600,
) < 1e-6, ) < 1e-6,
); );
assert.equal(calls[0].options.offset.pitch, -Math.PI / 2); assert.equal(calls()[0].options.offset.pitch, -Math.PI / 2);
viewer.camera.frustum.aspectRatio = 16 / 9; frustum.aspectRatio = 16 / 9;
flyToDataSource(viewer, ds); frustum.fovy = 2 * Math.atan(Math.tan(Math.PI / 6) / frustum.aspectRatio);
assert.ok(calls[0].options.offset.range > calls[1].options.offset.range); flyToDataSource(engine, ds);
assert.ok(calls()[0].options.offset.range > calls()[1].options.offset.range);
const symmetric = { const symmetric = {
entities: { entities: {
values: [ values: [
@@ -138,10 +145,10 @@ test('portrait framing accounts for FOV, high ground and symmetric extents', ()
], ],
}, },
}; };
assert.doesNotThrow(() => flyToDataSource(viewer, symmetric)); assert.doesNotThrow(() => flyToDataSource(engine, symmetric));
assert.ok(Number.isFinite(calls.at(-1).options.offset.range)); assert.ok(Number.isFinite(calls().at(-1).options.offset.range));
assert.doesNotThrow(() => calls.at(-1).options.complete()); assert.doesNotThrow(() => calls().at(-1).options.complete());
cancelCameraArrival(viewer); cancelCameraArrival(engine);
}); });
test('non-finite positions and hidden features never enter the camera footprint', () => { 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. * and returns their disconnect.
* *
* The imported files belong to the session; their drawing belongs to the * 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), * `attachScene({ engine, draw })` on a globe engine without one (Navara),
* where `draw(dataSource)` resolves with that engine's drawing of a file * where `draw(dataSource)` resolves with that engine's drawing of a file
* ({ report, setShow, refresh, destroy }: src/layers/shared/render/navaraStory.js). * ({ report, setShow, refresh, destroy }: src/layers/shared/render/navaraStory.js).
@@ -67,7 +68,8 @@ export function createGeoFileImport({
let hint = ''; let hint = '';
const lifetime = new AbortController(); const lifetime = new AbortController();
// The attached globe ({ viewer, engine, draw, controller }): a Cesium // 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; let scene = null;
const listen = (target, type, listener, options) => { const listen = (target, type, listener, options) => {
@@ -186,9 +188,9 @@ export function createGeoFileImport({
} }
const fly = (item) => { const fly = (item) => {
if (!scene || !item.dataSource) return false; if (!scene?.engine || !item.dataSource) return false;
if (!item.visible) setVisible(item.id, true); if (!item.visible) setVisible(item.id, true);
flyToSource(scene.viewer ?? scene.engine, item.dataSource, { ground }); flyToSource(scene.engine, item.dataSource, { ground });
return true; return true;
}; };
@@ -471,7 +473,7 @@ export function createGeoFileImport({
if (typeof offMapSource === 'function') groundRemovers.push(offMapSource); if (typeof offMapSource === 'function') groundRemovers.push(offMapSource);
const attached = (scene = { const attached = (scene = {
viewer, viewer,
engine: viewer ? null : engine, engine,
draw: viewer ? null : drawOnEngine, draw: viewer ? null : drawOnEngine,
controller: new AbortController(), controller: new AbortController(),
}); });
+13 -2
View File
@@ -90,6 +90,9 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
}, },
flyTo: (target) => flights.push(target), 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 revoked = [];
const loads = []; const loads = [];
const defaultLoad = async (file, opts) => { const defaultLoad = async (file, opts) => {
@@ -110,7 +113,12 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
document, document,
...(navigation ? { navigation } : {}), ...(navigation ? { navigation } : {}),
...(subscribeMapSource ? { subscribeMapSource } : {}), ...(subscribeMapSource ? { subscribeMapSource } : {}),
flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)), flyToSource:
flyToSource ||
((target, ds) => {
flownOn.push(target);
flights.push(ds);
}),
publish: (view) => { publish: (view) => {
shown = view; shown = view;
}, },
@@ -119,15 +127,17 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
return () => (actions = null); return () => (actions = null);
}, },
}); });
tool?.attachScene({ viewer }); tool?.attachScene({ viewer, engine });
return { return {
tool, tool,
els, els,
document, document,
viewer, viewer,
engine,
added, added,
removed, removed,
flights, flights,
flownOn,
revoked, revoked,
loads, loads,
renders: () => renders, renders: () => renders,
@@ -166,6 +176,7 @@ test('a pick imports each file', async () => {
); );
assert.equal(rows(t).length, 2); assert.equal(rows(t).length, 2);
assert.equal(t.flights.length, 1, 'the first file of a batch is flown to'); 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.equal(t.shown().hint, 'Added 2 files.');
assert.deepEqual( assert.deepEqual(
t.tool.list().map((f) => f.name), t.tool.list().map((f) => f.name),
+4 -7
View File
@@ -375,9 +375,8 @@ export function createGevActionRunner({
{ memory: analystMemory }, { memory: analystMemory },
)); ));
installViewTargetPrewarm(engine); installViewTargetPrewarm(engine);
// Keep Cesium's viewer rig; viewerless engines use the contract rig. if (cameraVerbsSupported(engine))
if (cameraVerbsSupported(viewer ?? engine)) initCameraVerbs(engine, () => getViewTargetCartesian(engine), {
initCameraVerbs(viewer ?? engine, () => getViewTargetCartesian(engine), {
isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active), isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active),
}); });
const annotationsAvailable = (method) => const annotationsAvailable = (method) =>
@@ -398,11 +397,9 @@ export function createGevActionRunner({
case 'clear_annotations': case 'clear_annotations':
return annotationsAvailable('clear'); return annotationsAvailable('clear');
case 'move_camera': case 'move_camera':
return cameraVerbsSupported(viewer ?? engine); return cameraVerbsSupported(engine);
case 'fly_route': case 'fly_route':
return ( return cameraVerbsSupported(engine) && annotationsAvailable('list');
cameraVerbsSupported(viewer ?? engine) && annotationsAvailable('list')
);
case 'set_visual_style': case 'set_visual_style':
return renderSupported('visualStyles', 'setStyle'); return renderSupported('visualStyles', 'setStyle');
case 'set_cyber_sonar': case 'set_cyber_sonar':
+2
View File
@@ -163,6 +163,8 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) {
}, },
globe: { getHeight: () => 0 }, globe: { getHeight: () => 0 },
tweens: [], tweens: [],
// The frame event a camera verb advances on (through the engine).
preRender: new Cesium.Event(),
}, },
camera: { camera: {
moveEnd: { addEventListener() {} }, moveEnd: { addEventListener() {} },
+5 -12
View File
@@ -93,7 +93,7 @@ function fixture(t, kind, { native = kind === 'cesium' } = {}) {
...options, ...options,
floorServices: { cachedGroundFloor: () => 0, warmGroundFloor: () => {} }, floorServices: { cachedGroundFloor: () => 0, warmGroundFloor: () => {} },
}); });
t.after(() => releaseCameraVerbs(viewer ?? engine)); t.after(() => releaseCameraVerbs(engine));
return { engine, viewer, annotations, styleManager, calls, build }; return { engine, viewer, annotations, styleManager, calls, build };
} }
@@ -129,12 +129,9 @@ for (const kind of ['cesium', 'navara']) {
const result = await f.build()(action, args); const result = await f.build()(action, args);
assert.equal(result.ok, true, JSON.stringify(result)); assert.equal(result.ok, true, JSON.stringify(result));
assert.equal(result.engine, kind); assert.equal(result.engine, kind);
// The camera verbs drive the engine on both.
if (call) assert.equal(f.calls[0][0], call); if (call) assert.equal(f.calls[0][0], call);
else else assert.equal(getActiveCameraMotion().rig, 'engine');
assert.equal(
getActiveCameraMotion().rig,
f.viewer ? 'viewer' : 'engine',
);
if (action === 'set_post_processing') { if (action === 'set_post_processing') {
assert.deepEqual( assert.deepEqual(
f.calls.map(([name]) => name), 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 === 'annotate_map') delete f.annotations.annotate;
if (action === 'clear_annotations') delete f.annotations.clear; if (action === 'clear_annotations') delete f.annotations.clear;
if (action === 'fly_route') delete f.annotations.list; if (action === 'fly_route') delete f.annotations.list;
if (action === 'move_camera') { if (action === 'move_camera') delete f.engine.camera.lookAt;
if (f.viewer) delete f.viewer.clock.onTick;
else delete f.engine.camera.lookAt;
}
if (action === 'set_visual_style') { if (action === 'set_visual_style') {
if (f.viewer) delete f.styleManager.setStyle; if (f.viewer) delete f.styleManager.setStyle;
else delete f.engine.render.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) => { test(`${kind}: fly_route also requires camera verbs`, async (t) => {
const f = fixture(t, kind); const f = fixture(t, kind);
if (f.viewer) delete f.viewer.clock.onTick; delete f.engine.camera.lookAt;
else delete f.engine.camera.lookAt;
assert.equal((await f.build()('fly_route')).code, UNAVAILABLE_ON_ENGINE); assert.equal((await f.build()('fly_route')).code, UNAVAILABLE_ON_ENGINE);
}); });