Move shared follow offsets and Draw onto the globe contract

Share links read the current follow handle and displayed position through
engine.camera and restore their ENU offset with follow's swingTo option.
Cesium retains its first-frame wait, displayed-position cache, 60-degree
frame limit and range interpolation. Navara and the fake engine apply the
same swing. Cancellation, camera handoff and destruction release it; an
externally tracked Cesium entity keeps its existing handle.

Draw uses one surface with engine input, geodesic lines and point movers.
An exclusive pointer claim suppresses ambient subscriptions and deferred
picks across ownership changes. Cesium owns suspension and exact restoration
of its stock select and track actions. Camera gestures remain enabled.
Detaching releases the sketch and preview and retains shape and colour.
The separate Navara surface and Cesium data-source preview are removed.

Cesium remains the reference for clicked heights, line width and dash,
vertex size and outline, keyboard handling and completed annotations.
One deliberate visual fix is that the occluded rubber band uses the selected
colour instead of retaining the initial amber: it now identifies the same
sketch as its visible segment. Navara adopts Cesium's geodesic preview edges.
Finished annotation drapes are unchanged.

External Cesium import files fall from 7 to 5, viewer lines from 1005 to
953, and engine-name lines from 39 to 37. No coupling metric grows.

The full unit suite, import directions, package boundaries, formatting
and coupling checks pass.

Checked in the browser, keyless: the contract suite passes on Navara and on
Cesium apart from the camera-events check that also fails intermittently
before; qa-navara-draw passes; qa-draw-tool passes apart from its hidden-line
pixel check, which fails the same way before this change. Its destroy check
now expects no preview data source, since the preview has none.
This commit is contained in:
German Khokhlov committed 2026-11-07 14:31:59 +04:00
1 parent 910d693b94
commit dacfd33f10
31 files changed
+1071 -785

No files matched your search

+7
View File
@@ -218,6 +218,13 @@ one application, and only the globe-bound parts move to the next engine.
engines, and `scripts/qa-ground-looks.mjs` drew the jam line and the
route on Cesium as the old materials did, with no pixel changed at four
moments of the pulse and the dashes.
- **Shared follow and Draw.** Share links read the follow target, identity and
camera pose through the engine; their saved offset uses `camera.follow`'s
`swingTo` option on both engines. Cesium keeps its guarded 60-degree frame
swing. Draw uses one contract surface with lines, point movers and an
exclusive `engine.input` pointer claim. Cesium's stock select/track actions
are suspended by the engine while drawing and restored on release. Scene
detach ends the sketch and removes the preview, retaining shape and colour.
- **Engine-owned effects and camera input.** Cesium's viewer, atmosphere
compatibility, zoom guard, starry sky and map implementation live in
`src/globe/cesium/`. Styles, bloom and contact sonar use `engine.render`.
+24
View File
@@ -566,3 +566,27 @@ Annotation drapes still use their existing renderers. Their bounded building
classification volumes, animated fills and wire cages are not ordinary world
triangles; they need a classification contract before that migration can
preserve Cesium's behaviour.
## Shared follow offsets and manual drawing
`engine.camera.follow({ position, viewFrom, swingTo })` can swing from the
initial follow frame to `swingTo`, an east/north/up offset in metres. Each
frame turns at most 60 degrees and interpolates range, preserving Cesium's
tracked-camera crossing guard. `handle.retarget({ swingTo })` changes only
the pending swing; `null` cancels it. Stopping or replacing the follow, a
camera command or engine destruction ends it. Share links read the current
handle's target and label identity and the camera pose through the contract.
`engine.input.acquirePointer()` returns an exclusive claim, or `null` if
one is held. Pass it to `input.on(type, listener, { pointer: claim })` and
call `claim.release()` when done. Other screen subscriptions yield, including
picks that settle after ownership changes. Camera gestures stay enabled.
Cesium suspends its stock select and track clicks and restores the original
functions when the claim is released or the engine is destroyed.
The Draw tool uses one `drawSurface.js` on every engine: a dashed geodesic
line and a point mover set at the clicked heights. The dim, occluded band
uses the selected colour as well as the visible band. Finished geometry still
goes through the annotation board. Detaching the scene releases the claim,
listeners and preview while retaining the chosen shape and colour. The
page's pointer lease still arbitrates Draw against other tools.
+4 -4
View File
@@ -2,15 +2,15 @@
"metrics": {
"cesiumLayerRendererLines": 210,
"navaraLayerRendererLines": 794,
"cesiumImportFiles": 7,
"viewerLines": 1005,
"cesiumImportFiles": 5,
"viewerLines": 953,
"navaraKindChecks": 2,
"engineSniffCalls": 0,
"engineExtensionReads": 6,
"groundEffectPasses": 1,
"cesiumSdkLoads": 3,
"screenSpaceHandlerFiles": 4,
"engineNameLines": 39
"engineNameLines": 37
},
"measuredAt": "engine.meshes: CCTV and data packs on one primitive; dead Navara stems helper removed"
"measuredAt": "Move shared follow offsets and Draw input and previews onto the globe contract"
}
+6 -1
View File
@@ -1680,5 +1680,10 @@
"src/scenes/dataPacks/shapes.test.mjs",
"src/globe/cesium/meshes.test.mjs",
"src/globe/meshes.test.mjs",
"src/scenes/dataPacks/display.test.mjs"
"src/scenes/dataPacks/display.test.mjs",
"src/globe/exclusivePointer.js",
"src/globe/cesium/followOffset.js",
"src/annotations/drawSurface.js",
"src/annotations/drawSurface.test.mjs",
"src/annotations/drawToolBehaviour.test.mjs"
]
+9 -4
View File
@@ -3401,7 +3401,7 @@
"src/app/engineChoice.js",
"src/app/navaraMode.js",
"src/annotations/navaraAnnotationRenderer.js",
"src/annotations/drawInputNavara.js",
"src/annotations/drawSurface.js",
"src/globe/cesium/sceneProjection.js",
"src/globe/cesium/screenEvents.js",
"src/globe/cesium/trail.js",
@@ -3579,7 +3579,9 @@
"src/data/geoImportDisplay.js",
"src/globe/cesium/meshes.js",
"src/globe/meshes.js",
"src/globe/navara/meshes.js"
"src/globe/navara/meshes.js",
"src/globe/exclusivePointer.js",
"src/globe/cesium/followOffset.js"
],
"external": [
"hls.js",
@@ -4339,7 +4341,9 @@
"src/data/geoImportKml.js",
"src/data/geoImportDisplay.js",
"src/globe/cesium/meshes.js",
"src/globe/meshes.js"
"src/globe/meshes.js",
"src/globe/exclusivePointer.js",
"src/globe/cesium/followOffset.js"
],
"external": [
"hls.js",
@@ -5103,7 +5107,8 @@
"src/styles/anime.js",
"src/styles/noir.js",
"src/globe/cesium/meshes.js",
"src/globe/meshes.js"
"src/globe/meshes.js",
"src/globe/exclusivePointer.js"
],
"external": ["cesium", "egm96-universal", "pbf"]
},
+7 -8
View File
@@ -329,13 +329,12 @@ try {
check(
'three enable/disable cycles add no listener, handler or preview data source',
after.domListeners === before.domListeners &&
after.previewDataSources === 1 &&
before.previewDataSources === 1 &&
after.previewDataSources === 0 &&
before.previewDataSources === 0 &&
after.sceneHandler === false &&
after.pointerOwner === null &&
after.stockDoubleClick === before.stockDoubleClick,
after.pointerOwner === null,
`listeners ${before.domListeners}→${after.domListeners}, previews ${after.previewDataSources}, ` +
`handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`,
`handler ${after.sceneHandler}, owner ${after.pointerOwner}`,
);
// ── the viewer's own click actions are borrowed, then given back ────────
@@ -873,7 +872,7 @@ try {
finalState.active === false &&
finalState.pointerOwner === null &&
finalState.sceneHandler === false &&
finalState.previewDataSources === 1,
finalState.previewDataSources === 0,
JSON.stringify(finalState),
);
// ── destroy(): the shell disposing the tool leaves nothing behind ───────
@@ -919,9 +918,9 @@ try {
JSON.stringify(destroyed.diagnostics),
);
check(
'destroy() detaches the preview data source and gives the window handle back',
'destroy() leaves no preview data source and gives the window handle back',
destroyed.previews === 0 &&
destroyed.dataSources === beforeDestroy - 1 &&
destroyed.dataSources === beforeDestroy &&
destroyed.handleGone,
`previews ${destroyed.previews}, dataSources ${beforeDestroy} → ${destroyed.dataSources}, handle gone ${destroyed.handleGone}`,
);
-150
View File
@@ -1,150 +0,0 @@
import '../testSupport/registerDom.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import {
isPointerFree,
pointerOwner,
resetPointerOwnership,
} from '../data/inputOwnership.js';
import { createSessionDrawTool } from './drawTool.js';
import { navaraDrawSurface } from './drawInputNavara.js';
import * as geo from '../geo/index.js';
/** The board double the tool draws on (its calls are what Draw sends). */
function fakeAnnotations() {
const calls = { annotate: [], frame: [] };
return {
calls,
async annotate(specs, options) {
calls.annotate.push({ specs, options });
return { drawn: specs.length };
},
clear() {},
list: () => [],
onChange: () => () => {},
frame: (id) => calls.frame.push(id),
};
}
function harness(t) {
resetPointerOwnership();
document.body.replaceChildren();
const toggle = document.createElement('button');
toggle.id = 'draw-toggle';
document.body.append(toggle);
// Over (0, 0): the canvas centre is the ellipsoid under the camera.
const engine = createFakeEngine({ kind: 'navara', altitude: 20000 });
const annotations = fakeAnnotations();
let actions = null;
const tool = createSessionDrawTool({
annotations,
connect: (next) => {
actions = next;
return () => (actions = null);
},
});
t.after(() => {
tool.destroy();
resetPointerOwnership();
});
const attach = () => tool.attachScene({ surface: navaraDrawSurface(engine) });
attach();
const click = (x, y) => engine.fake.input('click', { position: { x, y } });
const key = (name) =>
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: name, bubbles: true }),
);
return {
engine,
tool,
annotations,
attach,
click,
key,
act: (name, ...args) => actions?.[name]?.(...args),
line: () => engine.fake.line('gev-draw-preview'),
dots: () => engine.fake.moverSet('gev-draw-vertices'),
};
}
test('on Navara a click is a vertex, the rubber band follows the pointer and a double click finishes', async (t) => {
const h = harness(t);
h.act('toggleDraw');
assert.equal(pointerOwner(), 'draw', 'the session holds the pointer');
assert.equal(h.tool.diagnostics().sceneHandler, true);
h.tool.setShape('area');
h.click(500, 380);
h.click(540, 380);
h.click(540, 420);
assert.equal(h.tool.session.vertices.length, 3);
assert.equal(
h.engine.movers.list().find(({ id }) => id === 'gev-draw-vertices').count,
3,
);
// The band: three vertices, the pointer and the closing vertex.
h.engine.fake.input('move', {
startPosition: { x: 540, y: 420 },
endPosition: { x: 500, y: 420 },
});
const band = h.line();
assert.equal(band.shown, true);
assert.equal(band.positions.length, 5 * 3);
assert.equal(band.options.dash.lengthPx, 8);
assert.equal(band.options.dash.gapPx, 8);
// The vertex hangs where the click landed (the ellipsoid here).
const first = geo.cartographic.fromCartesian({
x: band.positions[0],
y: band.positions[1],
z: band.positions[2],
});
assert.ok(Math.abs(first.height) < 1);
h.engine.fake.input('doubleClick', { position: { x: 500, y: 420 } });
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 1);
const [spec] = h.annotations.calls.annotate[0].specs;
assert.equal(spec.type, 'area');
assert.equal(spec.manual, true);
assert.ok(spec.ring.length >= 3);
assert.equal(h.tool.session.vertices.length, 0, 'a fresh shape is open');
});
test('Escape cancels the open shape, then leaves Draw and frees the pointer', (t) => {
const h = harness(t);
h.act('toggleDraw');
h.tool.setShape('line');
h.click(500, 380);
h.click(560, 380);
assert.equal(h.tool.session.vertices.length, 2);
h.key('Escape');
assert.equal(h.tool.session.vertices.length, 0, 'the sketch is cancelled');
assert.equal(h.tool.active, true);
h.key('Escape');
assert.equal(h.tool.active, false);
assert.equal(isPointerFree(), true);
assert.equal(h.tool.diagnostics().sceneHandler, false);
});
test('a switch mid-sketch ends the sketch, takes the preview off the engine and keeps shape and colour', (t) => {
const h = harness(t);
h.act('toggleDraw');
h.tool.setShape('line');
h.tool.setColor('amber');
h.click(500, 380);
h.click(560, 380);
assert.ok(h.line());
h.tool.detachScene();
assert.equal(h.tool.active, false);
assert.equal(isPointerFree(), true, 'the pointer is free after the switch');
assert.equal(h.line(), undefined, 'the band left the engine');
assert.equal(h.dots(), undefined);
// A click on the old engine does nothing now.
h.click(520, 400);
assert.equal(h.tool.session, null);
h.attach();
assert.equal(h.tool.shape, 'line');
assert.equal(h.tool.color, 'amber');
h.act('toggleDraw');
h.click(500, 380);
assert.equal(h.tool.session.vertices.length, 1);
});
@@ -1,10 +1,3 @@
// UTILITIES ▸ Draw on a globe engine without a Cesium viewer (Navara): the
// draw surface src/annotations/drawTool.js runs on. Clicks, moves and the
// double click come from engine.input (the pointer claim the tool holds
// makes every layer's own click yield, as on Cesium); the open shape shows
// as a dashed engine line (the rubber band) and a dot per vertex, at the
// height each click landed on. No Cesium, no three.
import * as geo from '../geo/index.js';
import { pickWorldFromScreen } from './annotationResolver.js';
import { DRAW_COLOR_CSS } from './drawMarks.js';
@@ -32,7 +25,7 @@ const world = (v) => geo.cartesian3.fromDegrees(v.lon, v.lat, v.height || 0);
* input and preview initDrawTool runs on.
* @param {import('../globe/contract.js').GlobeEngine} engine
*/
export function navaraDrawSurface(engine) {
export function createDrawSurface(engine) {
return (live) => {
let line = null;
let dots = null;
@@ -44,6 +37,7 @@ export function navaraDrawSurface(engine) {
line ??= engine.lines.createLine({
id: LINE_ID,
widthPx: 3,
arc: 'geodesic',
color: cssRgba(DRAW_COLOR_CSS.primary, 0.9),
// Cesium's depth-fail dash at 0.35 of the colour, 0.9 in front.
depthFailAlpha: 0.35 / 0.9,
@@ -120,12 +114,12 @@ export function navaraDrawSurface(engine) {
engine.render.request('draw');
},
bound: () => Boolean(removers),
bind({ click, move, doubleClick }) {
bind({ click, move, doubleClick }, pointer) {
if (removers || destroyed) return;
removers = [
engine.input.on('click', click),
engine.input.on('move', move),
engine.input.on('doubleClick', doubleClick),
engine.input.on('click', click, { pointer }),
engine.input.on('move', move, { pointer }),
engine.input.on('doubleClick', doubleClick, { pointer }),
];
},
unbind() {
@@ -134,16 +128,13 @@ export function navaraDrawSurface(engine) {
},
diagnostics: ({ destroyed: toolDestroyed }) => ({
sceneHandler: Boolean(removers),
// Navara draws no Cesium data source; its preview is one line and
// one dot per vertex.
// One line and one dot per vertex.
previewDataSources: 0,
previewEntities: toolDestroyed
? 0
: (line ? 1 : 0) + (dots ? dots.count() : 0),
previewLines: engine.lines.list().filter(({ id }) => id === LINE_ID)
.length,
stockSingleClick: false,
stockDoubleClick: false,
}),
settled: () => Promise.resolve(),
destroy() {
+255
View File
@@ -0,0 +1,255 @@
import '../testSupport/registerDom.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import {
isPointerFree,
pointerOwner,
resetPointerOwnership,
} from '../data/inputOwnership.js';
import { createSessionDrawTool } from './drawTool.js';
import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs';
import { createFakeEventHandlers } from '../globe/cesium/testing/fakeViewer.mjs';
import * as Cesium from 'cesium';
import * as geo from '../geo/index.js';
/** The board double the tool draws on (its calls are what Draw sends). */
function fakeAnnotations() {
const calls = { annotate: [], frame: [] };
return {
calls,
async annotate(specs, options) {
calls.annotate.push({ specs, options });
return { drawn: specs.length };
},
clear() {},
list: () => [],
onChange: () => () => {},
frame: (id) => calls.frame.push(id),
};
}
function harness(t, kind) {
resetPointerOwnership();
document.body.replaceChildren();
const toggle = document.createElement('button');
toggle.id = 'draw-toggle';
document.body.append(toggle);
// Over (0, 0): the canvas centre is the ellipsoid under the camera.
const handlers = createFakeEventHandlers();
const fixture =
kind === 'fake'
? null
: createCesiumMoversEngine({
engineOptions: { createEventHandler: handlers.create },
});
const engine = fixture?.engine ?? createFakeEngine({ altitude: 20000 });
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(0, 0, 20000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
// Read the contract writes while the real primitives also receive them.
const lines = new Map();
const createLine = engine.lines.createLine;
engine.lines.createLine = (options) => {
const line = createLine(options);
const record = { options, positions: [], shown: true };
lines.set(options.id, record);
const { setPositions, setColor, show, destroy } = line;
line.setColor = (color) => {
record.color = color;
setColor(color);
};
line.setPositions = (points) => {
record.positions = points.flatMap((point) => [point.x, point.y, point.z]);
setPositions(points);
};
line.show = (shown) => {
record.shown = shown;
show(shown);
};
line.destroy = () => {
lines.delete(options.id);
destroy();
};
return line;
};
const input = (type, event) => {
if (!fixture) return engine.fake.input(type, event);
const types = {
click: Cesium.ScreenSpaceEventType.LEFT_CLICK,
move: Cesium.ScreenSpaceEventType.MOUSE_MOVE,
doubleClick: Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
};
for (const handler of handlers.handlers) handler.fire(types[type], event);
};
const annotations = fakeAnnotations();
let actions = null;
const tool = createSessionDrawTool({
annotations,
connect: (next) => {
actions = next;
return () => (actions = null);
},
});
t.after(() => {
tool.destroy();
engine.destroy();
resetPointerOwnership();
});
const attach = () => tool.attachScene({ engine });
attach();
const click = (x, y) => input('click', { position: { x, y } });
const key = (name) =>
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: name, bubbles: true }),
);
return {
engine,
input,
tool,
annotations,
attach,
click,
key,
act: (name, ...args) => actions?.[name]?.(...args),
line: () => lines.get('gev-draw-preview'),
dots: () =>
engine.movers.list().find(({ id }) => id === 'gev-draw-vertices'),
};
}
for (const kind of ['fake', 'cesium']) {
const testCase = (name, run) => test(`${kind}: ${name}`, run);
testCase(
'a click is a vertex, the rubber band follows the pointer and a double click finishes',
async (t) => {
const h = harness(t, kind);
h.act('toggleDraw');
assert.equal(pointerOwner(), 'draw', 'the session holds the pointer');
assert.equal(h.tool.diagnostics().sceneHandler, true);
h.tool.setShape('area');
h.click(500, 380);
h.click(540, 380);
h.click(540, 420);
assert.equal(h.tool.session.vertices.length, 3);
assert.equal(
h.engine.movers.list().find(({ id }) => id === 'gev-draw-vertices')
.count,
3,
);
// The band: three vertices, the pointer and the closing vertex.
h.input('move', {
startPosition: { x: 540, y: 420 },
endPosition: { x: 500, y: 420 },
});
const band = h.line();
assert.equal(band.shown, true);
assert.equal(band.positions.length, 5 * 3);
assert.equal(band.options.dash.lengthPx, 8);
assert.equal(band.options.dash.gapPx, 8);
// The vertex hangs where the click landed (the ellipsoid here).
const first = geo.cartographic.fromCartesian({
x: band.positions[0],
y: band.positions[1],
z: band.positions[2],
});
assert.ok(Math.abs(first.height) < 1);
h.input('doubleClick', { position: { x: 500, y: 420 } });
await Promise.resolve();
assert.equal(h.annotations.calls.annotate.length, 1);
const [spec] = h.annotations.calls.annotate[0].specs;
assert.equal(spec.type, 'area');
assert.equal(spec.manual, true);
assert.ok(spec.ring.length >= 3);
assert.equal(h.tool.session.vertices.length, 0, 'a fresh shape is open');
},
);
testCase(
'Escape cancels the open shape, then leaves Draw and frees the pointer',
(t) => {
const h = harness(t, kind);
h.act('toggleDraw');
h.tool.setShape('line');
h.click(500, 380);
h.click(560, 380);
assert.equal(h.tool.session.vertices.length, 2);
h.key('Escape');
assert.equal(
h.tool.session.vertices.length,
0,
'the sketch is cancelled',
);
assert.equal(h.tool.active, true);
h.key('Escape');
assert.equal(h.tool.active, false);
assert.equal(isPointerFree(), true);
assert.equal(h.tool.diagnostics().sceneHandler, false);
},
);
testCase(
'a switch mid-sketch ends the sketch, takes the preview off the engine and keeps shape and colour',
(t) => {
const h = harness(t, kind);
h.act('toggleDraw');
h.tool.setShape('line');
h.tool.setColor('amber');
h.click(500, 380);
h.click(560, 380);
assert.ok(h.line());
h.tool.detachScene();
assert.equal(h.tool.active, false);
assert.equal(
isPointerFree(),
true,
'the pointer is free after the switch',
);
assert.equal(h.line(), undefined, 'the band left the engine');
assert.equal(h.dots(), undefined);
// A click on the old engine does nothing now.
h.click(520, 400);
assert.equal(h.tool.session, null);
h.attach();
assert.equal(h.tool.shape, 'line');
assert.equal(h.tool.color, 'amber');
h.act('toggleDraw');
h.click(500, 380);
assert.equal(h.tool.session.vertices.length, 1);
},
);
testCase(
'preview styles, pin visibility and clicked heights match the controls',
(t) => {
const h = harness(t, kind);
h.tool.setActive(true);
h.tool.setShape('line');
h.tool.setColor('cyan');
h.tool.addVertex(0, 0, 187.5);
h.tool.addVertex(0.01, 0, 212.25);
const band = h.line();
assert.equal(band.options.arc, 'geodesic');
assert.equal(band.options.widthPx, 3);
assert.equal(band.options.depthFailAlpha, 0.35 / 0.9);
assert.equal(band.color[3], 0.9);
const heights = [0, 3].map(
(i) =>
geo.cartographic.fromCartesian({
x: band.positions[i],
y: band.positions[i + 1],
z: band.positions[i + 2],
}).height,
);
assert.ok(Math.abs(heights[0] - 187.5) < 0.01);
assert.ok(Math.abs(heights[1] - 212.25) < 0.01);
h.tool.setShape('pin');
h.tool.addVertex(0, 0, 150);
assert.equal(h.line().shown, false);
assert.equal(h.dots().count, 1);
h.tool.setActive(false);
assert.equal(h.line().shown, false);
assert.equal(h.dots().count, 0);
},
);
}
+32 -242
View File
@@ -1,5 +1,5 @@
/**
* Manual whiteboard drawing: the Cesium + DOM half.
* Manual whiteboard drawing through the globe engine and DOM.
*
* UTILITIES ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin) and a colour, click the vertices on the real world,
@@ -16,13 +16,8 @@
* so a vertex clicked on top of an aircraft or a camera is a vertex and
* nothing else. It is one claim consulted by all of them, not a list of
* exceptions inside each.
* - Cesium's Viewer binds its OWN left click (select the entity under the
* pointer) and double click (track it) on the viewer's handler, outside every
* layer and therefore outside the shared claim. Both are borrowed for the
* session and given back on the way out, and `destroy()` gives back every
* listener, the Cesium handler, the preview data source and the window
* handle, so the tool can be turned off or the shell disposed without leaving
* anything behind.
* - The engine's exclusive pointer suspends other screen subscriptions and
* stock select/track actions until drawing ends.
*
* A vertex carries the height the click landed on — a roof, a hillside — and
* that height is what the live preview hangs on, so the rubber band follows the
@@ -30,8 +25,7 @@
* drapes finished areas and routes onto the surface, so the height is dropped
* at finish, deliberately and in one place — see `finishSpec` in drawMode.js.
*/
import * as Cesium from 'cesium';
import { pickWorldFromScreen } from './annotationResolver.js';
import { createDrawSurface } from './drawSurface.js';
import {
claimPointer,
pointerOwner,
@@ -50,14 +44,11 @@ import {
removeLastVertex,
} from './drawMode.js';
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
import { DRAW_COLORS, presentDrawMarks } from './drawMarks.js';
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
/** The id this tool claims the pointer under. */
const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
const PREVIEW = DRAW_COLOR_CSS;
/**
* Run the Draw control. Returns a handle with a `destroy()` the application
@@ -67,18 +58,12 @@ const PREVIEW = DRAW_COLOR_CSS;
* through the actions it gives `connect`, which returns their disconnect.
* A row's fly-to is an immediate navigation through `navigation`, like
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
* The globe it draws on is a Cesium `viewer` (with its `engine`, which
* answers the picks) or a draw `surface` factory (`surface({ session,
* cursor })`: the input and preview of a globe without a viewer,
* src/annotations/drawInputNavara.js), which gets the open shape and the
* rubber band's end as live getters.
* @param {{viewer?: Cesium.Viewer, engine?: object, surface?: Function, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
* Input and preview use the attached globe engine.
* @param {{engine: object, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
* @returns {{destroy: Function}|null}
*/
export function initDrawTool({
viewer = null,
engine = null,
surface: makeSurface = null,
annotations,
navigation = null,
publish = () => {},
@@ -88,11 +73,7 @@ export function initDrawTool({
}) {
// A page without the Draw controls (the Display controls view omitted) has
// no draw tool.
if (
(!viewer && !makeSurface) ||
!annotations ||
!document.getElementById('draw-toggle')
)
if (!engine || !annotations || !document.getElementById('draw-toggle'))
return null;
let active = false;
@@ -102,6 +83,7 @@ export function initDrawTool({
let color = DRAW_COLORS.includes(initialColor) ? initialColor : 'primary';
let hint = '';
let lease = null;
let pointer = null;
// The last pointer position on the globe ({ lon, lat, height }), for the
// rubber band.
let cursor = null;
@@ -117,11 +99,8 @@ export function initDrawTool({
domListeners.push([target, type, listener, options]);
};
// The globe's input and preview: Cesium's here, or the surface's.
const live = { session: () => session, cursor: () => cursor };
const surface = makeSurface
? makeSurface(live)
: cesiumDrawSurface(viewer, engine, live);
const surface = createDrawSurface(engine)(live);
const present = () =>
publish({
@@ -297,7 +276,10 @@ export function initDrawTool({
if (destroyed || next === active) return active;
if (next) {
lease = claimPointer(DRAW_POINTER_OWNER);
if (!lease) {
if (lease) pointer = engine.input.acquirePointer();
if (!lease || !pointer) {
releasePointer(lease);
lease = null;
// Somebody else is using the pointer — possibly an older instance of
// this same tool that has not finished tearing down. Say so instead of
// half-starting.
@@ -321,6 +303,8 @@ export function initDrawTool({
session = null;
cursor = null;
releaseSceneHandler();
pointer?.release();
pointer = null;
releasePointer(lease);
lease = null;
syncPreview();
@@ -330,13 +314,16 @@ export function initDrawTool({
function bindSceneHandler() {
if (surface.bound()) return;
surface.bind({
click: onClick,
move: onMove,
doubleClick: () => {
void finish();
surface.bind(
{
click: onClick,
move: onMove,
doubleClick: () => {
void finish();
},
},
});
pointer,
);
listen(document, 'keydown', onKey, true);
}
@@ -443,8 +430,7 @@ export function initDrawTool({
destroyed,
domListeners: domListeners.length,
pointerOwner: pointerOwner(),
// The globe's handler, preview and (Cesium) the viewer's OWN click
// actions: see the surface's diagnostics.
// The globe's input and preview.
...surface.diagnostics({ destroyed }),
};
},
@@ -462,6 +448,8 @@ export function initDrawTool({
offBoard();
disconnect();
releaseSceneHandler();
pointer?.release();
pointer = null;
releasePointer(lease);
lease = null;
for (const [target, type, listener, options] of domListeners.splice(0)) {
@@ -480,192 +468,12 @@ export function initDrawTool({
return api;
}
/**
* The Draw tool's input and preview on a Cesium viewer: a dashed rubber band
* and a dot per vertex in a preview data source, and a ScreenSpaceEventHandler
* for clicks, moves and the double click. Cesium's Viewer binds BOTH stock
* click actions on its own handler (select, track); they are borrowed while a
* sketch is open and given back on the way out.
*/
function cesiumDrawSurface(viewer, engine, live) {
const previewEntities = [];
const dataSource = new Cesium.CustomDataSource(PREVIEW_DATA_SOURCE_NAME);
// `add()` resolves on the NEXT tick, so an init-then-immediate-destroy would
// remove a source that had not been attached yet and leave the attachment to
// land afterwards — a preview data source nobody owns. Chain the removal onto
// the attachment instead of racing it.
let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch(
() => null,
);
let handler = null;
let savedSingleClick = null;
let savedDoubleClick = null;
const toCartesian = (v) =>
Cesium.Cartesian3.fromDegrees(v.lon, v.lat, v.height || 0);
const previewLine = dataSource.entities.add({
show: false,
polyline: {
positions: new Cesium.CallbackProperty(() => {
const session = live.session();
if (!session) return [];
const pts = session.vertices.map(toCartesian);
const cursor = live.cursor();
if (cursor && session.shape !== 'pin') pts.push(toCartesian(cursor));
if (session.shape === 'area' && pts.length >= 3) pts.push(pts[0]);
return pts;
}, false),
width: 3,
material: new Cesium.PolylineDashMaterialProperty({
color: Cesium.Color.fromCssColorString(PREVIEW.primary).withAlpha(0.9),
dashLength: 16,
}),
depthFailMaterial: new Cesium.PolylineDashMaterialProperty({
color: Cesium.Color.fromCssColorString(PREVIEW.primary).withAlpha(0.35),
dashLength: 16,
}),
clampToGround: false,
},
});
return {
canvas: viewer.scene.canvas,
/** The world point under a canvas position (the engine's picks), or null. */
pick(position) {
if (!engine) return null;
const { width, height } = engine.host.size();
return pickWorldFromScreen(
engine,
position.x / (width || 1),
position.y / (height || 1),
);
},
/** Show the open shape (null: none) in `color`. */
sync(session, color) {
previewEntities.forEach((e) => dataSource.entities.remove(e));
previewEntities.length = 0;
if (!session) {
previewLine.show = false;
viewer.scene.requestRender();
return;
}
const stroke = Cesium.Color.fromCssColorString(
PREVIEW[color] || PREVIEW.primary,
);
previewLine.polyline.material = new Cesium.PolylineDashMaterialProperty({
color: stroke.withAlpha(0.9),
dashLength: 16,
});
previewLine.show = session.shape !== 'pin';
for (const v of session.vertices) {
previewEntities.push(
dataSource.entities.add({
position: toCartesian(v),
point: {
pixelSize: 8,
color: stroke,
outlineColor: Cesium.Color.BLACK.withAlpha(0.6),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
}),
);
}
viewer.scene.requestRender();
},
/** The rubber band's end moved (read live by the preview line). */
moved() {
viewer.scene.requestRender();
},
bound: () => Boolean(handler),
bind({ click, move, doubleClick }) {
if (handler) return;
handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction(click, Cesium.ScreenSpaceEventType.LEFT_CLICK);
handler.setInputAction(move, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
handler.setInputAction(
doubleClick,
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
);
// Neither stock action goes through a layer, so neither can be fixed by
// the shared pointer claim — they are borrowed outright for the session.
// Borrowing the single click is what stops a vertex placed on a contact
// from also selecting it.
const stock = viewer.screenSpaceEventHandler;
savedSingleClick =
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
savedDoubleClick =
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null;
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK);
stock.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
},
unbind() {
if (handler) {
handler.destroy();
handler = null;
}
if (savedSingleClick) {
viewer.screenSpaceEventHandler.setInputAction(
savedSingleClick,
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
savedSingleClick = null;
}
if (savedDoubleClick) {
viewer.screenSpaceEventHandler.setInputAction(
savedDoubleClick,
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
);
savedDoubleClick = null;
}
},
diagnostics: ({ destroyed }) => ({
sceneHandler: Boolean(handler),
previewDataSources: countPreviewDataSources(viewer),
previewEntities: destroyed ? 0 : dataSource.entities.values.length,
// The viewer's OWN click actions. While a session is open both are
// borrowed (absent here); after it closes both are back. A harness can
// compare the restored functions by identity with what it captured
// before, which is the only way to prove they were given back rather
// than replaced.
stockSingleClick: Boolean(
viewer.screenSpaceEventHandler?.getInputAction?.(
Cesium.ScreenSpaceEventType.LEFT_CLICK,
),
),
stockDoubleClick: Boolean(
viewer.screenSpaceEventHandler?.getInputAction?.(
Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
),
),
}),
/** Resolves once every deferred teardown step has run. */
settled: () => attaching,
destroy() {
previewEntities.length = 0;
dataSource.entities.removeAll();
// Wait for the pending add() before removing: a destroy() in the same
// tick as init would otherwise remove nothing and let the attachment
// land behind it.
attaching = attaching.then(() => {
try {
viewer.dataSources.remove(dataSource, true);
} catch {
/* viewer already disposed — nothing to detach from */
}
});
return attaching;
},
};
}
/**
* The Draw control for the page session. The marks live on the annotation
* board and the chosen shape and colour here; the drawing tool itself (its
* preview, click handler and Escape/Enter keys) belongs to the attached
* globe. `attachScene({ viewer, engine, navigation })` starts the tool on
* a Cesium viewer, `attachScene({ surface, navigation })` on a globe without one
* (Navara: src/annotations/drawInputNavara.js), with the shape and colour
* last chosen; `detachScene()` ends it, an open shape included. Without a
* globe. `attachScene({ engine, navigation })` starts the tool with the shape
* and colour last chosen; `detachScene()` ends the open shape. Without a
* globe the controls answer nothing. Same handle as initDrawTool's
* (`window.__gevDrawTool`), kept across switches.
*/
@@ -734,20 +542,13 @@ export function createSessionDrawTool({
pointerOwner: pointerOwner(),
},
whenSettled: () => inner?.whenSettled() ?? settled,
attachScene({
viewer = null,
engine = null,
surface = null,
navigation: next = navigation,
} = {}) {
attachScene({ engine = null, navigation: next = navigation } = {}) {
if (destroyed) return;
detachScene();
navigation = next;
if (!viewer && !surface) return;
if (!engine) return;
inner = initDrawTool({
viewer,
engine,
surface,
annotations,
navigation,
publish,
@@ -769,14 +570,3 @@ export function createSessionDrawTool({
window.__gevDrawTool = wrapper;
return wrapper;
}
/** How many draw previews are attached — more than one means a leaked tool. */
function countPreviewDataSources(viewer) {
const sources = viewer?.dataSources;
if (!sources) return 0;
let found = 0;
for (let index = 0; index < sources.length; index += 1) {
if (sources.get(index)?.name === PREVIEW_DATA_SOURCE_NAME) found += 1;
}
return found;
}
+37 -56
View File
@@ -5,6 +5,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs';
import { createFakeEventHandlers } from '../globe/cesium/testing/fakeViewer.mjs';
import { createSessionDrawTool, initDrawTool } from './drawTool.js';
import {
isPointerFree,
@@ -141,45 +143,20 @@ function fakeDom() {
/* ── viewer double ─────────────────────────────────────────────────────── */
function fakeViewer() {
const canvas = new FakeElement('CANVAS');
canvas.clientWidth = 800;
canvas.clientHeight = 600;
function drawEngine() {
const handlers = createFakeEventHandlers();
const fixture = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
},
});
const actions = new Map();
const stockHandler = {
fixture.viewer.screenSpaceEventHandler = {
getInputAction: (type) => actions.get(type),
setInputAction: (action, type) => actions.set(type, action),
removeInputAction: (type) => actions.delete(type),
};
const attached = [];
const pendingAdds = [];
const dataSources = {
get length() {
return attached.length;
},
get: (index) => attached[index],
add(source) {
// Cesium resolves this on a later tick — the whole point of finding 7.
const promise = Promise.resolve().then(() => {
attached.push(source);
return source;
});
pendingAdds.push(promise);
return promise;
},
remove(source) {
const at = attached.indexOf(source);
if (at >= 0) attached.splice(at, 1);
return at >= 0;
},
};
return {
scene: { canvas, requestRender() {} },
screenSpaceEventHandler: stockHandler,
dataSources,
_attached: attached,
_actions: actions,
};
return fixture;
}
function fakeAnnotations() {
@@ -241,7 +218,7 @@ function fakeAnnotations() {
function harness({ navigation = null, session = false } = {}) {
resetPointerOwnership();
const { byId, documentDouble } = fakeDom();
const viewer = fakeViewer();
const { viewer, engine, primitive } = drawEngine();
const annotations = fakeAnnotations();
const previousDocument = globalThis.document;
const previousWindow = globalThis.window;
@@ -261,12 +238,13 @@ function harness({ navigation = null, session = false } = {}) {
};
const tool = session
? createSessionDrawTool(options)
: initDrawTool({ viewer, ...options });
if (session) tool.attachScene({ viewer });
: initDrawTool({ engine, ...options });
if (session) tool.attachScene({ engine });
return {
tool,
shown,
viewer,
engine,
annotations,
byId,
documentDouble,
@@ -275,6 +253,8 @@ function harness({ navigation = null, session = false } = {}) {
/** A control's change, as the view sends it. */
act: (name, ...args) => controls.actions?.[name]?.(...args),
restore() {
tool.destroy();
engine.destroy();
resetPointerOwnership();
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
@@ -447,14 +427,19 @@ test('the live preview hangs at the height the click landed on', async () => {
h.tool.addVertex(-97.74, 30.26, 212.25);
await h.tool.whenSettled();
const source = h.viewer._attached[0] ?? null;
assert.ok(source, 'the preview data source is attached');
const points = source.entities.values.filter((entity) => entity.point);
assert.equal(points.length, 2, 'one preview dot per vertex');
const heights = points.map((entity) => {
const position = entity.position.getValue(Cesium.JulianDate.now());
return Cesium.Cartographic.fromCartesian(position).height;
});
const line = h.engine.lines
.list()
.find(({ id }) => id === 'gev-draw-preview');
assert.equal(
h.engine.movers.list().find(({ id }) => id === 'gev-draw-vertices').count,
2,
);
assert.ok(line, 'the preview is attached');
const primitive = h.viewer.scene.primitives.get(0).get(0).get(0);
const positions = primitive.geometryInstances.geometry._positions;
const heights = positions.map(
(position) => Cesium.Cartographic.fromCartesian(position).height,
);
assert.ok(Math.abs(heights[0] - 187.5) < 0.5, `got ${heights[0]}`);
assert.ok(Math.abs(heights[1] - 212.25) < 0.5, `got ${heights[1]}`);
@@ -476,15 +461,15 @@ test('the live preview hangs at the height the click landed on', async () => {
}
});
test('destroy in the same tick as init still removes the preview data source', async () => {
test('destroy in the same tick as init still removes the preview', async () => {
const h = harness();
try {
// Nothing has been attached yet — dataSources.add() resolves later.
assert.equal(h.viewer._attached.length, 0, 'the add is still pending');
// Preview allocation is lazy.
assert.equal(h.engine.lines.list().length, 0, 'no preview before drawing');
await h.tool.destroy();
await Promise.resolve();
assert.equal(
h.viewer._attached.length,
h.engine.lines.list().length,
0,
'the preview must not be left attached behind the teardown',
);
@@ -519,11 +504,7 @@ test('destroy releases the pointer, the handler, the listeners and the stock act
stockClick,
'the viewer got its selecting click back',
);
assert.equal(
h.viewer._attached.length,
0,
'the preview data source is detached',
);
assert.equal(h.engine.lines.list().length, 0, 'the preview is detached');
assert.equal(
h.annotations.listeners.size,
0,
@@ -723,7 +704,7 @@ test('a page without the Draw controls gets no draw tool', () => {
try {
let connected = false;
const tool = initDrawTool({
viewer: fakeViewer(),
engine: {},
annotations: fakeAnnotations(),
connect: () => {
connected = true;
@@ -756,7 +737,7 @@ test('an engine switch ends an open sketch and keeps the chosen shape and colour
assert.equal(h.controls.actions, null, 'no viewer answers the controls');
assert.equal(globalThis.window.__gevDrawTool, handle);
handle.attachScene({ viewer: fakeViewer() });
handle.attachScene({ engine: h.engine });
assert.equal(handle.attached, true);
assert.equal(handle.shape, 'line');
assert.equal(handle.color, 'amber');
+2 -4
View File
@@ -13,7 +13,6 @@ import {
uninstallRenderGovernor,
} from '../renderGovernor.js';
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js';
import { navaraDrawSurface } from '../annotations/drawInputNavara.js';
/** The Navara globe module, loaded on demand. */
const loadNavaraGlobeModule = async () =>
@@ -153,9 +152,8 @@ export function navaraToolBackend({ engine }) {
drawn
? { engine, renderer: createNavaraAnnotationRenderer(engine, views) }
: null,
// The session's tool on the engine's input and preview
// (src/annotations/drawInputNavara.js).
draw: () => ({ surface: navaraDrawSurface(engine) }),
// The session's tool on the engine's input and preview.
draw: () => ({ engine }),
recentImagery: () => ({ engine }),
// Holds drive engine.render.setContinuous; requests go to the engine. A
// parked engine uninstalls it (createNavaraScene suspend).
+1 -1
View File
@@ -270,7 +270,7 @@ function cesiumToolBackend({ engine, tileset }) {
return {
viewer,
board: () => ({ viewer, engine, tileset }),
draw: () => ({ viewer, engine }),
draw: () => ({ engine }),
// The tileset lets the layer drape while the globe is hidden.
recentImagery(layer) {
layer.attachTileset(tileset);
+46 -5
View File
@@ -10,6 +10,7 @@
// its `name` (the Entity's name, else its id): what the camera verbs say they
// are following.
import { swingFollowOffset } from './followOffset.js';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { createEngineEvent } from '../contract.js';
@@ -200,6 +201,7 @@ export function createCesiumFollow(viewer, { commanded }) {
// Handles for entities the app tracked itself, one per entity.
const wrapped = new WeakMap();
let switching = false;
let stopExternalSwing = null;
const wrap = (entity) => {
let handle = wrapped.get(entity);
@@ -214,16 +216,36 @@ export function createCesiumFollow(viewer, { commanded }) {
stop() {
if (viewer.trackedEntity === entity) viewer.trackedEntity = undefined;
},
retarget: (next) =>
follow({
retarget(next) {
if (viewer.trackedEntity !== entity) return;
const options = normalizeFollowOptions({
native: entity,
position: () =>
displayPosition(entity, viewer.clock.currentTime, undefined),
position: handle.target,
viewFrom: () =>
entity.viewFrom?.getValue?.(viewer.clock.currentTime) ??
entity.viewFrom,
...next,
}),
});
if (!Object.keys(next).every((key) => key === 'swingTo'))
return follow(options);
watchTracking();
stopExternalSwing?.();
if (!options.swingTo) return;
const stop = swingFollowOffset(
viewer,
entity,
options.swingTo,
handle.target,
);
const offCommand = commanded.addEventListener(() =>
stopExternalSwing?.(),
);
stopExternalSwing = () => {
stop();
offCommand();
stopExternalSwing = null;
};
},
};
wrapped.set(entity, handle);
}
@@ -242,6 +264,7 @@ export function createCesiumFollow(viewer, { commanded }) {
let removeTracked = null;
const onTracked = () => {
if (switching) return;
stopExternalSwing?.();
if (own && viewer.trackedEntity !== own.entity) release(own);
followChanged.raiseEvent(following());
};
@@ -257,6 +280,8 @@ export function createCesiumFollow(viewer, { commanded }) {
// Undo what one follow put on the viewer (not trackedEntity itself).
function teardown(state) {
state.stopSwing?.();
state.stopSwing = null;
state.stopFrame?.();
state.stopFrame = null;
state.removeCommanded?.();
@@ -319,6 +344,10 @@ export function createCesiumFollow(viewer, { commanded }) {
applyTrackedCameraFrame(viewer, entity, viewFrom, {
minRange: options.minRange,
}) ?? null;
if (options.swingTo)
state.stopSwing = swingFollowOffset(viewer, entity, options.swingTo, () =>
displayPosition(entity, viewer.clock.currentTime),
);
// A command that takes the camera ends the follow; so does another
// tracked entity (Cesium raises commanded for that too).
state.removeCommanded = commanded.addEventListener(() => {
@@ -332,6 +361,7 @@ export function createCesiumFollow(viewer, { commanded }) {
function follow(input) {
const options = normalizeFollowOptions(input);
stopExternalSwing?.();
watchTracking();
if (own) {
const previous = own;
@@ -360,6 +390,16 @@ export function createCesiumFollow(viewer, { commanded }) {
retarget(next) {
if (own !== state) return;
const updated = normalizeFollowOptions({ ...state.options, ...next });
if (Object.keys(next).every((key) => key === 'swingTo')) {
state.options = updated;
state.stopSwing?.();
state.stopSwing = updated.swingTo
? swingFollowOffset(viewer, state.entity, updated.swingTo, () =>
displayPosition(state.entity, viewer.clock.currentTime),
)
: null;
return;
}
switching = true;
try {
teardown(state);
@@ -379,6 +419,7 @@ export function createCesiumFollow(viewer, { commanded }) {
return {
camera: { follow, following, followChanged },
destroy() {
stopExternalSwing?.();
if (own) {
const state = own;
if (viewer.trackedEntity === state.entity)
+41
View File
@@ -221,3 +221,44 @@ test('destroy ends an engine follow and leaves an app-tracked entity alone', ()
second.destroy();
assert.equal(second.viewer.trackedEntity, appEntity);
});
test('a swing uses the displayed target without sampling the position again', () => {
const { viewer, camera, frame } = setup();
let samples = 0;
const handle = camera.follow({
native: { gevDisplayPosition: () => TARGET },
position: () => {
samples++;
return TARGET;
},
viewFrom: { x: 0, y: -900, z: 400 },
});
frame();
handle.retarget({ swingTo: { x: 0, y: 900, z: 400 } });
const before = samples;
for (let i = 0; i < 8; i++) frame();
assert.equal(samples, before);
assert.ok(Math.abs(viewer.camera.position.y - 900) < 1e-6);
handle.stop();
});
test('an app-tracked entity swings without replacing its follow handle', () => {
const { viewer, camera, frame, destroy } = setup();
const entity = new Cesium.Entity({ position: TARGET });
viewer.trackedEntity = entity;
viewer.camera.lookAtTransform(
Cesium.Transforms.eastNorthUpToFixedFrame(TARGET),
new Cesium.Cartesian3(0, -900, 400),
);
const handle = camera.following();
handle.retarget({ swingTo: { x: 0, y: 900, z: 400 } });
for (let i = 0; i < 8; i++) frame();
assert.equal(camera.following(), handle);
assert.ok(Math.abs(viewer.camera.position.y - 900) < 1e-6);
handle.retarget({ swingTo: { x: 0, y: -900, z: 400 } });
handle.retarget({ swingTo: null });
frame();
frame();
assert.ok(Math.abs(viewer.camera.position.y - 900) < 1e-6);
destroy();
});
+70
View File
@@ -0,0 +1,70 @@
import * as Cesium from 'cesium';
import { MAX_FOLLOW_TURN, turnFollowOffset } from '../follow.js';
const MAX_FRAMES = 120;
/**
* Put the camera at a shared follow offset from `entity` once following it
* has framed the camera. Cesium keeps the offset afterwards, as it keeps one
* the user orbits to. The camera swings round the entity at most 60° a frame:
* the follow's own guard treats a jump to the far side as the camera crossing
* through the entity and puts it back. Returns a disposer.
*/
export function swingFollowOffset(viewer, entity, local, position) {
if (!viewer?.scene?.preUpdate || !entity || !local) return () => {};
let frames = 0;
let remove = null;
const stop = () => {
remove?.();
remove = null;
};
remove = viewer.scene.preUpdate.addEventListener(() => {
if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) {
stop();
return;
}
frames += 1;
const camera = viewer.camera;
// Let the follow frame the camera first (its first frame), then move it.
const framed =
frames > 1 &&
!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY);
const target = framed ? position() : null;
if (!target) {
if (frames >= MAX_FRAMES) stop();
return;
}
// The offset is east/north/up at the entity; the follow's own frame may
// differ, so place the camera in world space and re-express it there.
const world = Cesium.Matrix4.multiplyByPoint(
Cesium.Transforms.eastNorthUpToFixedFrame(target),
new Cesium.Cartesian3(local.x, local.y, local.z),
new Cesium.Cartesian3(),
);
const goal = Cesium.Matrix4.multiplyByPoint(
Cesium.Matrix4.inverseTransformation(
camera.transform,
new Cesium.Matrix4(),
),
world,
new Cesium.Cartesian3(),
);
const from = Cesium.Cartesian3.clone(
camera.position,
new Cesium.Cartesian3(),
);
const angle =
Cesium.Cartesian3.magnitude(from) > 0
? Cesium.Cartesian3.angleBetween(from, goal)
: 0;
const done = !(angle > MAX_FOLLOW_TURN);
const next = done
? goal
: Cesium.Cartesian3.clone(turnFollowOffset(from, goal));
camera.lookAtTransform(camera.transform, next);
viewer.scene.requestRender?.();
if (done) stop();
});
viewer.scene.requestRender?.();
return stop;
}
+19 -2
View File
@@ -1,3 +1,4 @@
import { createExclusivePointer } from '../exclusivePointer.js';
// Screen input on one lazily created ScreenSpaceEventHandler. Cesium keeps
// one action per (type, modifier); the engine fans it out to its listeners.
@@ -53,6 +54,19 @@ export function createCesiumInput(
// setCameraInputs asked for since.
let suspended = 0;
let resumeTo = true;
const pointer = createExclusivePointer(() => {
const stock = viewer.screenSpaceEventHandler;
const saved = [TYPES.click, TYPES.doubleClick].map((type) => {
const action = stock?.getInputAction(type);
stock?.removeInputAction(type);
return [type, action];
});
return () => {
if (stock?.isDestroyed?.()) return;
for (const [type, action] of saved)
if (action) stock?.setInputAction(action, type);
};
});
const delivery = createPickDelivery({
pick: (position) => picker?.pick(position),
drill: (position, limit) => picker?.drillPick(position, limit) ?? [],
@@ -60,6 +74,7 @@ export function createCesiumInput(
});
const input = {
acquirePointer: () => pointer.acquire(),
on(type, listener, options = {}) {
const { modifier, pick, drill } = normalizeInputOptions(type, options);
if (destroyed) return () => {};
@@ -71,15 +86,16 @@ export function createCesiumInput(
groups.set(key, listeners);
handler.setInputAction(
(event) => {
const accepts = pointer.delivery();
delivery.deliver(type, toEvent(event), [...listeners], {
isLive: (entry) => listeners.has(entry),
isLive: (entry) => listeners.has(entry) && accepts(entry),
});
},
TYPES[type],
KEYS[modifier],
);
}
const entry = { listener, pick, drill };
const entry = { listener, pick, drill, pointer: options.pointer };
listeners.add(entry);
return () => {
if (!listeners.delete(entry) || listeners.size) return;
@@ -111,6 +127,7 @@ export function createCesiumInput(
return {
input,
destroy() {
pointer.destroy();
destroyed = true;
groups.clear();
handler?.destroy();
+44
View File
@@ -220,3 +220,47 @@ test('click listeners with pick and drill get the picker answers once per click'
assert.equal(hovers.length, 1);
assert.deepEqual({ ...calls.at(-1)[1] }, { x: 2, y: 0 });
});
test('exclusive pointer suppresses ambient and stock clicks, restores exact actions on destruction', () => {
const { viewer, input, destroy, handlers } = setup();
const actions = new Map();
const click = () => {};
const double = () => {};
actions.set(Type.LEFT_CLICK, click);
actions.set(Type.LEFT_DOUBLE_CLICK, double);
viewer.screenSpaceEventHandler = {
getInputAction: (type) => actions.get(type),
setInputAction: (action, type) => actions.set(type, action),
removeInputAction: (type) => actions.delete(type),
};
let ambient = 0,
owned = 0;
input.on('click', () => ambient++);
const pointer = input.acquirePointer();
assert.equal(input.acquirePointer(), null);
assert.equal(actions.size, 0);
input.on('click', () => owned++, { pointer });
handlers[0].fire(Type.LEFT_CLICK, { position: { x: 1, y: 1 } });
assert.equal(ambient, 0);
assert.equal(owned, 1);
assert.equal(input.cameraInputsEnabled(), true);
pointer.release();
pointer.release();
assert.equal(actions.get(Type.LEFT_CLICK), click);
assert.equal(actions.get(Type.LEFT_DOUBLE_CLICK), double);
handlers[0].fire(Type.LEFT_CLICK, { position: { x: 1, y: 1 } });
assert.equal(ambient, 1);
assert.equal(owned, 1, 'released subscriptions stay inert');
const next = input.acquirePointer();
pointer.release();
assert.equal(
input.acquirePointer(),
null,
'an old claim cannot release a new one',
);
destroy();
next.release();
assert.equal(actions.get(Type.LEFT_CLICK), click);
assert.equal(actions.get(Type.LEFT_DOUBLE_CLICK), double);
assert.equal(input.acquirePointer(), null);
});
+13
View File
@@ -139,6 +139,11 @@
* @property {() => Cartesian3} position Where the target is (read each frame).
* @property {Cartesian3 | (() => Cartesian3)} viewFrom The camera's offset
* from the target in its east-north-up frame, metres.
* @property {Cartesian3 | null} [swingTo] Desired ENU offset after the first
* follow frame. Turns at most 60 degrees per frame, interpolating range,
* then leaves the offset to user gestures. retarget({ swingTo }) keeps the
* current follow; null cancels a pending swing. Stop or camera handoff
* cancels it as well.
* @property {number} [minRange] Closest the camera comes (150 m).
* @property {'enu'} [frame]
* @property {any} [native] What is followed, as the handle's `native`. On
@@ -175,6 +180,7 @@
* under the position, nearest first.
*
* @typedef {object} InputOptions
* @property {{ release: () => void }} [pointer] The live exclusive pointer claim.
* @property {InputModifier} [modifier]
* @property {boolean} [pick] click, doubleClick and move only. A move
* listener with pick gets at most one payload per frame (the latest).
@@ -300,6 +306,12 @@
* @typedef {{ intensity: number, uniforms: Record<string, number> }} StyleState
*
* @typedef {object} GlobeInput
* @property {() => ({ release: () => void } | null)} acquirePointer Claim
* exclusive screen events, or null while another claim is held. Pass the
* claim as InputOptions.pointer; other subscriptions yield. Release is
* idempotent, and engine destruction releases it. Camera gestures remain
* enabled. Cesium's stock select/track clicks are suspended and restored
* by identity. Deferred picks cannot cross a claim or release.
* @property {(type: InputType, listener: (event: InputEvent) => void, options?: InputOptions) => () => void} on
* As in Cesium, a listener with a modifier fires only while that key is
* held and one without fires only while no modifier is held.
@@ -893,6 +905,7 @@ const SHAPE = {
input: {
on: isFunction,
setCameraInputs: isFunction,
acquirePointer: isFunction,
suspendCameraInputs: isFunction,
cameraInputsEnabled: isFunction,
},
+35
View File
@@ -0,0 +1,35 @@
// One pointer owner per engine. A claim gates subscriptions without disabling
// camera gestures. Deferred picks from an older ownership epoch are discarded.
export function createExclusivePointer(suspend = () => () => {}) {
let current = null;
let epoch = 0;
let destroyed = false;
return {
acquire() {
if (destroyed || current) return null;
const resume = suspend();
const claim = {
release() {
if (current !== claim) return;
current = null;
epoch++;
resume();
},
};
current = claim;
epoch++;
return claim;
},
delivery() {
const started = epoch;
return (entry) =>
started === epoch &&
!destroyed &&
(current ? entry.pointer === current : !entry.pointer);
},
destroy() {
current?.release();
destroyed = true;
},
};
}
+63 -1
View File
@@ -40,6 +40,12 @@ export function normalizeFollowOptions(options) {
throw new TypeError(`camera.follow minRange must be >= 0: ${minRange}`);
if (options.frame !== undefined && options.frame !== 'enu')
throw new TypeError("camera.follow frame must be 'enu'");
if (
options.swingTo != null &&
(!isCartesian(options.swingTo) ||
cartesian3.magnitude(options.swingTo) === 0)
)
throw new TypeError('camera.follow swingTo must be a nonzero ENU offset');
const { label } = options;
if (
label !== undefined &&
@@ -56,6 +62,7 @@ export function normalizeFollowOptions(options) {
position: options.position,
viewFrom: viewFromOf,
minRange,
swingTo: options.swingTo ? cartesian3.clone(options.swingTo) : null,
native: options.native,
label,
};
@@ -318,6 +325,16 @@ export function createFollowController({
cartesian3.distance(handle.previous, target) > STILL_M
)
changed = true;
if (handle.swingTo && handle.previous) {
const angle = cartesian3.angleBetween(handle.offset, handle.swingTo);
handle.offset =
angle > MAX_FOLLOW_TURN
? turnFollowOffset(handle.offset, handle.swingTo)
: cartesian3.clone(handle.swingTo);
if (!(angle > MAX_FOLLOW_TURN)) handle.swingTo = null;
changed = true;
}
if (handle.swingTo) changed = true;
const pose = followPose(target, handle.offset, handle.options.minRange);
handle.previous = cartesian3.clone(target, handle.previous ?? undefined);
// The offset in force (the minimum range applied).
@@ -343,6 +360,7 @@ export function createFollowController({
options,
native: options.native,
label: options.label,
swingTo: options.swingTo,
offset: null,
previous: null,
target() {
@@ -354,7 +372,17 @@ export function createFollowController({
},
stop: () => end(handle),
retarget(next) {
const updated = normalizeFollowOptions({ ...options, ...next });
if (current !== handle) return;
const updated = normalizeFollowOptions({
...handle.options,
...next,
});
handle.swingTo = updated.swingTo;
if (Object.keys(next).every((key) => key === 'swingTo')) {
handle.options = updated;
requestRender();
return;
}
handle.options = updated;
handle.native = updated.native;
handle.label = updated.label;
@@ -379,3 +407,37 @@ export function createFollowController({
},
};
}
export const MAX_FOLLOW_TURN = Math.PI / 3;
/**
* `from` turned `step` radians toward `to`, with its length moved the same
* fraction of the way: one frame of the swing round the entity.
*/
export function turnFollowOffset(from, to, step = MAX_FOLLOW_TURN) {
const angle = cartesian3.angleBetween(from, to);
const unitFrom = cartesian3.normalize(from, cartesian3.create());
// The direction perpendicular to `from` in the plane toward `to` (or any
// perpendicular when they are opposite).
let toward = cartesian3.subtract(
cartesian3.normalize(to, cartesian3.create()),
cartesian3.multiplyByScalar(unitFrom, Math.cos(angle), cartesian3.create()),
cartesian3.create(),
);
if (cartesian3.magnitude(toward) < 1e-9)
toward = cartesian3.cross(
unitFrom,
Math.abs(unitFrom.z) < 0.9 ? cartesian3.UNIT_Z : cartesian3.UNIT_X,
cartesian3.create(),
);
cartesian3.normalize(toward, toward);
const length =
cartesian3.magnitude(from) +
(cartesian3.magnitude(to) - cartesian3.magnitude(from)) * (step / angle);
const direction = cartesian3.add(
cartesian3.multiplyByScalar(unitFrom, Math.cos(step), cartesian3.create()),
cartesian3.multiplyByScalar(toward, Math.sin(step), cartesian3.create()),
cartesian3.create(),
);
return cartesian3.multiplyByScalar(direction, length, direction);
}
+13 -2
View File
@@ -1,3 +1,4 @@
import { createExclusivePointer } from '../exclusivePointer.js';
// Screen input for the Navara engine, from DOM pointer events on Navara's
// canvas, with Cesium's ScreenSpaceEventHandler semantics: left button only,
// a click is a down and up within 5 px, positions are CSS px from the
@@ -48,6 +49,7 @@ export function createNavaraInput(
let downAt = null;
let last = null;
const pointer = createExclusivePointer();
const delivery = createPickDelivery({
pick: (position, type) =>
type === 'move' ? picker?.pick(position) : picker?.clickPick(position),
@@ -61,11 +63,12 @@ export function createNavaraInput(
const emit = (type, event, payload) => {
const modifier = modifierOf(event);
const group = listeners.get(type);
const accepts = pointer.delivery();
delivery.deliver(
type,
payload,
[...group].filter((entry) => entry.modifier === modifier),
{ isLive: (entry) => group.has(entry) },
{ isLive: (entry) => group.has(entry) && accepts(entry) },
);
};
@@ -121,6 +124,7 @@ export function createNavaraInput(
});
};
const input = {
acquirePointer: () => pointer.acquire(),
on(type, listener, options = {}) {
const { modifier, pick, drill } = normalizeInputOptions(type, options);
if (destroyed) return () => {};
@@ -129,7 +133,13 @@ export function createNavaraInput(
for (const name of SCREEN_EVENTS)
canvas.addEventListener(name, onScreenEvent);
}
const entry = { listener, modifier, pick, drill };
const entry = {
listener,
modifier,
pick,
drill,
pointer: options.pointer,
};
listeners.get(type).add(entry);
return () => {
listeners.get(type).delete(entry);
@@ -174,6 +184,7 @@ export function createNavaraInput(
cameraInputs: () => cameraInputs,
destroy() {
if (destroyed) return;
pointer.destroy();
destroyed = true;
for (const type of CAMERA_EVENTS)
canvas.removeEventListener(type, onCameraEvent, { capture: true });
+45
View File
@@ -220,3 +220,48 @@ test('clicks with pick wait for the click pick; moves with pick use the CPU pick
assert.deepEqual(seen.at(-1)[1].picked, { id: 'cpu' });
assert.deepEqual(calls.at(-1), ['cpu', { x: 21, y: 21 }]);
});
test('exclusive pointer drops deferred picks across ownership changes and releases on destroy', async () => {
const canvas = fakeCanvas();
const answers = [];
const { input, destroy } = createNavaraInput(
{ canvas, camera: {} },
{
picker: {
clickPick: () => new Promise((resolve) => answers.push(resolve)),
},
},
);
const click = () => {
canvas.dispatchEvent(pointer('pointerdown', 110, 60));
canvas.dispatchEvent(pointer('pointerup', 110, 60));
};
let ambient = 0,
owned = 0;
input.on('click', () => ambient++, { pick: true });
click();
const claim = input.acquirePointer();
input.on('click', () => owned++, { pick: true, pointer: claim });
answers.shift()({ id: 'late' });
await Promise.resolve();
assert.equal(ambient, 0);
click();
answers.shift()({ id: 'owned' });
await Promise.resolve();
assert.equal(owned, 1);
assert.equal(ambient, 0);
click();
claim.release();
answers.shift()({ id: 'released' });
await Promise.resolve();
assert.equal(owned, 1);
assert.equal(ambient, 0);
click();
answers.shift()({ id: 'ambient' });
await Promise.resolve();
assert.equal(ambient, 1);
const next = input.acquirePointer();
destroy();
next.release();
assert.equal(input.acquirePointer(), null);
});
+93
View File
@@ -9,6 +9,7 @@
// Checks request renders themselves, so engines in request-render mode work.
// Each check sets the view it needs; checks may leave the camera anywhere.
import { enuOffset } from '../follow.js';
import * as geo from '../../geo/index.js';
import {
INPUT_TYPES,
@@ -1680,6 +1681,98 @@ export function createContractSuite(
);
},
],
[
'input.acquirePointer: exclusive subscriptions, release and camera gestures',
async () => {
const input = engine.input;
const before = input.cameraInputsEnabled();
const claim = input.acquirePointer();
ensure(claim, 'no pointer claim');
const removers = [];
let ambient = 0,
owned = 0;
try {
ensure(input.acquirePointer() === null, 'a second claim succeeded');
ensure(
input.cameraInputsEnabled() === before,
'claim disabled camera gestures',
);
removers.push(input.on('click', () => ambient++));
removers.push(input.on('click', () => owned++, { pointer: claim }));
if (pointer) {
await pointer.click(100, 100);
await settle();
ensure(owned === 1 && ambient === 0, 'exclusive click leaked');
}
claim.release();
claim.release();
if (pointer) {
await pointer.click(100, 100);
await settle();
ensure(
owned === 1 && ambient === 1,
'released click stayed exclusive',
);
}
const next = input.acquirePointer();
ensure(next, 'release did not free the pointer');
claim.release();
ensure(
input.acquirePointer() === null,
'stale release freed its successor',
);
next.release();
} finally {
claim.release();
for (const remove of removers) remove();
}
},
],
[
'camera.follow: swings to an ENU offset and cancels on handoff',
async () => {
const target = cartesian3.fromDegrees(10, 30, 5000);
const goal = { x: 0, y: 4800, z: 3300 };
const handle = camera.follow({
position: () => target,
viewFrom: { x: 0, y: -4800, z: 3300 },
swingTo: goal,
});
try {
await until(
() =>
cartesian3.distance(
enuOffset(target, camera.pose().position),
goal,
) < 5,
'the shared follow offset',
);
ensure(camera.following() === handle, 'swing replaced the follow');
handle.retarget({ swingTo: { x: 0, y: -4800, z: 3300 } });
handle.retarget({ swingTo: null });
await settle(3);
near(
cartesian3.distance(
enuOffset(target, camera.pose().position),
goal,
),
0,
5,
'cancelled swing moved the camera',
);
camera.setView({
destination: cartesian3.fromDegrees(10, 30, 50000),
});
await settle(3);
ensure(
camera.following() === null,
'camera handoff retained the swing',
);
} finally {
handle.stop();
}
},
],
[
'input: every type subscribes and unsubscribes; camera inputs toggle',
async () => {
+14 -1
View File
@@ -1,3 +1,4 @@
import { createExclusivePointer } from '../exclusivePointer.js';
// A GlobeEngine built on src/geo alone, for tests of shell modules. It keeps a
// controllable perspective camera on the WGS84 ellipsoid, projects and picks
// against it, records every contract call and lets tests emit events, drive
@@ -689,6 +690,7 @@ export function createFakeEngine(options = {}) {
fake.renderRequests++;
},
});
const pointer = createExclusivePointer();
const engine = {
kind: options.kind ?? 'cesium',
host,
@@ -801,6 +803,7 @@ export function createFakeEngine(options = {}) {
tilesLoaded: () => scripted('tilesLoaded', [], () => true),
},
input: {
acquirePointer: () => pointer.acquire(),
on(type, listener, inputOptions = {}) {
record('input.on', [type, inputOptions]);
const { modifier, pick, drill } = normalizeInputOptions(
@@ -808,7 +811,13 @@ export function createFakeEngine(options = {}) {
inputOptions,
);
const listeners = inputs.get(type);
const entry = { listener, modifier, pick, drill };
const entry = {
listener,
modifier,
pick,
drill,
pointer: inputOptions.pointer,
};
listeners.add(entry);
return () => listeners.delete(entry);
},
@@ -884,9 +893,11 @@ export function createFakeEngine(options = {}) {
},
destroy() {
record('destroy', []);
follow.destroy();
cancelCurrentFlight();
moverRegistry.destroyAll();
lineRegistry.destroyAll();
pointer.destroy();
meshRegistry.destroy();
groundRegistry.destroyAll();
imageryRegistry.destroyAll();
@@ -937,7 +948,9 @@ export function createFakeEngine(options = {}) {
input(type, event, modifier) {
const listeners = inputs.get(type);
if (!listeners) throw new TypeError(`Unknown input type: ${type}`);
const accepts = pointer.delivery();
for (const entry of [...listeners]) {
if (!listeners.has(entry) || !accepts(entry)) continue;
if (entry.modifier !== modifier) continue;
if (!entry.pick) {
entry.listener(event);
+12 -17
View File
@@ -241,21 +241,16 @@ test('edge:local-adsb: a selected local aircraft never reaches a share link', as
);
assert.ok(marker, 'the local marker carries its tracked id');
const shareViewer = {
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(20, 9.9, 20_000),
},
clock: { currentTime: Cesium.JulianDate.now() },
trackedEntity: marker,
};
assert.equal(readShareFollowSubject({ viewer: shareViewer }), null);
// Control: the same entity under a shareable layer id would be named.
const control = {
...shareViewer,
trackedEntity: {
gevTrackedId: 'flights:abc123',
gevDisplayPosition: marker.gevDisplayPosition,
},
};
assert.equal(readShareFollowSubject({ viewer: control })?.id, 'abc123');
const engine = createFakeEngine();
const options = { native: marker, position: marker.gevDisplayPosition,
viewFrom: { x: 0, y: -1000, z: 500 } };
engine.camera.follow(options);
engine.fake.frame();
assert.equal(readShareFollowSubject({ engine }), null);
engine.camera.follow({ ...options, native: {
gevTrackedId: 'flights:abc123',
} });
engine.fake.frame();
assert.equal(readShareFollowSubject({ engine })?.id, 'abc123');
engine.destroy();
});
+4 -4
View File
@@ -284,7 +284,7 @@ export class StyleManager extends ShellFacade {
initialShareCode,
});
this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) =>
readShareFollowSubject({ viewer: this.viewer, nowMs }),
readShareFollowSubject({ engine: this._scene?.engine, nowMs }),
);
this.shareLinkManager.setPanelStateProvider(() =>
this._buildSharePanelState(),
@@ -451,7 +451,7 @@ export class StyleManager extends ShellFacade {
const services = this.services;
const layerSession = this._layerSession;
this._shareRestoration = new ShareRestoration({
viewer,
engine,
canvas: engine?.host.canvas ?? viewer?.canvas,
handoffLayers: layerSession?.handoffLayers,
navigation: this._navigation,
@@ -461,8 +461,8 @@ export class StyleManager extends ShellFacade {
this._showGlobalStatusNotice(message, options),
feedback: this._feedback,
updateFeedback: () => this._updateGlobalLoadingFeedback(),
applyFollowOffset: (entity, offset) =>
applyShareFollowOffset(viewer, entity, offset),
applyFollowOffset: (handle, offset) =>
applyShareFollowOffset(engine, handle, offset),
});
this._visualSettings = new VisualSettings({
+27 -158
View File
@@ -1,190 +1,59 @@
import * as Cesium from 'cesium';
import { trackedDisplayPositionForCamera } from '../data/trackedCamera.js';
import * as geo from '../geo/index.js';
import { enuOffset } from '../globe/follow.js';
import {
SHARE_VIEW_ENTITY_KINDS,
followOffsetFromEnu,
followOffsetToEnu,
} from '../share/follow.js';
/**
* The bridge between the camera following an entity and a share link's thing
* view: what the camera follows now, in the share format's terms, and the
* stored follow offset applied to an entity the camera has started to follow.
*/
const KIND_BY_LAYER = new Map(
SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.layerId, entry.kind]),
);
const MAX_FRAMES = 120;
/** `position`'s local east/north/up offset from `origin`, in metres. */
function enuOffset(origin, position) {
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(origin);
const local = Cesium.Matrix4.multiplyByPoint(
Cesium.Matrix4.inverseTransformation(frame, new Cesium.Matrix4()),
position,
new Cesium.Cartesian3(),
/** The followed subject, camera offset and displayed fix in share format. */
export function readShareFollowSubject({ engine, nowMs = Date.now() }) {
const handle = engine?.camera.following();
const trackedId = String(
handle?.label?.id || handle?.native?.gevTrackedId || '',
);
return { east: local.x, north: local.y, up: local.z };
}
/**
* The entity the camera follows as `{ kind, id, offset, lastKnown }`, with
* the camera's offset from it and its displayed position as of `nowMs`; null
* when the camera follows nothing a share link can name. A selected vessel is
* not followed (the camera stays where the user puts it), so a link made then
* keeps its own camera.
*/
export function readShareFollowSubject({ viewer, nowMs = Date.now() }) {
const cameraPosition = viewer?.camera?.positionWC;
if (!cameraPosition) return null;
const tracked = viewer.trackedEntity;
const trackedId = String(tracked?.gevTrackedId || '');
const separator = trackedId.indexOf(':');
if (separator <= 0) return null;
const kind = KIND_BY_LAYER.get(trackedId.slice(0, separator));
const id = trackedId.slice(separator + 1);
const target = kind
? trackedDisplayPositionForCamera(
tracked,
viewer.clock.currentTime,
new Cesium.Cartesian3(),
)
: null;
const target = kind ? (handle.label?.position?.() ?? handle.target()) : null;
if (!kind || !id || !target) return null;
const offset = followOffsetFromEnu(enuOffset(target, cameraPosition));
const fix = Cesium.Cartographic.fromCartesian(target);
const local = enuOffset(target, engine.camera.pose().position);
const offset = followOffsetFromEnu({
east: local.x,
north: local.y,
up: local.z,
});
const fix = geo.cartographic.fromCartesian(target);
if (!offset || !fix) return null;
return {
kind,
id,
offset,
lastKnown: {
lat: Cesium.Math.toDegrees(fix.latitude),
lon: Cesium.Math.toDegrees(fix.longitude),
lat: geo.math.toDegrees(fix.latitude),
lon: geo.math.toDegrees(fix.longitude),
alt: fix.height,
observedAtMs: nowMs,
},
};
}
/** Largest turn, in radians, the camera makes around the entity per frame. */
const MAX_TURN_PER_FRAME = Math.PI / 3;
/**
* Put the camera at a shared follow offset from `entity` once following it
* has framed the camera. Cesium keeps the offset afterwards, as it keeps one
* the user orbits to. The camera swings round the entity at most 60° a frame:
* the follow's own guard treats a jump to the far side as the camera crossing
* through the entity and puts it back. Returns a disposer.
*/
export function applyShareFollowOffset(viewer, entity, offset) {
if (!viewer?.scene?.preUpdate || !entity || !(offset?.range > 0))
/** Swing the current follow to a shared ENU offset; return its cancellation. */
export function applyShareFollowOffset(engine, handle, offset) {
if (!handle || engine?.camera.following() !== handle || !(offset?.range > 0))
return () => {};
const local = followOffsetToEnu(offset);
let frames = 0;
let remove = null;
const stop = () => {
remove?.();
remove = null;
handle.retarget({ swingTo: { x: local.east, y: local.north, z: local.up } });
let stopped = false;
return () => {
if (stopped) return;
stopped = true;
if (engine.camera.following() === handle)
handle.retarget({ swingTo: null });
};
remove = viewer.scene.preUpdate.addEventListener(() => {
if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) {
stop();
return;
}
frames += 1;
const camera = viewer.camera;
// Let the follow frame the camera first (its first frame), then move it.
const framed =
frames > 1 &&
!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY);
const target = framed
? trackedDisplayPositionForCamera(
entity,
viewer.clock.currentTime,
new Cesium.Cartesian3(),
)
: null;
if (!target) {
if (frames >= MAX_FRAMES) stop();
return;
}
// The offset is east/north/up at the entity; the follow's own frame may
// differ, so place the camera in world space and re-express it there.
const world = Cesium.Matrix4.multiplyByPoint(
Cesium.Transforms.eastNorthUpToFixedFrame(target),
new Cesium.Cartesian3(local.east, local.north, local.up),
new Cesium.Cartesian3(),
);
const goal = Cesium.Matrix4.multiplyByPoint(
Cesium.Matrix4.inverseTransformation(
camera.transform,
new Cesium.Matrix4(),
),
world,
new Cesium.Cartesian3(),
);
const from = Cesium.Cartesian3.clone(
camera.position,
new Cesium.Cartesian3(),
);
const angle =
Cesium.Cartesian3.magnitude(from) > 0
? Cesium.Cartesian3.angleBetween(from, goal)
: 0;
const done = !(angle > MAX_TURN_PER_FRAME);
const next = done ? goal : turnToward(from, goal, MAX_TURN_PER_FRAME);
camera.lookAtTransform(camera.transform, next);
viewer.scene.requestRender?.();
if (done) stop();
});
return stop;
}
/**
* `from` turned `step` radians toward `to`, with its length moved the same
* fraction of the way: one frame of the swing round the entity.
*/
function turnToward(from, to, step) {
const angle = Cesium.Cartesian3.angleBetween(from, to);
const unitFrom = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3());
// The direction perpendicular to `from` in the plane toward `to` (or any
// perpendicular when they are opposite).
let toward = Cesium.Cartesian3.subtract(
Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()),
Cesium.Cartesian3.multiplyByScalar(
unitFrom,
Math.cos(angle),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
if (Cesium.Cartesian3.magnitude(toward) < 1e-9)
toward = Cesium.Cartesian3.cross(
unitFrom,
Math.abs(unitFrom.z) < 0.9
? Cesium.Cartesian3.UNIT_Z
: Cesium.Cartesian3.UNIT_X,
new Cesium.Cartesian3(),
);
Cesium.Cartesian3.normalize(toward, toward);
const length =
Cesium.Cartesian3.magnitude(from) +
(Cesium.Cartesian3.magnitude(to) - Cesium.Cartesian3.magnitude(from)) *
(step / angle);
const direction = Cesium.Cartesian3.add(
Cesium.Cartesian3.multiplyByScalar(
unitFrom,
Math.cos(step),
new Cesium.Cartesian3(),
),
Cesium.Cartesian3.multiplyByScalar(
toward,
Math.sin(step),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
return Cesium.Cartesian3.multiplyByScalar(direction, length, direction);
}
+133 -101
View File
@@ -1,3 +1,6 @@
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs';
import { enuOffset } from '../globe/follow.js';
import { viewerEngine } from '../testSupport/viewerEngine.mjs';
// Short share links: `/share/<code>` opens through the start-up restore
// pipeline, the address then follows the live view, and the share button
@@ -127,7 +130,8 @@ function openPage(t, href, { managerOptions = {}, tracking = null } = {}) {
const clock = installManualTimers(t);
const viewer = fakeViewer();
const restores = [];
const manager = new ShareLinkManager(viewerEngine(viewer), {
const engine = viewerEngine(viewer);
const manager = new ShareLinkManager(engine, {
onRestore: async (state) => restores.push(state),
isNavigationCurrent: () => true,
...managerOptions,
@@ -135,7 +139,8 @@ function openPage(t, href, { managerOptions = {}, tracking = null } = {}) {
const notices = [];
let generation = 0;
const owner = new ShareRestoration({
viewer,
engine,
canvas: viewer.canvas,
navigation: {
_beginDeferredNavigation: () => ++generation,
_reassertNavigationHandoff: () => true,
@@ -147,7 +152,7 @@ function openPage(t, href, { managerOptions = {}, tracking = null } = {}) {
feedback: {},
updateFeedback() {},
applyFollowOffset: (entity, offset) =>
applyShareFollowOffset(viewer, entity, offset),
applyShareFollowOffset(engine, entity, offset),
});
// Notices are checked by message; their start-up-cover timing is not.
owner._presentAfterStartupCover = (message) => notices.push(message);
@@ -161,7 +166,16 @@ function openPage(t, href, { managerOptions = {}, tracking = null } = {}) {
};
owner.start();
t.after(() => owner.destroy());
return { ...page, ...clock, viewer, manager, owner, restores, notices };
return {
...page,
...clock,
viewer,
engine,
manager,
owner,
restores,
notices,
};
}
/** Run the delayed start-up restore and let it settle. */
@@ -246,7 +260,11 @@ test('a thing view turns its layer on, follows by id and frames the shared offse
const page = openPage(t, `http://localhost/share/${aircraftCode()}`, {
tracking: (owner) => {
// What the coordinator does when the aircraft is in the feed.
owner.viewer.trackedEntity = entity;
owner.engine.camera.follow({
native: entity,
position: entity.gevDisplayPosition,
viewFrom: { x: 0, y: -4800, z: 3300 },
});
owner._handleShareTrackingRestoreStatus({
layerId: 'flights',
targetId: 'a1b2c3',
@@ -256,29 +274,6 @@ test('a thing view turns its layer on, follows by id and frames the shared offse
},
});
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
const listeners = [];
const looks = [];
Object.assign(page.viewer, {
clock: { currentTime: Cesium.JulianDate.now() },
scene: {
preUpdate: {
addEventListener(fn) {
listeners.push(fn);
return () => listeners.splice(listeners.indexOf(fn), 1);
},
},
},
isDestroyed: () => false,
});
Object.assign(page.viewer.camera, {
transform: Cesium.Transforms.eastNorthUpToFixedFrame(target),
// The app's own follow framing: south of the aircraft and above it.
position: new Cesium.Cartesian3(0, -4800, 3300),
lookAtTransform(transform, offset) {
looks.push(offset);
this.position = Cesium.Cartesian3.clone(offset);
},
});
entity = {
gevTrackedId: 'flights:a1b2c3',
gevDisplayPosition: () => target,
@@ -304,16 +299,12 @@ test('a thing view turns its layer on, follows by id and frames the shared offse
);
// Once followed, the camera takes the shared offset after the follow's
// own first frame.
assert.equal(listeners.length, 1);
listeners[0]();
assert.equal(looks.length, 0);
listeners[0]?.();
assert.equal(looks.length, 1);
for (let i = 0; i < 10; i++) page.engine.fake.frame();
const local = enuOffset(target, page.engine.camera.pose().position);
const expected = followOffsetToEnu(OFFSET);
assert.ok(Math.abs(looks[0].x - expected.east) < 1);
assert.ok(Math.abs(looks[0].y - expected.north) < 1);
assert.ok(Math.abs(looks[0].z - expected.up) < 1);
assert.equal(listeners.length, 0, 'the offset is applied once');
assert.ok(Math.abs(local.x - expected.east) < 1);
assert.ok(Math.abs(local.y - expected.north) < 1);
assert.ok(Math.abs(local.z - expected.up) < 1);
assert.deepEqual(page.notices, []);
});
@@ -477,7 +468,17 @@ test('the followed entity is read in the share format, and its offset round-trip
gevDisplayPosition: () => target,
},
};
const subject = readShareFollowSubject({ viewer, nowMs: NOW_MS });
const engine = {
camera: {
pose: () => ({ position: cameraPosition }),
following: () =>
viewer.trackedEntity && {
native: viewer.trackedEntity,
target: () => target,
},
},
};
const subject = readShareFollowSubject({ engine, nowMs: NOW_MS });
assert.equal(subject.kind, 'military_aircraft');
assert.equal(subject.id, 'ae1234');
assert.deepEqual(subject.offset, offset);
@@ -485,74 +486,80 @@ test('the followed entity is read in the share format, and its offset round-trip
assert.ok(Math.abs(subject.lastKnown.alt - FIX.alt) < 1e-3);
// A followed entity the share format has no kind for is not a thing view.
viewer.trackedEntity.gevTrackedId = 'local-adsb:abc';
assert.equal(readShareFollowSubject({ viewer }), null);
assert.equal(readShareFollowSubject({ engine }), null);
// Nor is a camera that follows nothing (a selected vessel is not followed).
viewer.trackedEntity = undefined;
assert.equal(readShareFollowSubject({ viewer }), null);
assert.equal(readShareFollowSubject({ engine }), null);
});
test('a shared offset on the far side swings round, never jumping through the entity', () => {
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
const listeners = [];
const looks = [];
const entity = { gevDisplayPosition: () => target };
const viewer = {
clock: { currentTime: Cesium.JulianDate.now() },
scene: {
preUpdate: {
addEventListener(fn) {
listeners.push(fn);
return () => listeners.splice(listeners.indexOf(fn), 1);
},
},
},
trackedEntity: entity,
camera: {
transform: Cesium.Transforms.eastNorthUpToFixedFrame(target),
position: new Cesium.Cartesian3(0, -4800, 3300),
lookAtTransform(transform, offset) {
looks.push(Cesium.Cartesian3.clone(offset));
this.position = Cesium.Cartesian3.clone(offset);
},
},
};
// Looking south at the nose: the opposite side of the app's framing.
const offset = { range: 5800, heading: 180, pitch: -35 };
applyShareFollowOffset(viewer, entity, offset);
let previous = viewer.camera.position;
for (let frame = 0; frame < 10 && listeners.length; frame += 1) {
listeners[0]();
const now = viewer.camera.position;
// The follow's guard treats a turn of 90° or more as a crossing.
assert.ok(Cesium.Cartesian3.angleBetween(previous, now) < Math.PI / 2);
previous = now;
}
assert.equal(listeners.length, 0, 'it arrives');
assert.ok(looks.length >= 2, `${looks.length} steps`);
const expected = followOffsetToEnu(offset);
const last = looks.at(-1);
assert.ok(Math.abs(last.x - expected.east) < 1);
assert.ok(Math.abs(last.y - expected.north) < 1);
assert.ok(Math.abs(last.z - expected.up) < 1);
});
for (const kind of ['fake', 'cesium']) {
test(`${kind}: a shared offset swings round without crossing the target`, () => {
const fixture = kind === 'cesium' ? createCesiumMoversEngine() : null;
const engine = fixture?.engine ?? createFakeEngine();
const frame = fixture?.frame ?? (() => engine.fake.frame());
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
const handle = engine.camera.follow({
position: () => target,
viewFrom: { x: 0, y: -4800, z: 3300 },
label: { id: 'flights:abc123', position: () => target },
});
frame();
const offset = { range: 5800, heading: 180, pitch: -35 };
const stop = applyShareFollowOffset(engine, handle, offset);
let previous = enuOffset(target, engine.camera.pose().position);
let turns = 0;
for (let i = 0; i < 10; i++) {
frame();
const now = enuOffset(target, engine.camera.pose().position);
const angle = Cesium.Cartesian3.angleBetween(previous, now);
assert.ok(angle <= Math.PI / 3 + 1e-8);
if (angle > 1e-8) turns++;
previous = now;
}
assert.ok(turns >= 2);
const expected = followOffsetToEnu(offset);
assert.ok(Math.abs(previous.x - expected.east) < 1);
assert.ok(Math.abs(previous.y - expected.north) < 1);
assert.ok(Math.abs(previous.z - expected.up) < 1);
assert.deepEqual(readShareFollowSubject({ engine }).offset, offset);
stop();
stop();
handle.stop();
assert.equal(readShareFollowSubject({ engine }), null);
engine.destroy();
});
test('applying a follow offset gives up when the follow ends', () => {
const listeners = [];
const viewer = {
scene: {
preUpdate: {
addEventListener(fn) {
listeners.push(fn);
return () => listeners.splice(listeners.indexOf(fn), 1);
},
},
},
trackedEntity: null,
};
applyShareFollowOffset(viewer, {}, OFFSET);
listeners[0]();
assert.equal(listeners.length, 0);
});
test(`${kind}: cancelling a swing or ending its follow releases the camera`, () => {
const fixture = kind === 'cesium' ? createCesiumMoversEngine() : null;
const engine = fixture?.engine ?? createFakeEngine();
const frame = fixture?.frame ?? (() => engine.fake.frame());
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
const handle = engine.camera.follow({
position: () => target,
viewFrom: { x: 0, y: -4800, z: 3300 },
});
frame();
const stop = applyShareFollowOffset(engine, handle, OFFSET);
stop();
const before = engine.camera.pose().position;
frame();
frame();
assert.ok(
Cesium.Cartesian3.distance(before, engine.camera.pose().position) < 1e-6,
);
applyShareFollowOffset(engine, handle, OFFSET);
handle.stop();
engine.camera.setView({ destination: target });
const released = engine.camera.pose().position;
frame();
frame();
assert.ok(
Cesium.Cartesian3.distance(released, engine.camera.pose().position) <
1e-6,
);
engine.destroy();
});
}
test('long links and hash navigation are unaffected by a share code start', async (t) => {
// A long link still opens from the hash.
@@ -572,3 +579,28 @@ test('long links and hash navigation are unaffected by a share code start', asyn
assert.equal(page.restores.length, 2);
assert.equal(page.restores[1].style, 'normal');
});
test('a share reads the displayed label fix without advancing follow interpolation', () => {
const engine = createFakeEngine();
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
const local = followOffsetToEnu(OFFSET);
engine.camera.setView({
destination: Cesium.Matrix4.multiplyByPoint(
Cesium.Transforms.eastNorthUpToFixedFrame(target),
new Cesium.Cartesian3(local.east, local.north, local.up),
new Cesium.Cartesian3(),
),
});
engine.camera.follow({
position: () => {
throw new Error('must use the display cache');
},
viewFrom: { x: 0, y: -900, z: 400 },
label: { id: 'flights:cached', position: () => target },
});
const subject = readShareFollowSubject({ engine });
assert.equal(subject.id, 'cached');
assert.deepEqual(subject.offset, OFFSET);
assert.ok(Math.abs(subject.lastKnown.alt - FIX.alt) < 0.01);
engine.destroy();
});
+7 -7
View File
@@ -108,8 +108,8 @@ export function sharedFixNotice(entity, observedAtMs, nowMs = Date.now()) {
/** Own initial share restoration, durable layer state and restoration notices. */
export class ShareRestoration {
constructor({
viewer,
canvas = viewer?.canvas,
engine,
canvas = engine?.host.canvas,
handoffLayers = null,
navigation,
syncShareState,
@@ -120,7 +120,7 @@ export class ShareRestoration {
applyFollowOffset = null,
}) {
Object.assign(this, {
viewer,
engine,
canvas,
handoffLayers,
navigation,
@@ -129,7 +129,7 @@ export class ShareRestoration {
showStatus,
feedback,
updateFeedback,
// (entity, offset) => disposer: frame a followed entity from a shared
// (handle, offset) => disposer: frame a followed entity from a shared
// follow offset (shareFollow.js).
applyFollowOffset,
});
@@ -477,17 +477,17 @@ export class ShareRestoration {
/** Frame a shared entity the camera now follows from the shared offset. */
_applySharedFollowOffset(result) {
const view = this._sharedThingFor(result);
const entity = this.viewer?.trackedEntity;
const handle = this.engine?.camera.following();
// Only a link made while following carries a real offset (with the fix);
// a long link's follow keeps the app's own framing.
if (
!view?.lastKnown ||
String(entity?.gevTrackedId || '') !==
String(handle?.label?.id || handle?.native?.gevTrackedId || '') !==
`${result.layerId}:${result.targetId}`
)
return;
this._stopFollowOffset?.();
this._stopFollowOffset = this.applyFollowOffset?.(entity, view.offset);
this._stopFollowOffset = this.applyFollowOffset?.(handle, view.offset);
}
cancelSelection() {
+1 -1
View File
@@ -69,7 +69,7 @@ test('share teardown settles its promise, removes gestures and rejects a retaine
let stamps = 0;
let applies = 0;
const owner = new ShareRestoration({
viewer: { canvas },
engine: { host: { canvas } },
navigation: {
_beginDeferredNavigation: () => 1,
_reassertNavigationHandoff: () => true,