fix(globe): render holds and commanded on Navara

The render governor installs on a non-Cesium globe engine as its backend:
owner-counted holds drive engine.render.setContinuous and requests go to
engine.render.request, so orbit and every other per-frame owner keep
Navara drawing while held and let it idle once the last hold goes. Cesium
stays on the viewer path its engine delegates to.

A user gesture on Navara now ends a flight without raising
camera.commanded (the user is not a new camera owner). Headless Navara QA
checks both.
This commit is contained in:
German Khokhlov committed 2026-10-15 20:06:13 +04:00
1 parent c6e1b65c67
commit d112ecf296
5 files changed
+81 -5

No files matched your search

+5
View File
@@ -389,6 +389,11 @@ export function createCameraModel({
camera, camera,
sync, sync,
afterFrame, afterFrame,
/**
* Ends a running flight (its `cancel` runs) without camera.commanded:
* for the user's own gestures, which are not a new camera owner.
*/
stopFlight: () => cancelCurrentFlight(),
/** The current camera for projections: pose, vertical fov and CSS size. */ /** The current camera for projections: pose, vertical fov and CSS size. */
view, view,
worldToWindow: (position, result) => worldToWindow: (position, result) =>
+2 -1
View File
@@ -120,7 +120,8 @@ export function createNavaraEngine(
view.on(name, handler); view.on(name, handler);
const input = createNavaraInput(view, { const input = createNavaraInput(view, {
onCameraInput: () => model.camera.cancelFlight(), // A gesture is the user, not a new camera owner: no camera.commanded.
onCameraInput: () => model.stopFlight(),
}); });
const credits = createNavaraCredits(view); const credits = createNavaraCredits(view);
const maps = createNavaraMaps(view, fetch ? { fetch } : {}); const maps = createNavaraMaps(view, fetch ? { fetch } : {});
@@ -226,3 +226,20 @@ test('pickGlobe meets the terrain along the pick ray, or the ellipsoid', () => {
near(math.toDegrees(place.longitude), 0, 1e-6, 'longitude'); near(math.toDegrees(place.longitude), 0, 1e-6, 'longitude');
assert.equal(engine.picker.pickGlobe(cartesian2.create(640, -1e7)), undefined); assert.equal(engine.picker.pickGlobe(cartesian2.create(640, -1e7)), undefined);
}); });
test('a user gesture cancels a flight without raising camera.commanded', () => {
const { engine, view } = setup();
const outcome = [];
let commanded = 0;
engine.camera.flyTo({
destination: cartesian3.fromDegrees(10, 10, 1e6),
duration: 5,
cancel: () => outcome.push('cancel'),
});
engine.camera.commanded.addEventListener(() => commanded++);
view.canvas.dispatchEvent(new Event('pointerdown'));
view.canvas.dispatchEvent(new Event('wheel'));
assert.deepEqual(outcome, ['cancel']);
assert.equal(engine.camera.flightActive(), false);
assert.equal(commanded, 0, 'a gesture is the user, not a new camera owner');
});
+31 -4
View File
@@ -36,9 +36,17 @@
* cycle) and keeps the viewer for what the contract leaves to the engine: * cycle) and keeps the viewer for what the contract leaves to the engine:
* `scene.requestRenderMode`, `scene.maximumRenderTimeChange` and * `scene.requestRenderMode`, `scene.maximumRenderTimeChange` and
* `scene.requestRender()`. It needs no frame events (`engine.frames`). * `scene.requestRender()`. It needs no frame events (`engine.frames`).
*
* Any other globe engine (Navara) is the governor's backend instead: install
* it on the engine and the owner-counted holds drive
* `engine.render.setContinuous`, requests go to `engine.render.request`.
*/ */
import { isGlobeEngine } from './globe/contract.js';
let _viewer = null; let _viewer = null;
/** The non-Cesium engine the governor drives, or null (Cesium: `_viewer`). */
let _engine = null;
let _installed = false; let _installed = false;
const _holds = new Set(); const _holds = new Set();
@@ -47,8 +55,15 @@ const _recentRequests = [];
const RECENT_REQUEST_CAP = 16; const RECENT_REQUEST_CAP = 16;
function applyMode() { function applyMode() {
if (!_installed || !_viewer?.scene) return; if (!_installed) return;
const continuous = _holds.size > 0; const continuous = _holds.size > 0;
if (_engine) {
if (_engine.render.isContinuous() !== continuous)
_engine.render.setContinuous(continuous);
if (!continuous) _engine.render.request('governor-idle');
return;
}
if (!_viewer?.scene) return;
const scene = _viewer.scene; const scene = _viewer.scene;
if (scene.requestRenderMode === !continuous) return; if (scene.requestRenderMode === !continuous) return;
scene.requestRenderMode = !continuous; scene.requestRenderMode = !continuous;
@@ -64,12 +79,21 @@ function applyMode() {
* hold/release still record into the holds set (and apply at install time); * hold/release still record into the holds set (and apply at install time);
* requests are safe no-ops — so modules can call all three unconditionally * requests are safe no-ops — so modules can call all three unconditionally
* in tests without a viewer. * in tests without a viewer.
* @param {Cesium.Viewer} viewer * @param {Cesium.Viewer | import('./globe/contract.js').GlobeEngine} viewer
* The Cesium viewer, or a non-Cesium globe engine.
* @returns {void} * @returns {void}
*/ */
export function installRenderGovernor(viewer) { export function installRenderGovernor(viewer) {
if (isGlobeEngine(viewer) && viewer.kind !== 'cesium') {
_engine = viewer;
_viewer = viewer;
_installed = true;
applyMode();
return;
}
if (!viewer?.scene) if (!viewer?.scene)
throw new TypeError('installRenderGovernor requires a Cesium viewer'); throw new TypeError('installRenderGovernor requires a Cesium viewer');
_engine = null;
_viewer = viewer; _viewer = viewer;
_installed = true; _installed = true;
// Never let Cesium re-render on simulation-time deltas behind our back — // Never let Cesium re-render on simulation-time deltas behind our back —
@@ -115,12 +139,13 @@ export function releaseContinuousRender(ownerId) {
* @returns {void} * @returns {void}
*/ */
export function governorRequestRender(reason = 'unspecified') { export function governorRequestRender(reason = 'unspecified') {
if (!_installed || !_viewer?.scene) return; if (!_installed || (!_engine && !_viewer?.scene)) return;
if (_holds.size === 0) { if (_holds.size === 0) {
_recentRequests.push({ reason, at: Date.now() }); _recentRequests.push({ reason, at: Date.now() });
if (_recentRequests.length > RECENT_REQUEST_CAP) _recentRequests.shift(); if (_recentRequests.length > RECENT_REQUEST_CAP) _recentRequests.shift();
} }
_viewer.scene.requestRender?.(); if (_engine) _engine.render.request(reason);
else _viewer.scene.requestRender?.();
} }
/** /**
@@ -150,6 +175,7 @@ export function isRenderGovernorInstalled(viewer) {
export function uninstallRenderGovernor(viewer) { export function uninstallRenderGovernor(viewer) {
if (_viewer !== viewer) return; if (_viewer !== viewer) return;
_viewer = null; _viewer = null;
_engine = null;
_installed = false; _installed = false;
_holds.clear(); _holds.clear();
_recentRequests.length = 0; _recentRequests.length = 0;
@@ -158,6 +184,7 @@ export function uninstallRenderGovernor(viewer) {
/** Test seam: reset module state between unit tests. */ /** Test seam: reset module state between unit tests. */
export function _resetRenderGovernorForTest() { export function _resetRenderGovernorForTest() {
_viewer = null; _viewer = null;
_engine = null;
_installed = false; _installed = false;
_holds.clear(); _holds.clear();
_recentRequests.length = 0; _recentRequests.length = 0;
+26
View File
@@ -7,6 +7,8 @@ import {
governorRequestRender, governorRequestRender,
getRenderGovernorDiagnostics, getRenderGovernorDiagnostics,
_resetRenderGovernorForTest, _resetRenderGovernorForTest,
isRenderGovernorInstalled,
uninstallRenderGovernor,
} from './renderGovernor.js'; } from './renderGovernor.js';
function makeViewer() { function makeViewer() {
@@ -110,3 +112,27 @@ test('isRenderGovernorInstalled names the one governed viewer', async () => {
uninstallRenderGovernor(viewer); uninstallRenderGovernor(viewer);
assert.equal(isRenderGovernorInstalled(viewer), false); assert.equal(isRenderGovernorInstalled(viewer), false);
}); });
test('on a non-Cesium globe engine the governor drives engine.render with owner-counted holds', async () => {
const { createFakeEngine } = await import('./globe/testing/fakeEngine.js');
_resetRenderGovernorForTest();
const engine = createFakeEngine({ kind: 'navara' });
holdContinuousRender('before-install');
installRenderGovernor(engine);
assert.equal(engine.render.isContinuous(), true, 'a hold taken before install applies');
releaseContinuousRender('before-install');
assert.equal(engine.render.isContinuous(), false);
holdContinuousRender('a');
holdContinuousRender('b');
assert.equal(engine.render.isContinuous(), true);
releaseContinuousRender('a');
assert.equal(engine.render.isContinuous(), true, 'b still holds');
releaseContinuousRender('b');
assert.equal(engine.render.isContinuous(), false);
engine.fake.resetCalls();
governorRequestRender('slider');
assert.deepEqual(engine.fake.callsOf('render.request').map((c) => c.args), [['slider']]);
assert.equal(isRenderGovernorInstalled(engine), true);
uninstallRenderGovernor(engine);
_resetRenderGovernorForTest();
});