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,