From dacfd33f1080c8a76398cb63d34914682f9386f9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 7 Nov 2026 14:31:49 +0400 Subject: [PATCH] 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. --- docs/CURRENT-STATE.md | 7 + docs/GLOBE-PRIMITIVES.md | 24 ++ scripts/engine-coupling-baseline.json | 8 +- scripts/format-scope.json | 7 +- scripts/package-boundaries.json | 13 +- scripts/qa-draw-tool.mjs | 15 +- src/annotations/drawInputNavara.test.mjs | 150 ---------- .../{drawInputNavara.js => drawSurface.js} | 23 +- src/annotations/drawSurface.test.mjs | 255 ++++++++++++++++ src/annotations/drawTool.js | 274 ++---------------- src/annotations/drawToolBehaviour.test.mjs | 93 +++--- src/app/navaraMode.js | 6 +- src/app/tools.js | 2 +- src/globe/cesium/follow.js | 51 +++- src/globe/cesium/follow.test.mjs | 41 +++ src/globe/cesium/followOffset.js | 70 +++++ src/globe/cesium/input.js | 21 +- src/globe/cesium/input.test.mjs | 44 +++ src/globe/contract.js | 13 + src/globe/exclusivePointer.js | 35 +++ src/globe/follow.js | 64 +++- src/globe/navara/input.js | 15 +- src/globe/navara/input.test.mjs | 45 +++ src/globe/testing/contractSuite.mjs | 93 ++++++ src/globe/testing/fakeEngine.js | 15 +- src/layers/localAdsb/edgeCases.test.mjs | 29 +- src/ui/applicationShell.js | 8 +- src/ui/shareFollow.js | 185 ++---------- src/ui/shareLinks.test.mjs | 234 ++++++++------- src/ui/shareRestoration.js | 14 +- src/ui/stateOwners.test.mjs | 2 +- 31 files changed, 1071 insertions(+), 785 deletions(-) delete mode 100644 src/annotations/drawInputNavara.test.mjs rename src/annotations/{drawInputNavara.js => drawSurface.js} (83%) create mode 100644 src/annotations/drawSurface.test.mjs create mode 100644 src/globe/cesium/followOffset.js create mode 100644 src/globe/exclusivePointer.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 45a35f202..c86e616ba 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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`. diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index 8c8556794..23fceec10 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -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. diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index 5a824c8b5..a69692bd0 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -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" } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ac39036ee..7ff3781fc 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9f4c1fc63..c68263e49 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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"] }, diff --git a/scripts/qa-draw-tool.mjs b/scripts/qa-draw-tool.mjs index 924f86f1f..135ca035a 100644 --- a/scripts/qa-draw-tool.mjs +++ b/scripts/qa-draw-tool.mjs @@ -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}`, ); diff --git a/src/annotations/drawInputNavara.test.mjs b/src/annotations/drawInputNavara.test.mjs deleted file mode 100644 index dc8c9b7e5..000000000 --- a/src/annotations/drawInputNavara.test.mjs +++ /dev/null @@ -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); -}); diff --git a/src/annotations/drawInputNavara.js b/src/annotations/drawSurface.js similarity index 83% rename from src/annotations/drawInputNavara.js rename to src/annotations/drawSurface.js index 46242c826..f037239ac 100644 --- a/src/annotations/drawInputNavara.js +++ b/src/annotations/drawSurface.js @@ -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() { diff --git a/src/annotations/drawSurface.test.mjs b/src/annotations/drawSurface.test.mjs new file mode 100644 index 000000000..0ccdb9f03 --- /dev/null +++ b/src/annotations/drawSurface.test.mjs @@ -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); + }, + ); +} diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index ea8caf3ba..f686628d2 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -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; -} diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index 12526c8c5..775bbc566 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -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'); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 169676565..1a81fa8ab 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -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). diff --git a/src/app/tools.js b/src/app/tools.js index c94f0b16b..f74ac48e3 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -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); diff --git a/src/globe/cesium/follow.js b/src/globe/cesium/follow.js index 30fcc80e1..53a5a058c 100644 --- a/src/globe/cesium/follow.js +++ b/src/globe/cesium/follow.js @@ -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) diff --git a/src/globe/cesium/follow.test.mjs b/src/globe/cesium/follow.test.mjs index 80d331713..e6069fb11 100644 --- a/src/globe/cesium/follow.test.mjs +++ b/src/globe/cesium/follow.test.mjs @@ -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(); +}); diff --git a/src/globe/cesium/followOffset.js b/src/globe/cesium/followOffset.js new file mode 100644 index 000000000..f95e31b74 --- /dev/null +++ b/src/globe/cesium/followOffset.js @@ -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; +} diff --git a/src/globe/cesium/input.js b/src/globe/cesium/input.js index 67ee84300..d7f37607f 100644 --- a/src/globe/cesium/input.js +++ b/src/globe/cesium/input.js @@ -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(); diff --git a/src/globe/cesium/input.test.mjs b/src/globe/cesium/input.test.mjs index 70f71ed9b..1ebc5b42d 100644 --- a/src/globe/cesium/input.test.mjs +++ b/src/globe/cesium/input.test.mjs @@ -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); +}); diff --git a/src/globe/contract.js b/src/globe/contract.js index 73ee45296..f4230154f 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -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 }} 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, }, diff --git a/src/globe/exclusivePointer.js b/src/globe/exclusivePointer.js new file mode 100644 index 000000000..1314b67c5 --- /dev/null +++ b/src/globe/exclusivePointer.js @@ -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; + }, + }; +} diff --git a/src/globe/follow.js b/src/globe/follow.js index c8800ee3c..c16d71cab 100644 --- a/src/globe/follow.js +++ b/src/globe/follow.js @@ -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); +} diff --git a/src/globe/navara/input.js b/src/globe/navara/input.js index bb0dcab0c..a076e9492 100644 --- a/src/globe/navara/input.js +++ b/src/globe/navara/input.js @@ -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 }); diff --git a/src/globe/navara/input.test.mjs b/src/globe/navara/input.test.mjs index 325504d08..ff178c1ce 100644 --- a/src/globe/navara/input.test.mjs +++ b/src/globe/navara/input.test.mjs @@ -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); +}); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 3bbe2ace8..e9569ac85 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -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 () => { diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index f5c391512..5fe861d20 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -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); diff --git a/src/layers/localAdsb/edgeCases.test.mjs b/src/layers/localAdsb/edgeCases.test.mjs index f81c4f73c..681489e92 100644 --- a/src/layers/localAdsb/edgeCases.test.mjs +++ b/src/layers/localAdsb/edgeCases.test.mjs @@ -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(); }); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 65a58ac31..ee847950b 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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({ diff --git a/src/ui/shareFollow.js b/src/ui/shareFollow.js index ba5851da6..c1c5f1c1d 100644 --- a/src/ui/shareFollow.js +++ b/src/ui/shareFollow.js @@ -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); } diff --git a/src/ui/shareLinks.test.mjs b/src/ui/shareLinks.test.mjs index b992e7a9c..1d27393f5 100644 --- a/src/ui/shareLinks.test.mjs +++ b/src/ui/shareLinks.test.mjs @@ -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/` 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(); +}); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 4b92517bc..4f7436e51 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -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() { diff --git a/src/ui/stateOwners.test.mjs b/src/ui/stateOwners.test.mjs index 52a56b386..d659c8a71 100644 --- a/src/ui/stateOwners.test.mjs +++ b/src/ui/stateOwners.test.mjs @@ -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,