diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index ad3e76a11..f1b3cdbdd 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -2,15 +2,15 @@ "metrics": { "cesiumLayerRendererLines": 330, "navaraLayerRendererLines": 2304, - "cesiumImportFiles": 31, - "viewerLines": 1367, - "navaraKindChecks": 6, - "engineSniffCalls": 14, + "cesiumImportFiles": 27, + "viewerLines": 1234, + "navaraKindChecks": 5, + "engineSniffCalls": 0, "engineExtensionReads": 12, "groundEffectPasses": 3, "cesiumSdkLoads": 4, "screenSpaceHandlerFiles": 4, - "engineNameLines": 78 + "engineNameLines": 73 }, - "measuredAt": "after engine.imagery with FIRMS heat and Recent Imagery on it, 2026-11-07" + "measuredAt": "after the phase 7 feature moves, 2026-11-07" } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7702ac078..82ede59e8 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1401,7 +1401,6 @@ "scripts/fixtures/w2/radio.mjs", "scripts/qa-map-policy.mjs", "scripts/qa-map-policy.test.mjs", - "src/ui/imageryBoxNavara.test.mjs", "scripts/qa-w2-imagery.mjs", "src/globe/navara/postSamplingPass.test.mjs", "src/globe/navara/emptySceneRender.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 2871a86db..d04143ac4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3415,7 +3415,6 @@ "src/layers/vessels/icons.js", "src/maps/sceneApply.js", "src/layers/localAdsb/qaDrawn.js", - "src/ui/imageryBoxNavara.js", "src/geo/", "src/time/clock.js", "src/globe/navara/overlay.js", diff --git a/src/annotations/adminOutline.test.mjs b/src/annotations/adminOutline.test.mjs index 2028fe711..b0f4ece87 100644 --- a/src/annotations/adminOutline.test.mjs +++ b/src/annotations/adminOutline.test.mjs @@ -6,9 +6,10 @@ import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js'; import { createAnnotationResolver } from './resolver.js'; import { createAnnotationEngine } from './annotationEngine.js'; import { _resetRenderGovernorForTest } from '../renderGovernor.js'; +import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs'; function viewerAt(lat, lon, height = 20_000) { - return { + return cameraOnlyEngine({ camera: { positionCartographic: { latitude: (lat * Math.PI) / 180, @@ -16,7 +17,7 @@ function viewerAt(lat, lon, height = 20_000) { height, }, }, - }; + }); } /** A resolver whose boundary lookups and geocoder record every call. */ @@ -45,7 +46,7 @@ function harness(place = null) { const resolve = (target, viewer, extra = {}) => resolveAnnotationTarget({ placeSearch, - viewer, + engine: viewer, target, footprint: true, deferFootprint: true, @@ -287,7 +288,7 @@ test('the engine keeps every part on the mark, drawn at once or upgraded', async destroy() {}, }; const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer, resolveTarget: async ({ target }) => target === 'now' diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 3a33a4feb..9c7201f3c 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1,8 +1,6 @@ import { isUnavailableCapability } from '../sources/capability.js'; import { defaultGeospatial } from '../search/defaults.js'; -import * as Cesium from 'cesium'; import * as geo from '../geo/index.js'; -import { isGlobeEngine } from '../globe/contract.js'; import { greatCircleMeters } from '../geoDistance.js'; import { holdContinuousRender, @@ -22,16 +20,6 @@ import { } from '../sources/phaseTiming.js'; import { formatDistanceText } from '../data/distanceFormat.js'; -// Dev convenience: expose the app's Cesium instance for console/preview probing -// (single shared module instance — avoids dual-Cesium state bugs when testing). -// A getter, because the production build's Cesium global loads only with the -// first Cesium globe. -if (typeof window !== 'undefined' && !('__CESIUM__' in window)) - Object.defineProperty(window, '__CESIUM__', { - configurable: true, - get: () => Cesium, - }); - /** * Annotation engine — the voice agent's "whiteboard" over the 3D world. * @@ -148,7 +136,7 @@ const DETACHED_RENDERER = Object.freeze({ export function createAnnotationEngine({ placeSearch = defaultGeospatial, - viewer = null, + globe = null, renderer = DETACHED_RENDERER, outlineRetryDelaysMs = OUTLINE_RETRY_DELAYS_MS, resolveTarget = resolveAnnotationTarget, @@ -428,7 +416,7 @@ export function createAnnotationEngine({ * @returns {Promise<{ok, drawn, failed, ids, results}>} */ async function annotate(requests, opts = {}) { - if (destroyed || !viewer) + if (destroyed || !globe) return { ok: false, drawn: 0, @@ -437,7 +425,7 @@ export function createAnnotationEngine({ results: [], error: destroyed ? 'destroyed' - : 'Annotations are drawn on the Cesium globe only.', + : 'Annotations are drawn on a globe only.', }; const list = (Array.isArray(requests) ? requests : [requests]).map( promoteStreetHighlight, @@ -914,7 +902,7 @@ export function createAnnotationEngine({ const type = normalizeType(spec?.type); // MANUAL geometry (drawTool.js): the person clicked the vertices, so there is // nothing to resolve. Same record shapes as a resolved spec, source 'manual'. - if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer); + if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, globe); if (type === 'route') { const points = Array.isArray(spec.points) ? spec.points : []; if (points.length < 2) @@ -925,7 +913,7 @@ export function createAnnotationEngine({ const name = pt.target ?? pt.name ?? null; const r = await resolveTarget({ placeSearch, - viewer, + engine: globe, target: name, latitude: pt.latitude, longitude: pt.longitude, @@ -983,7 +971,7 @@ export function createAnnotationEngine({ if (type === 'arrow') { const from = await resolveTarget({ placeSearch, - viewer, + engine: globe, target: spec.target, latitude: spec.latitude, longitude: spec.longitude, @@ -995,7 +983,7 @@ export function createAnnotationEngine({ }); const to = await resolveTarget({ placeSearch, - viewer, + engine: globe, target: spec.toTarget, latitude: spec.toLatitude, longitude: spec.toLongitude, @@ -1028,7 +1016,7 @@ export function createAnnotationEngine({ type === 'area' ? spec.footprint !== false : Boolean(spec.footprint); return resolveTarget({ placeSearch, - viewer, + engine: globe, target: spec.target, latitude: spec.latitude, longitude: spec.longitude, @@ -1669,7 +1657,7 @@ export function createAnnotationEngine({ /** Fly the camera to one mark; a line is framed whole, not by its start. */ function frame(id) { const anno = annotations.get(id); - if (!anno || !viewer) return false; + if (!anno || !globe) return false; if (anno.type === 'route' && anno.path?.length) { const ring = anno.path.map((p) => [p.lon, p.lat]); // Unwrap around the first vertex, as ringRange does, so a line across @@ -1739,7 +1727,7 @@ export function createAnnotationEngine({ const cart = points.map((p) => geo.cartesian3.fromDegrees(p.lon, p.lat, p.height || 0), ); - const sphere = Cesium.BoundingSphere.fromPoints(cart); + const sphere = geo.boundingSphere.fromPoints(cart); flyToSphere(sphere.center, sphere.radius, { pitch: geo.math.toRadians(-35), range: Math.max(900, sphere.radius * 2.6), @@ -1755,23 +1743,7 @@ export function createAnnotationEngine({ function isPointOnScreen(p) { try { const pos = geo.cartesian3.fromDegrees(p.lon, p.lat, p.height || 0); - if (isGlobeEngine(viewer)) return engineSeesPoint(viewer, pos); - const camera = viewer.camera; - const cv = camera.frustum.computeCullingVolume( - camera.position, - camera.direction, - camera.up, - ); - if ( - cv.computeVisibility(new Cesium.BoundingSphere(pos, 1)) === - Cesium.Intersect.OUTSIDE - ) - return false; - const occluder = new Cesium.EllipsoidalOccluder( - Cesium.Ellipsoid.WGS84, - camera.position, - ); - return occluder.isPointVisible(pos); + return engineSeesPoint(globe, pos); } catch { return true; } @@ -1810,28 +1782,13 @@ export function createAnnotationEngine({ } /** - * Fly to a sphere keeping the camera's heading: Cesium's - * flyToBoundingSphere on a viewer, the contract's flyToSphere on a globe - * engine (the same offset semantics). + * Fly to a sphere keeping the camera's heading (the contract's + * flyToSphere: Cesium's flyToBoundingSphere offset). */ function flyToSphere(center, radius, { pitch, range, duration }) { - if (isGlobeEngine(viewer)) { - viewer.camera.flyToSphere( - { center, radius }, - { heading: viewer.camera.pose().heading, pitch, range, duration }, - ); - return; - } - viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(center, radius), - { - offset: new Cesium.HeadingPitchRange( - viewer.camera.heading, - pitch, - range, - ), - duration, - }, + globe.camera.flyToSphere( + { center, radius }, + { heading: globe.camera.pose().heading, pitch, range, duration }, ); } @@ -1861,7 +1818,7 @@ export function createAnnotationEngine({ generation++; const previous = renderer; renderer = DETACHED_RENDERER; - viewer = null; + globe = null; for (const anno of annotations.values()) { anno.pendingOutline = false; try { @@ -1877,13 +1834,13 @@ export function createAnnotationEngine({ } }, /** - * Draw the board on a globe: `viewer` and the `renderer` built for it. - * Marks kept while no globe drew them appear again as they were. + * Draw the board on a globe: its `engine` and the `renderer` built for + * it. Marks kept while no globe drew them appear again as they were. */ - attachRenderer({ viewer: nextViewer, renderer: nextRenderer }) { + attachRenderer({ engine: nextGlobe = null, renderer: nextRenderer }) { if (destroyed) return; engine.detachRenderer(); - viewer = nextViewer; + globe = nextGlobe; renderer = nextRenderer ?? DETACHED_RENDERER; for (const anno of annotations.values()) { try { @@ -2093,7 +2050,7 @@ export function createAnnotationEngine({ }) { if (destroyed) return; try { - viewer?.camera.flyTo({ + globe?.camera.flyTo({ destination: geo.cartesian3.fromDegrees(lon, lat, height), orientation: { heading: geo.math.toRadians(heading), @@ -2371,7 +2328,7 @@ function manualPairs(list) { * (not synthesized) outline so it drapes solid; a line is a `route` with mode * 'manual', its length as distance and no travel time; a pin is a point. */ -function resolveManualSpec(spec, type, viewer) { +function resolveManualSpec(spec, type, globe) { if (type === 'route') { // height 0 is a placeholder, not a placement: the world renderer draws a // route with clampToGround + CESIUM_3D_TILE classification, so the line is @@ -2412,7 +2369,7 @@ function resolveManualSpec(spec, type, viewer) { return { lon, lat, - height: sampleGroundHeight(viewer, lon, lat), + height: sampleGroundHeight(globe, lon, lat), ring, ...(polygons ? { polygons } : {}), footprintKind: 'area', @@ -2427,7 +2384,7 @@ function resolveManualSpec(spec, type, viewer) { return { lon, lat, - height: sampleGroundHeight(viewer, lon, lat), + height: sampleGroundHeight(globe, lon, lat), ring: null, source: 'manual', }; @@ -2490,8 +2447,8 @@ function appendDistance(baseLabel, distM) { /** * Whether a globe engine shows a world point: in front of the camera, inside - * the canvas and on the near side of the globe (what the Cesium path's - * culling volume and ellipsoid occluder answer). + * the canvas and on the near side of the globe (what Cesium's culling + * volume and ellipsoid occluder answer). */ function engineSeesPoint(engine, position) { if (!engine.projector.horizonVisible(position)) return false; diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index da7e4ae06..544d81a4d 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -241,7 +241,7 @@ test('outline queue: an 8-spec batch keeps at most two FIFO upgrades in flight', }; }; const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer, resolveTarget, }); @@ -313,7 +313,7 @@ test('outline queue: clear drops queued-but-unstarted upgrades without a later f }), }); engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer, resolveTarget, }); @@ -379,7 +379,7 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429 }); const resolved = await resolveAnnotationTarget({ - viewer: {}, + globe: {}, target: 'PT2 rate-limit fixture', latitude: 12.345, longitude: 67.89, @@ -453,7 +453,7 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde }); const resolved = await resolveAnnotationTarget({ - viewer: {}, + globe: {}, target: 'PT2 transient fixture', latitude: -12.345, longitude: -67.89, @@ -603,8 +603,8 @@ test('outline upgrade updates the rendered element in place without remove/add', remove() { calls.remove += 1; }, sync() {}, }; - const viewer = {}; - const engine = createAnnotationEngine({ viewer, renderer, placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => 'unit-test-key' }) }); + const globe = {}; + const engine = createAnnotationEngine({ globe, renderer, placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => 'unit-test-key' }) }); const upgraded = new Promise((resolve) => engine.onOutlineEvent(resolve)); const result = await engine.annotate([{ @@ -715,7 +715,7 @@ test('fresh async building batch reserves slots together and releases failed/cle t.after(() => _resetRenderGovernorForTest()); const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer: h.renderer, resolveTarget: async ({ target }) => ({ ...BUILDING_FP, @@ -762,7 +762,7 @@ test('outline workers retain completion ownership and clear cancels resolved-but const h = controlledBuildingRenderer(); const started = []; const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer: h.renderer, resolveTarget: async ({ target }) => ({ ...FIXED_POINT, @@ -805,7 +805,7 @@ test('building recaption timeout retains old route and a newer replacement owns installAnimationFrameStubs(t); const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), }); @@ -860,7 +860,7 @@ test('replacement cleanup failure still removes the old route and retains failed installAnimationFrameStubs(t); const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), + globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), }); const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]); await flushMicrotasks(); @@ -905,7 +905,7 @@ test('identical refresh revokes an older building replacement even if cancellati installAnimationFrameStubs(t); const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), + globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), }); const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]); await flushMicrotasks(); h.pending[0].finish(); await initial; @@ -936,7 +936,7 @@ test('successful replacement survives failed old-route retirement and keeps clea installAnimationFrameStubs(t); const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), + globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), }); const initial = engine.annotate([{ type: 'area', target: 'Capitol', label: 'Old' }]); await flushMicrotasks(); h.pending[0].finish(); await initial; @@ -975,7 +975,7 @@ test('pending fresh predecessor failure cannot erase a newer building intent', a globalThis.requestAnimationFrame = (callback) => { frames.push(callback); return frames.length; }; const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), + globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), }); const first = engine.annotate([{ type: 'area', target: 'Capitol', label: 'First' }]); await flushMicrotasks(); @@ -1004,7 +1004,7 @@ test('duplicate pending building results reflect final failure rather than a dea installAnimationFrameStubs(t); const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - viewer: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), + globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), }); const spec = { type: 'area', target: 'Capitol', label: 'Same' }; const batch = engine.annotate([spec, spec]); @@ -1023,7 +1023,7 @@ test('separate duplicate callers observe the original building install outcome', for (const outcome of ['installed', 'failed', 'timeout', 'clear']) { const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...BUILDING_FP }), }); @@ -1077,7 +1077,7 @@ test('a duplicate joins the physical deferred install but not another outline lo const h = controlledBuildingRenderer(); let lookups = 0; const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer: h.renderer, resolveTarget: async () => ({ ...FIXED_POINT, @@ -1119,7 +1119,7 @@ test('duplicate-replacement: a renderer throw leaves no phantom mark and no leak const { renderer, failAddOnce } = throwingRendererHarness(); const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer, resolveTarget: async () => ({ ...FIXED_POINT }), }); @@ -1173,7 +1173,7 @@ test('fresh path: a renderer throw is rolled back the same way', async (t) => { const { renderer, failAddOnce } = throwingRendererHarness(); const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer, resolveTarget: async () => ({ ...FIXED_POINT }), }); @@ -1237,7 +1237,7 @@ test('fresh path: a throw AFTER partial renderer state leaves nothing on the boa const { renderer, failAddOnce, live } = partialStateRendererHarness(); const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer, resolveTarget: async () => ({ ...FIXED_POINT }), }); @@ -1278,7 +1278,7 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing const { renderer, failAddOnce, live } = partialStateRendererHarness(); const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer, resolveTarget: async () => ({ ...FIXED_POINT }), }); @@ -1335,7 +1335,7 @@ test('destroy releases the renderer and prevents subsequent annotation work', as renderer.destroy = () => { destroyed++; }; - const engine = createAnnotationEngine({ viewer: {}, renderer }); + const engine = createAnnotationEngine({ globe: {}, renderer }); const drawn = await engine.annotate([ { type: 'pin', longitude: -97.74, latitude: 30.27 }, ]); @@ -1360,7 +1360,7 @@ test('a late annotation resolver cannot redraw after destruction', async (t) => let release; let signal; const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer, resolveTarget: (options) => { signal = options.signal; @@ -1385,9 +1385,10 @@ test('caller cancellation prevents a late annotation and camera mutation', async let resolverSignal; let flights = 0; const engine = createAnnotationEngine({ - viewer: { + globe: { camera: { - flyToBoundingSphere() { + pose: () => ({ heading: 0 }), + flyToSphere() { flights += 1; }, }, @@ -1424,7 +1425,7 @@ test('only an explicit navigation request permits resolving distant annotation t renderer.destroy = () => {}; const received = []; const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer, resolveTarget: async (options) => { received.push(options); @@ -1448,7 +1449,7 @@ test('recaption inherits outline credit from borrowed geometry rather than its n const { renderer } = fakeRenderer(); let first = true; const ring = [[-99,31],[-98.99,31],[-98.99,31.01],[-99,31]]; - const engine = createAnnotationEngine({ viewer: {}, renderer, + const engine = createAnnotationEngine({ globe: {}, renderer, resolveTarget: async () => { if (first) { first = false; return { ...FIXED_POINT, ring, footprintKind: 'area', outlineCredit: 'tiles' }; } return { ...FIXED_POINT, source: 'osm-local', resolveOutline: async () => null }; @@ -1471,7 +1472,7 @@ test('hand-drawn marks: one mark can be hidden, renamed, recoloured, framed and const { renderer, calls } = fakeRenderer(); const flights = []; const engine = createAnnotationEngine({ - viewer: { camera: { heading: 0, flyToBoundingSphere: (sphere) => flights.push(sphere) } }, + globe: { camera: { pose: () => ({ heading: 0 }), flyToSphere: (sphere) => flights.push(sphere) } }, renderer, }); let changes = 0; @@ -1540,10 +1541,10 @@ test('hand-drawn marks: remove, hide, clear, expiry and framing have their effec const { renderer, calls } = fakeRenderer(); const flights = []; const engine = createAnnotationEngine({ - viewer: { + globe: { camera: { - heading: 0, - flyToBoundingSphere: (sphere) => flights.push(sphere), + pose: () => ({ heading: 0 }), + flyToSphere: (sphere) => flights.push(sphere), }, }, renderer, @@ -1638,7 +1639,7 @@ test('hand-drawn marks: a voice mark or a hidden mark is not taken as their dupl _resetRenderGovernorForTest(); t.after(() => _resetRenderGovernorForTest()); const { renderer } = fakeRenderer(); - const engine = createAnnotationEngine({ viewer: {}, renderer }); + const engine = createAnnotationEngine({ globe: {}, renderer }); await engine.annotate( [ { @@ -1691,7 +1692,7 @@ test('hand-drawn marks: only a manual mark can be restyled', async (t) => { t.after(() => _resetRenderGovernorForTest()); const { renderer } = fakeRenderer(); const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer, resolveTarget: async () => ({ ...FIXED_POINT }), }); @@ -1706,7 +1707,7 @@ test('a drawn line that rounds up to the next unit is labelled in it, not as 100 installAnimationFrameStubs(t); const labels = []; const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} }; - const engine = createAnnotationEngine({ viewer: {}, renderer }); + const engine = createAnnotationEngine({ globe: {}, renderer }); const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180; for (const metres of [994, 996, 9940, 9960]) { await engine.annotate([ @@ -1722,7 +1723,7 @@ test('hand-drawn marks: two unnamed pins close together are two pins, whatever t _resetRenderGovernorForTest(); t.after(() => _resetRenderGovernorForTest()); const { renderer } = fakeRenderer(); - const engine = createAnnotationEngine({ viewer: {}, renderer }); + const engine = createAnnotationEngine({ globe: {}, renderer }); const pin = (lon, color) => ({ type: 'pin', manual: true, latitude: 30.27, longitude: lon, label: '', color }); await engine.annotate([pin(-97.74, 'primary')], { persist: true }); await engine.annotate([pin(-97.7402, 'primary')], { persist: true }); @@ -1740,7 +1741,7 @@ test('hand-drawn marks: hiding a mark before its first frame takes it off at onc t.after(() => _resetRenderGovernorForTest()); let syncs = 0; const renderer = { add() {}, update() {}, remove() {}, sync() { syncs += 1; }, destroy() {} }; - const engine = createAnnotationEngine({ viewer: {}, renderer }); + const engine = createAnnotationEngine({ globe: {}, renderer }); await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true }); const [mark] = engine.list(); const before = syncs; @@ -1759,7 +1760,7 @@ test('hand-drawn marks: with every mark hidden the scene idles, and showing one let renders = 0; installRenderGovernor({ scene: { requestRenderMode: true, requestRender: () => (renders += 1) } }); const { renderer } = fakeRenderer(); - const engine = createAnnotationEngine({ viewer: {}, renderer }); + const engine = createAnnotationEngine({ globe: {}, renderer }); await engine.annotate([ { type: 'pin', manual: true, latitude: 1, longitude: 1 }, { type: 'pin', manual: true, latitude: 2, longitude: 2 }, @@ -1784,7 +1785,7 @@ test('hand-drawn marks: a restyle the renderer cannot draw takes the mark off an const { renderer, failAddOnce } = throwingRendererHarness(); let syncs = 0; renderer.sync = () => (syncs += 1); - const engine = createAnnotationEngine({ viewer: {}, renderer }); + const engine = createAnnotationEngine({ globe: {}, renderer }); await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true }); const [mark] = engine.list(); let changes = 0; @@ -1811,7 +1812,7 @@ test('hand-drawn marks: a mark shown again right after it was drawn appears at o }); const tick = () => queued.splice(0).forEach((callback) => callback()); const { renderer } = fakeRenderer(); - const engine = createAnnotationEngine({ viewer: {}, renderer }); + const engine = createAnnotationEngine({ globe: {}, renderer }); await engine.annotate([{ type: 'pin', manual: true, latitude: 1, longitude: 1 }], { persist: true }); const [mark] = engine.list(); engine.setHidden(mark.id, true); @@ -1832,7 +1833,7 @@ test('removing one mark whose cleanup fails keeps it addressable for a retry', a calls.remove += 1; if (failRemove) throw new Error('renderer remove failed'); }; - const engine = createAnnotationEngine({ viewer: { camera: { heading: 0 } }, renderer }); + const engine = createAnnotationEngine({ globe: { camera: { pose: () => ({ heading: 0 }) } }, renderer }); await engine.annotate([ { type: 'pin', manual: true, latitude: 30.27, longitude: -97.74, label: 'Camp' }, ], { persist: true }); @@ -1850,7 +1851,7 @@ test('a building install in the batch does not re-judge marks that never install installAnimationFrameStubs(t); const h = controlledBuildingRenderer(); const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer: h.renderer, resolveTarget: async ({ target }) => target === 'Tower' ? { ...BUILDING_FP, lon: -97.7 } : { ...FIXED_POINT }, @@ -1873,7 +1874,7 @@ test('plain recaption keeps the old mark addressable when its removal throws', a installAnimationFrameStubs(t); const { renderer } = fakeRenderer(); const engine = createAnnotationEngine({ - viewer: {}, renderer, resolveTarget: async () => ({ ...FIXED_POINT }), + globe: {}, renderer, resolveTarget: async () => ({ ...FIXED_POINT }), }); await engine.annotate([{ type: 'point', target: 'Capitol', label: 'Capitol' }]); const old = engine.list()[0]; diff --git a/src/annotations/annotationResolver.test.mjs b/src/annotations/annotationResolver.test.mjs index 2c51213c1..13b523123 100644 --- a/src/annotations/annotationResolver.test.mjs +++ b/src/annotations/annotationResolver.test.mjs @@ -19,6 +19,7 @@ import { refineScope, isGroundsLikeAsk, } from './annotationResolver.js'; +import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs'; // Build a square way of ~`areaM2` centred `dLatM`/`dLonM` metres from an anchor, // in Overpass `out geom` element shape ({ tags, geometry: [{lat,lon}...] }). @@ -157,7 +158,7 @@ test('refineScope: entityKind refines only an unresolved (auto) scope', () => { }); function closeViewportViewer() { - return { + return cameraOnlyEngine({ camera: { positionCartographic: { latitude: 30.2672 * Math.PI / 180, @@ -165,7 +166,7 @@ function closeViewportViewer() { height: 1000, }, }, - }; + }); } function geocodePayload({ lat, lon, types, label }) { @@ -221,7 +222,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa }); const resolved = await resolveAnnotationTarget({ - viewer: closeViewportViewer(), + engine: closeViewportViewer(), target: 'the Texas Capitol', }); @@ -245,7 +246,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim }); const resolved = await resolveAnnotationTarget({ - viewer: closeViewportViewer(), + engine: closeViewportViewer(), target: 'state of Texas', }); @@ -286,7 +287,7 @@ for (const fixture of [ }); const resolved = await resolveAnnotationTarget({ - viewer: closeViewportViewer(), + engine: closeViewportViewer(), target: fixture.target, }); @@ -312,7 +313,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path', }); const resolved = await resolveAnnotationTarget({ - viewer: closeViewportViewer(), + engine: closeViewportViewer(), target: 'Texas', }); @@ -351,7 +352,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township for (const target of fixtures.keys()) { const resolved = await resolveAnnotationTarget({ - viewer: closeViewportViewer(), + engine: closeViewportViewer(), target, }); assert.ok(resolved); @@ -369,11 +370,11 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township // Synthetic footprints reproduce the real Capitol/YOTEL selection without live APIs. const CAPITOL_ANCHOR = { lat: 38.8899389, lon: -77.0090505 }; function capitolViewer() { - return { camera: { positionCartographic: { + return cameraOnlyEngine({ camera: { positionCartographic: { latitude: CAPITOL_ANCHOR.lat * Math.PI / 180, longitude: CAPITOL_ANCHOR.lon * Math.PI / 180, height: 1000, - } } }; + } } }); } function installCapitolMocks(t, elements, components = [ { long_name: 'Washington', types: ['locality', 'political'] }, @@ -401,7 +402,7 @@ for (const [target, deferFootprint] of [ const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' }); const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', tourism: 'hotel', name: 'YOTEL Washington DC' }); installCapitolMocks(t, [hotel, capitol]); - const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target, entityKind: 'building', footprint: true, deferFootprint }); + const result = await resolveAnnotationTarget({ engine: capitolViewer(), target, entityKind: 'building', footprint: true, deferFootprint }); assert.ok(result); const outline = deferFootprint ? await result.resolveOutline() : result; assert.deepEqual(outline.ring, capitol.geometry.map(p => [p.lon, p.lat])); @@ -412,7 +413,7 @@ for (const [target, deferFootprint] of [ test('address-only landmark without a matching outline keeps its geocoded point', async t => { const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', tourism: 'hotel', name: 'YOTEL Washington DC' }); installCapitolMocks(t, [hotel]); - const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'US Capitol building, Washington', entityKind: 'building', footprint: true, deferFootprint: true }); + const result = await resolveAnnotationTarget({ engine: capitolViewer(), target: 'US Capitol building, Washington', entityKind: 'building', footprint: true, deferFootprint: true }); assert.ok(result); assert.deepEqual([result.lat, result.lon], [CAPITOL_ANCHOR.lat, CAPITOL_ANCHOR.lon]); assert.equal(await result.resolveOutline(), null); @@ -422,7 +423,7 @@ test('missing address components do not turn a formatted city address into the l const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' }); const hotel = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', name: 'YOTEL Washington DC' }); installCapitolMocks(t, [hotel, capitol], []); - const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'United States Capitol building', footprint: true }); + const result = await resolveAnnotationTarget({ engine: capitolViewer(), target: 'United States Capitol building', footprint: true }); assert.deepEqual(result.ring, capitol.geometry.map(p => [p.lon, p.lat])); }); @@ -430,7 +431,7 @@ test('a genuine geocoded feature name still canonicalizes an alternate user name const capitol = squareWay(CAPITOL_ANCHOR, 0, 0, 20000, { building: 'yes', name: 'United States Capitol' }); const decoy = squareWay(CAPITOL_ANCHOR, 620, -140, 3000, { building: 'yes', name: 'Congress meeting building' }); installCapitolMocks(t, [decoy, capitol], [{ long_name: 'United States Capitol', types: ['landmark'] }]); - const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'Congress meeting building', footprint: true }); + const result = await resolveAnnotationTarget({ engine: capitolViewer(), target: 'Congress meeting building', footprint: true }); assert.deepEqual(result.ring, capitol.geometry.map(p => [p.lon, p.lat])); }); @@ -479,7 +480,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy }); const resolved = await resolveAnnotationTarget({ - viewer: closeViewportViewer(), + engine: closeViewportViewer(), target: 'Lady Bird Lake', }); @@ -534,7 +535,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s }); const resolved = await resolveAnnotationTarget({ - viewer: closeViewportViewer(), + engine: closeViewportViewer(), target: 'the monument near the Texas Capitol, Austin', footprint: true, }); @@ -581,11 +582,11 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t) }); const target = 'Waller Creek'; - assert.equal(await resolveAnnotationTarget({ viewer: closeViewportViewer(), target }), null); + assert.equal(await resolveAnnotationTarget({ engine: closeViewportViewer(), target }), null); const duringOutage = requests.length; photonReachable = true; - const retried = await resolveAnnotationTarget({ viewer: closeViewportViewer(), target }); + const retried = await resolveAnnotationTarget({ engine: closeViewportViewer(), target }); assert.ok(retried, 'the same target must resolve once the network is back'); assert.deepEqual([retried.lat, retried.lon], [30.2665, -97.7385]); diff --git a/src/annotations/annotationSession.test.mjs b/src/annotations/annotationSession.test.mjs index afb6dc745..e93ba4b68 100644 --- a/src/annotations/annotationSession.test.mjs +++ b/src/annotations/annotationSession.test.mjs @@ -47,7 +47,7 @@ test('the marks survive a globe leaving and are drawn again on the next one', as withAnimationFrames(t); const log = []; const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer: recordingRenderer(log, 'cesium'), resolveTarget: async () => ({ ...BUILDING }), }); @@ -69,7 +69,7 @@ test('the marks survive a globe leaving and are drawn again on the next one', as log.length = 0; engine.attachRenderer({ - viewer: {}, + engine: {}, renderer: recordingRenderer(log, 'next'), }); assert.deepEqual(log, ['next add Capitol']); @@ -83,13 +83,13 @@ test('detaching twice and attaching over an attached globe release each drawing withAnimationFrames(t); const log = []; const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer: recordingRenderer(log, 'a'), }); engine.detachRenderer(); engine.detachRenderer(); - engine.attachRenderer({ viewer: {}, renderer: recordingRenderer(log, 'b') }); - engine.attachRenderer({ viewer: {}, renderer: recordingRenderer(log, 'c') }); + engine.attachRenderer({ engine: {}, renderer: recordingRenderer(log, 'b') }); + engine.attachRenderer({ engine: {}, renderer: recordingRenderer(log, 'c') }); assert.deepEqual(log, ['a destroy', 'b destroy']); engine.destroy(); }); diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index ee34fb66f..ea8caf3ba 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -67,15 +67,17 @@ 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` 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, surface?: Function, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps + * 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 * @returns {{destroy: Function}|null} */ export function initDrawTool({ viewer = null, + engine = null, surface: makeSurface = null, annotations, navigation = null, @@ -119,7 +121,7 @@ export function initDrawTool({ const live = { session: () => session, cursor: () => cursor }; const surface = makeSurface ? makeSurface(live) - : cesiumDrawSurface(viewer, live); + : cesiumDrawSurface(viewer, engine, live); const present = () => publish({ @@ -485,7 +487,7 @@ export function initDrawTool({ * 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, live) { +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 @@ -526,12 +528,15 @@ function cesiumDrawSurface(viewer, live) { }); return { canvas: viewer.scene.canvas, - /** The world point under a canvas position, or null. */ + /** The world point under a canvas position (the engine's picks), or null. */ pick(position) { - const canvas = viewer.scene.canvas; - const w = canvas.clientWidth || canvas.width || 1; - const h = canvas.clientHeight || canvas.height || 1; - return pickWorldFromScreen(viewer, position.x / w, position.y / h); + 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) { @@ -657,8 +662,8 @@ function cesiumDrawSurface(viewer, live) { * 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, navigation })` starts the tool on a Cesium - * viewer, `attachScene({ surface, navigation })` on a globe without one + * 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 the controls answer nothing. Same handle as initDrawTool's @@ -731,6 +736,7 @@ export function createSessionDrawTool({ whenSettled: () => inner?.whenSettled() ?? settled, attachScene({ viewer = null, + engine = null, surface = null, navigation: next = navigation, } = {}) { @@ -740,6 +746,7 @@ export function createSessionDrawTool({ if (!viewer && !surface) return; inner = initDrawTool({ viewer, + engine, surface, annotations, navigation, diff --git a/src/annotations/drawTool.test.mjs b/src/annotations/drawTool.test.mjs index 85e0ccc1d..77cf920c1 100644 --- a/src/annotations/drawTool.test.mjs +++ b/src/annotations/drawTool.test.mjs @@ -53,7 +53,7 @@ test('the engine resolves manual geometry before any name resolution', () => { 'async function resolveSpec(spec, signal) {', ); const manualAt = engine.indexOf( - 'if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, viewer);', + 'if (isManualSpec(spec, type)) return resolveManualSpec(spec, type, globe);', resolveAt, ); const routeAt = engine.indexOf("if (type === 'route') {", resolveAt); diff --git a/src/annotations/hybridAnnotationRenderer.js b/src/annotations/hybridAnnotationRenderer.js index 08650d541..7a50e249d 100644 --- a/src/annotations/hybridAnnotationRenderer.js +++ b/src/annotations/hybridAnnotationRenderer.js @@ -24,9 +24,10 @@ import { createScreenAnnotationRenderer } from './screenAnnotationRenderer.js'; */ export function createHybridAnnotationRenderer( viewer, - { screen: screenOptions, world: worldRenderer, screenScene } = {}, + { screen: screenOptions, world: worldRenderer, screenScene, engine } = {}, ) { - const world = worldRenderer ?? createWorldAnnotationRenderer(viewer); + const world = + worldRenderer ?? createWorldAnnotationRenderer(viewer, { engine }); const screen = createScreenAnnotationRenderer( screenScene ?? viewer, screenOptions, diff --git a/src/annotations/hybridAnnotationRenderer.test.mjs b/src/annotations/hybridAnnotationRenderer.test.mjs index cf61502c4..4f7d13186 100644 --- a/src/annotations/hybridAnnotationRenderer.test.mjs +++ b/src/annotations/hybridAnnotationRenderer.test.mjs @@ -5,11 +5,18 @@ import { createPinia } from 'pinia'; import { useScreenAnnotationsStore } from '../ui/stores/screenAnnotations.js'; import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js'; import { showOsmCredit, hideOsmCredit } from '../data/dataCredits.js'; +import { createCesiumCredits } from '../globe/cesium/credits.js'; + +/** The engine fakeViewer built for each viewer double (its credits). */ +const enginesOf = new WeakMap(); /** A hybrid renderer whose screen board is presented to its own store. */ function hybridWithBoard(viewer) { const store = useScreenAnnotationsStore(createPinia()); - const renderer = createHybridAnnotationRenderer(viewer, { screen: { store } }); + const renderer = createHybridAnnotationRenderer(viewer, { + screen: { store }, + engine: enginesOf.get(viewer), + }); return { store, renderer }; } @@ -107,10 +114,7 @@ function fakeViewer(lon, lat) { scene.renderRequests += 1; }, }; - return { - dataSources, - frames, - viewer: { + const viewer = { credits, creditDisplay: { addStaticCredit: (credit) => credits.add(credit), @@ -130,8 +134,14 @@ function fakeViewer(lon, lat) { return index >= 0; }, }, - }, + }; + // The Cesium engine's credits over the viewer's credit display. + const engine = { + credits: createCesiumCredits(viewer).credits, + render: { request: () => scene.requestRender() }, }; + enginesOf.set(viewer, engine); + return { dataSources, frames, viewer, engine }; } function controlledFrames() { @@ -475,14 +485,14 @@ function workTrace() { test('buildings share one probe per frame and preserve probe geometry, filtering and fallback', async (t) => { installBrowserGlobals(t); - const { viewer, dataSources, frames } = fakeViewer(-99, 31); - const renderer = createHybridAnnotationRenderer(viewer); + const { viewer, engine, dataSources, frames } = fakeViewer(-99, 31); + const renderer = createHybridAnnotationRenderer(viewer, { engine }); t.after(() => renderer.destroy()); const first = buildingAnnotation('sampled-building'); const second = buildingAnnotation('fallback-building', 0.01); second.outlineCredit = 'osm'; const otherOwner = {}; - showOsmCredit(viewer, otherOwner); + showOsmCredit(engine, otherOwner); const probes = []; const outcomes = [-500, 120, 30, null, new Error('tile unavailable'), 60, 9001, 90]; viewer.scene.clampToHeight = (position) => { @@ -583,7 +593,7 @@ test('buildings share one probe per frame and preserve probe geometry, filtering 'removing the last tile proxy retains the OSM building and other owner'); renderer.remove(second); assert.equal([...viewer.credits].filter(c => c.showOnScreen).length, 1); - hideOsmCredit(viewer, otherOwner); + hideOsmCredit(engine, otherOwner); assert.equal(viewer.credits.size, 0); }); diff --git a/src/annotations/index.js b/src/annotations/index.js index bc5e0dad9..221fe6a20 100644 --- a/src/annotations/index.js +++ b/src/annotations/index.js @@ -12,22 +12,25 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js'; */ export function initAnnotations({ viewer, + engine: globe = null, tileset = null, placeSearch, resolver, }) { const engine = createSessionAnnotations({ placeSearch, resolver }); - engine.attachScene({ viewer, tileset }); + engine.attachScene({ viewer, engine: globe, tileset }); return engine; } /** * The page's annotation board, kept across globe engine switches: the marks - * belong to the page session, their drawing to the attached globe. - * `attachScene({ viewer, tileset })` draws the board on a Cesium viewer; - * `attachScene({ engine, renderer })` on a globe engine without a viewer - * (Navara: src/annotations/navaraAnnotationRenderer.js, built by the caller - * so this module never loads it); with neither the marks stay undrawn. + * belong to the page session, their drawing to the attached globe. The board + * works on the globe `engine` (camera, picks, surface); its drawing is the + * `renderer` built for that engine (Navara: + * src/annotations/navaraAnnotationRenderer.js, built by the caller so this + * module never loads it), or on a Cesium `viewer` the hybrid renderer built + * here. `attachScene({ engine, viewer, tileset })` or + * `attachScene({ engine, renderer })`; with neither the marks stay undrawn. * `detachScene()` releases the drawing and keeps the marks. */ export function createSessionAnnotations({ placeSearch, resolver }) { @@ -41,8 +44,7 @@ export function createSessionAnnotations({ placeSearch, resolver }) { engine: globe = null, renderer = null, } = {}) => { - if (!viewer && globe && renderer) - return engine.attachRenderer({ viewer: globe, renderer }); + if (renderer) return engine.attachRenderer({ engine: globe, renderer }); if (!viewer) return engine.detachRenderer(); // World-space footprint draping; clamped marks can use the photoreal tiles. if (tileset) { @@ -53,8 +55,8 @@ export function createSessionAnnotations({ placeSearch, resolver }) { } } engine.attachRenderer({ - viewer, - renderer: createHybridAnnotationRenderer(viewer), + engine: globe, + renderer: createHybridAnnotationRenderer(viewer, { engine: globe }), }); }; engine.detachScene = () => engine.detachRenderer(); diff --git a/src/annotations/outlineIdentity.test.mjs b/src/annotations/outlineIdentity.test.mjs index 8cd2ec54d..b86bd0e85 100644 --- a/src/annotations/outlineIdentity.test.mjs +++ b/src/annotations/outlineIdentity.test.mjs @@ -52,7 +52,7 @@ test('outlines sharing an outer ring but not their holes stay two marks', async footprintKind: 'area', }); const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer, resolveTarget, }); @@ -108,7 +108,7 @@ test('the engine resolves a bare street highlight with its footprint', async (t) }; }; const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer, resolveTarget, }); @@ -138,7 +138,7 @@ test('a duplicate that fails to clean up never costs the new outline', async (t) const stuck = new Set(); const outcomes = []; const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer: { ...renderer, add(anno) { diff --git a/src/annotations/outlineRungs.test.mjs b/src/annotations/outlineRungs.test.mjs index 6554e6f61..0631fe75d 100644 --- a/src/annotations/outlineRungs.test.mjs +++ b/src/annotations/outlineRungs.test.mjs @@ -28,6 +28,7 @@ import { createNominatimGate, } from '../../server/providers/regional/nominatimGate.js'; import { createVoiceAreas } from '../voice/areaActions.js'; +import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs'; const TILES = [6745, 6746].map((y) => decodeOpenFreeMapOutlineTile( @@ -924,15 +925,16 @@ const PLACES = { }, }; -const viewerAt = (lat, lon, height = 4000) => ({ - camera: { - positionCartographic: { - latitude: (lat * Math.PI) / 180, - longitude: (lon * Math.PI) / 180, - height, +const viewerAt = (lat, lon, height = 4000) => + cameraOnlyEngine({ + camera: { + positionCartographic: { + latitude: (lat * Math.PI) / 180, + longitude: (lon * Math.PI) / 180, + height, + }, }, - }, -}); + }); test('default settings: outline, street and building asks send nothing to Overpass', async (t) => { const prior = process.env.OVERPASS_UPSTREAMS; @@ -979,9 +981,9 @@ test('default settings: outline, street and building asks send nothing to Overpa const placeSearch = { geocode: async (q) => ({ place: PLACES[q] || null, answered: true }), }; - const resolve = async (viewer, args) => { + const resolve = async (engine, args) => { const result = await resolver.resolveAnnotationTarget({ - viewer, + engine, placeSearch, footprint: true, deferFootprint: true, @@ -1000,7 +1002,7 @@ test('default settings: outline, street and building asks send nothing to Overpa }); const areas = createVoiceAreas({ - viewer: viewerAt(27.7, 85.3, 40_000), + engine: viewerAt(27.7, 85.3, 40_000), annotationResolver: resolver, placeSearch, }); diff --git a/src/annotations/placeOutline.test.mjs b/src/annotations/placeOutline.test.mjs index 7f2acdde3..14faca0c9 100644 --- a/src/annotations/placeOutline.test.mjs +++ b/src/annotations/placeOutline.test.mjs @@ -7,9 +7,10 @@ import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js'; import { createAnnotationResolver } from './resolver.js'; import { polygonsContain } from '../data/adminBoundaries.js'; import { streetFootprint } from './outlineRungs.js'; +import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs'; function viewerAt(lat, lon, height = 20_000) { - return { + return cameraOnlyEngine({ camera: { positionCartographic: { latitude: (lat * Math.PI) / 180, @@ -17,7 +18,7 @@ function viewerAt(lat, lon, height = 20_000) { height, }, }, - }; + }); } /** A resolver whose boundary lookups and geocoder record every call. */ @@ -47,7 +48,7 @@ function harness(place = null, { outlineRungs } = {}) { const resolve = (target, viewer, extra = {}) => resolveAnnotationTarget({ placeSearch, - viewer, + engine: viewer, target, footprint: true, deferFootprint: true, @@ -710,7 +711,7 @@ test('configured OSM geometry retains its origin through inline, deferred and ca }); for (const deferFootprint of [false, true]) { const result = await resolver.resolveAnnotationTarget({ - viewer: viewerAt(31, -99, 3000), + engine: viewerAt(31, -99, 3000), latitude: 31, longitude: -99, target: 'Library', diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index c3b5e7f5f..3fe4246e7 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -14,7 +14,6 @@ import { nearestPointOnLines, } from '../sources/featureGeometry.js'; import { defaultGeospatial } from '../search/defaults.js'; -import * as Cesium from 'cesium'; import * as geo from '../geo/index.js'; import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js'; import { @@ -46,7 +45,6 @@ import { import { createOutlineRungs } from './outlineRungs.js'; import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; -import { isGlobeEngine } from '../globe/contract.js'; /** Prepare only local dateline outlines for planar admission and centering. */ function localOutlineFrame(fp) { @@ -180,7 +178,8 @@ export function createAnnotationResolver({ * Resolve a single annotation target to a normalized world anchor. * * @param {object} opts - * @param {Cesium.Viewer} opts.viewer + * @param {object} [opts.engine] The globe engine (view bias, screen picks, + * ground heights, credits). * @param {string} [opts.target] Place name to geocode. * @param {number} [opts.latitude] Explicit latitude (wins over target). * @param {number} [opts.longitude] Explicit longitude. @@ -202,7 +201,7 @@ export function createAnnotationResolver({ */ async function resolveAnnotationTarget({ placeSearch = unavailablePlaceSearch, - viewer, + engine, target, latitude, longitude, @@ -273,7 +272,7 @@ export function createAnnotationResolver({ !NON_ADMIN_ENTITY_KINDS.has(entityKind) ) { const center = - pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer); + pickWorldFromScreen(engine, 0.5, 0.5) || viewportProximity(engine); bundledCenter = center; const admin = await findAdminArea(trace.query, { near: center }).catch( () => null, @@ -282,7 +281,7 @@ export function createAnnotationResolver({ if (admin && !askNamesAdmin && isCoarseCityShape(admin)) coarseCityShape = admin; else if (admin) - return bundledAdminTarget(viewer, admin, trace.query, signal); + return bundledAdminTarget(engine, admin, trace.query, signal); // Bundled US places and neighborhoods named at or near the view. A // campus, grounds or monument ask ("Stanford" as a compound, "the // Capitol grounds") wants that feature, not a district of its name, @@ -290,10 +289,10 @@ export function createAnnotationResolver({ if (intent !== 'around_the_thing' && !isMonumentLikeQuery(trace.query)) { const area = await bundledAreaNear(trace.query, center, { entityKind, - viewRadiusKm: viewportProximity(viewer)?.radiusKm ?? null, + viewRadiusKm: viewportProximity(engine)?.radiusKm ?? null, }); signal?.throwIfAborted(); - if (area) return bundledAdminTarget(viewer, area, trace.query, signal); + if (area) return bundledAdminTarget(engine, area, trace.query, signal); } } @@ -301,7 +300,7 @@ export function createAnnotationResolver({ const query = String(target || '').trim(); if (query) { const center = - pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer); + pickWorldFromScreen(engine, 0.5, 0.5) || viewportProximity(engine); // Monument / grounds names scatter under Geocoding — try a view-biased Places Text Search FIRST. // A hit near the view centre is trusted (skips the proximity gate, like the osm-local snap); on a // miss we fall through to geocode + fetchLocalMonument below. The model's entityKind counts too: @@ -338,7 +337,7 @@ export function createAnnotationResolver({ if (source !== 'places') { const geocoded = await geocodePlace( query, - viewportBias(viewer), + viewportBias(engine), signal, placeSearch, ); @@ -411,7 +410,7 @@ export function createAnnotationResolver({ Number.isFinite(Number(screenY)) ) { const picked = pickWorldFromScreen( - viewer, + engine, Number(screenX), Number(screenY), ); @@ -424,7 +423,7 @@ export function createAnnotationResolver({ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return coarseCityHolds(bundledCenter) - ? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal) + ? bundledAdminTarget(engine, coarseCityShape, trace.query, signal) : null; // Monument/memorial/statue names geocode unreliably — Google scatters them across the city (e.g. @@ -437,7 +436,7 @@ export function createAnnotationResolver({ (isMonumentLikeQuery(target) || entityKind === 'point_feature') ) { const center = - pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer); + pickWorldFromScreen(engine, 0.5, 0.5) || viewportProximity(engine); if (center) { const mon = await fetchLocalMonument( center.lat, @@ -470,7 +469,7 @@ export function createAnnotationResolver({ // footprint resolution below re-checks its own recentered centroid against the same bound — a // polygon that would drag a good anchor out of plausibility is dropped (the mark stays an // honest point) rather than rejecting the whole annotation. - const vpGate = viewportProximity(viewer); + const vpGate = viewportProximity(engine); const gateDrift = (gLat, gLon) => { if (!vpGate) return null; // no camera info → pass const driftKm = @@ -491,7 +490,7 @@ export function createAnnotationResolver({ ); } return coarseCityHolds(bundledCenter) - ? bundledAdminTarget(viewer, coarseCityShape, trace.query, signal) + ? bundledAdminTarget(engine, coarseCityShape, trace.query, signal) : null; } } @@ -581,7 +580,7 @@ export function createAnnotationResolver({ ); signal?.throwIfAborted(); if (ne) { - registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT); + registerDynamicCredit(engine, NATURAL_EARTH_CREDIT); traceBundledOutline(ne.name, 'natural-earth'); const neCentroid = ringCentroid(ne.ring); return { @@ -594,7 +593,7 @@ export function createAnnotationResolver({ lat: neCentroid?.lat ?? lat, lon: neCentroid?.lon ?? lon, height: sampleGroundHeight( - viewer, + engine, neCentroid?.lon ?? lon, neCentroid?.lat ?? lat, ), @@ -642,7 +641,7 @@ export function createAnnotationResolver({ signal?.throwIfAborted(); if (admin && !askNamesAdmin && isCoarseCityShape(admin)) coarseCityShape ||= admin; - else if (admin) return bundledAdminOutline(viewer, admin, signal); + else if (admin) return bundledAdminOutline(engine, admin, signal); } // A geocoded US city, town or CDP: the Census place that holds it. if (scope === 'city') { @@ -652,7 +651,7 @@ export function createAnnotationResolver({ lon, ).catch(() => null); signal?.throwIfAborted(); - if (place) return bundledAdminOutline(viewer, place, signal); + if (place) return bundledAdminOutline(engine, place, signal); } // Pure admin: only an admin boundary is correct — never fall back to a // building/landuse (a city is never a single building). @@ -676,7 +675,7 @@ export function createAnnotationResolver({ lon, ).catch(() => null); signal?.throwIfAborted(); - if (wof) return bundledAdminOutline(viewer, wof, signal); + if (wof) return bundledAdminOutline(engine, wof, signal); } // Else fall through to the OSM admin/place → named-landuse → synthesis ladder. Each // returns a footprint, null (definitively no polygon), or undefined (transient @@ -875,7 +874,7 @@ export function createAnnotationResolver({ ...(fp.adminLevel ? { adminLevel: fp.adminLevel } : {}), lat: centroid.lat, lon: centroid.lon, - height: sampleGroundHeight(viewer, centroid.lon, centroid.lat), + height: sampleGroundHeight(engine, centroid.lon, centroid.lat), }; }; @@ -891,11 +890,11 @@ export function createAnnotationResolver({ pointLike, groundsLike, around: intent === 'around_the_thing', - view: viewportProximity(viewer), + view: viewportProximity(engine), signal, credit: (kind) => registerDynamicCredit( - viewer, + engine, kind === 'tiles' ? OPENMAPTILES_CREDIT : OSM_CREDIT, ), }; @@ -911,7 +910,7 @@ export function createAnnotationResolver({ coarseCityHolds({ lat, lon }) && !(result?.ring && !result.synthesized) ) - return bundledAdminOutline(viewer, coarseCityShape, signal); + return bundledAdminOutline(engine, coarseCityShape, signal); return result; }; @@ -939,7 +938,7 @@ export function createAnnotationResolver({ } } - const height = sampleGroundHeight(viewer, lon, lat); + const height = sampleGroundHeight(engine, lon, lat); // One concise line per target so the resolution path is visible in the browser console. if (trace.query) { console.log( @@ -1004,13 +1003,13 @@ export function createAnnotationResolver({ const COARSE_CITY_MAX_VERTICES = 32; /** Credit the pack a bundled boundary came from. */ - function creditAdminSource(viewer, admin) { + function creditAdminSource(engine, admin) { const credit = { 'natural-earth': NATURAL_EARTH_CREDIT, 'us-census': US_CENSUS_CREDIT, wof: WOF_CREDIT, }[admin.source]; - if (credit) registerDynamicCredit(viewer, credit); + if (credit) registerDynamicCredit(engine, credit); } /** @@ -1113,9 +1112,9 @@ export function createAnnotationResolver({ * draw them. Like the Natural Earth rung, it bypasses the scope caps and the * centroid drift bound: the unit IS the asked scope. */ - function bundledAdminOutline(viewer, admin, signal) { + function bundledAdminOutline(engine, admin, signal) { signal?.throwIfAborted(); - creditAdminSource(viewer, admin); + creditAdminSource(engine, admin); traceBundledOutline(admin.name, admin.source, admin.polygons?.length); const { lat, lon } = admin.label; return { @@ -1128,7 +1127,7 @@ export function createAnnotationResolver({ outlineSource: admin.source, lat, lon, - height: sampleGroundHeight(viewer, lon, lat), + height: sampleGroundHeight(engine, lon, lat), }; } @@ -1158,8 +1157,8 @@ export function createAnnotationResolver({ } /** A complete resolved target for a bundled administrative unit (no outline pending). */ - function bundledAdminTarget(viewer, admin, query, signal) { - const outline = bundledAdminOutline(viewer, admin, signal); + function bundledAdminTarget(engine, admin, query, signal) { + const outline = bundledAdminOutline(engine, admin, signal); const [west, south, east, north] = admin.bbox; console.log( `[Resolver] "${query}": bundled ${admin.kind} "${admin.name}"` + @@ -2200,7 +2199,7 @@ export function createAnnotationResolver({ return matches; } - // --- viewer helpers --------------------------------------------------------- + // --- engine helpers --------------------------------------------------------- /** * Current view rectangle as a Google `bounds` string `swLat,swLng|neLat,neLng`, @@ -2216,12 +2215,9 @@ export function createAnnotationResolver({ * Radius is scaled from camera height (a generous proxy for how much ground is on screen), with a * floor so a super-zoomed-in view doesn't over-reject genuinely nearby places. */ - function viewportProximity(viewer) { + function viewportProximity(engine) { try { - // A globe engine (search) or a Cesium viewer (annotations until 1c). - const carto = isGlobeEngine(viewer) - ? viewer.camera.pose().cartographic - : viewer?.camera?.positionCartographic; + const carto = engine?.camera.pose().cartographic; if (!carto) return null; const lat = geo.math.toDegrees(carto.latitude); const lon = geo.math.toDegrees(carto.longitude); @@ -2234,11 +2230,9 @@ export function createAnnotationResolver({ } /** Exported for searchAndFlyTo (src/locations.js), which shares this bias. */ - function viewportBias(viewer) { + function viewportBias(engine) { try { - const rect = isGlobeEngine(viewer) - ? viewer.camera.viewRectangle() - : viewer?.camera?.computeViewRectangle?.(); + const rect = engine?.camera.viewRectangle(); if (!rect) return null; const swLat = geo.math.toDegrees(rect.south).toFixed(4); const swLng = geo.math.toDegrees(rect.west).toFixed(4); @@ -2262,14 +2256,14 @@ export function createAnnotationResolver({ * ({ lat, lon, label, types, viewport, distanceM, … }) or null. */ async function placesNearViewRecovery( - viewer, + engine, query, geocoded = null, signal = undefined, placeSearch = defaultGeospatial, ) { const center = - pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer); + pickWorldFromScreen(engine, 0.5, 0.5) || viewportProximity(engine); if (!center) return null; const geocodeFar = !geocoded || @@ -2313,9 +2307,28 @@ export function createAnnotationResolver({ return geo.cartesian3.distance(hit, camera) < Math.max(2, 0.01 * reference); } - /** pickWorldFromScreen through a globe engine: the same cascade. */ - function enginePickWorldFromScreen(engine, nx, ny) { - const { width: w, height: h } = engine.host.size(); + /** + * Convert a NORMALIZED screen point (x,y in [0,1] of the current viewport) into + * a world lon/lat using the depth-aware pick cascade — the inverse of the + * project-to-screen used by the renderers. This is the "point at the pixel" + * fallback: the agent indicates a spot in the viewport screenshot when it can't + * name the place, and we anchor the mark to the actual world point under it. + * + * Each stage is validated before it is accepted: a depth pick over empty sky + * can return a NaN or centre-of-the-earth Cartesian. A degenerate pick IS a + * missed pick, so it falls through to the next stage and ultimately to the + * caller's null. The answer carries the height the pick landed on (a roof, a + * hillside, or 0 on the ellipsoid): the draw tool's live preview needs it, + * or the rubber band sinks to sea level while the pointer is on a hill. + */ + function pickWorldFromScreen(engine, nx, ny) { + let size; + try { + size = engine?.host.size(); + } catch { + return null; + } + const { width: w, height: h } = size ?? {}; if (!w || !h) return null; const screen = { x: Math.max(0, Math.min(1, nx)) * w, @@ -2343,107 +2356,13 @@ export function createAnnotationResolver({ } /** - * Convert a NORMALIZED screen point (x,y in [0,1] of the current viewport) into - * a world lon/lat using the depth-aware pick cascade — the inverse of the - * project-to-screen used by the renderers. This is the "point at the pixel" - * fallback: the agent indicates a spot in the viewport screenshot when it can't - * name the place, and we anchor the mark to the actual world point under it. + * Best-effort ground height at a coordinate. The globe is hidden behind + * the Google 3D tiles, so we try to clamp onto the rendered surface; if the + * tiles for that spot aren't loaded we fall back to the terrain, else the + * ellipsoid (0). */ - function pickWorldFromScreen(viewer, nx, ny) { - if (isGlobeEngine(viewer)) return enginePickWorldFromScreen(viewer, nx, ny); - const scene = viewer?.scene; - if (!scene) return null; - const canvas = scene.canvas; - const w = canvas.clientWidth || canvas.width || 0; - const h = canvas.clientHeight || canvas.height || 0; - if (!w || !h) return null; - const px = Math.max(0, Math.min(1, nx)) * w; - const py = Math.max(0, Math.min(1, ny)) * h; - const pos = new Cesium.Cartesian2(px, py); - - // Each stage is validated before it is accepted: a depth pick over empty sky - // can return a NaN or centre-of-the-earth Cartesian, and converting one of - // those throws inside Cesium. A degenerate pick IS a missed pick, so it falls - // through to the next stage and ultimately to the caller's null. - let cart = null; - if ( - scene.pickPositionSupported && - typeof scene.pickPosition === 'function' - ) { - try { - cart = scene.pickPosition(pos); - } catch { - cart = null; - } - } - if ( - !isPickedWorldPosition(cart) && - typeof viewer.camera.pickEllipsoid === 'function' - ) { - try { - cart = viewer.camera.pickEllipsoid(pos, Cesium.Ellipsoid.WGS84); - } catch { - cart = null; - } - } - if ( - !isPickedWorldPosition(cart) && - typeof viewer.camera.getPickRay === 'function' - ) { - try { - const ray = viewer.camera.getPickRay(pos); - cart = ray ? scene.globe?.pick(ray, scene) || null : null; - } catch { - cart = null; - } - } - if (!isPickedWorldPosition(cart)) return null; - const carto = geo.cartographic.fromCartesian(cart); - if (!carto) return null; - return { - lat: geo.math.toDegrees(carto.latitude), - lon: geo.math.toDegrees(carto.longitude), - // The height the pick actually landed on — a roof, a hillside, or 0 on - // the ellipsoid. Callers that only want a coordinate ignore it; the draw - // tool's live preview needs it, or the rubber band sinks to sea level - // while the pointer is on a hill. - height: Number.isFinite(carto.height) ? carto.height : 0, - }; - } - - /** - * Best-effort ground height at a coordinate. The Cesium globe is hidden behind - * the Google 3D tiles, so we try to clamp onto the photoreal tile surface; if - * the tiles for that spot aren't loaded we fall back to the ellipsoid (0). - */ - function sampleGroundHeight(viewer, lon, lat) { - if (isGlobeEngine(viewer)) return engineGroundHeight(viewer, lon, lat); - const scene = viewer?.scene; - if (!scene) return 0; - try { - if ( - scene.clampToHeightSupported && - typeof scene.clampToHeight === 'function' - ) { - const carto = geo.cartographic.fromDegrees(lon, lat); - const surface = geo.cartographic.toCartesian(carto); - const clamped = scene.clampToHeight(surface); - if (clamped) { - const h = geo.cartographic.fromCartesian(clamped).height; - if (Number.isFinite(h)) return h; - } - } - } catch { - /* tiles not ready — fall through */ - } - const globeHeight = scene.globe?.getHeight?.( - geo.cartographic.fromDegrees(lon, lat), - ); - return Number.isFinite(globeHeight) && globeHeight > 0 ? globeHeight : 0; - } - - /** sampleGroundHeight on a globe engine: the rendered surface, else terrain. */ - function engineGroundHeight(engine, lon, lat) { + function sampleGroundHeight(engine, lon, lat) { + if (!engine) return 0; try { if (engine.surface.clampToHeightSupported) { const clamped = engine.surface.clampToHeight( diff --git a/src/annotations/resolverEngine.test.mjs b/src/annotations/resolverEngine.test.mjs index 67d7db8b0..d5ca76ac1 100644 --- a/src/annotations/resolverEngine.test.mjs +++ b/src/annotations/resolverEngine.test.mjs @@ -1,6 +1,5 @@ -// The view-centred search helpers read the camera through a globe engine as -// well as through a viewer (src/locations.js searchAndFlyTo hands them the -// engine). +// The view-centred search helpers read the camera through the globe engine +// (src/locations.js searchAndFlyTo and the annotation board hand it to them). import test from 'node:test'; import assert from 'node:assert/strict'; import * as geo from '../geo/index.js'; diff --git a/src/annotations/worldAnnotationRenderer.js b/src/annotations/worldAnnotationRenderer.js index e40ccad0d..083f475b4 100644 --- a/src/annotations/worldAnnotationRenderer.js +++ b/src/annotations/worldAnnotationRenderer.js @@ -47,7 +47,12 @@ const PALETTE = { * BOTH paints onto terrain AND onto 3D tiles, so one value covers both worlds. */ -export function createWorldAnnotationRenderer(viewer) { +/** + * @param {Cesium.Viewer} viewer + * @param {{ engine?: object }} [options] `engine`: the globe engine whose + * credits show the OpenStreetMap attribution of the marks drawn from it. + */ +export function createWorldAnnotationRenderer(viewer, { engine = null } = {}) { const CLASSIFY = Cesium.ClassificationType.BOTH; const CLAMP = Cesium.HeightReference.CLAMP_TO_GROUND; const dataSource = new Cesium.CustomDataSource('gev-annotations'); @@ -522,7 +527,7 @@ export function createWorldAnnotationRenderer(viewer) { }), ); osmAnnotations.add(anno); - showOsmCredit(viewer, anno); + showOsmCredit(engine, anno); } else if (anno.type === 'arrow' && anno.to) { // Connector draped across the ground from origin to destination. const positions = [ @@ -577,7 +582,7 @@ export function createWorldAnnotationRenderer(viewer) { ) { // The actual world proxy owns both geometry and credit, including rollback. osmAnnotations.add(anno); - showOsmCredit(viewer, anno, { + showOsmCredit(engine, anno, { openMapTiles: anno.outlineCredit === 'tiles', }); } @@ -600,7 +605,7 @@ export function createWorldAnnotationRenderer(viewer) { function remove(anno) { pendingGround.get(anno)?.finish('cancelled'); - hideOsmCredit(viewer, anno); + hideOsmCredit(engine, anno); osmAnnotations.delete(anno); drawn.delete(anno); if (!anno?._entities) return; @@ -630,7 +635,7 @@ export function createWorldAnnotationRenderer(viewer) { function destroy() { destroyed = true; for (const task of [...pendingGround.values()]) task.finish('cancelled'); - for (const anno of osmAnnotations) hideOsmCredit(viewer, anno); + for (const anno of osmAnnotations) hideOsmCredit(engine, anno); osmAnnotations.clear(); drawn.clear(); try { diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index c94094ab6..d8169690a 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -13,11 +13,11 @@ import { } from './layers/renderers.js'; import { governorRequestRender, - installRenderGovernor, + installEngineRenderGovernor, uninstallRenderGovernor, } from '../renderGovernor.js'; import { attachSessionVoice } from './sessionVoice.js'; -import { initNavaraImageryBoxTool } from '../ui/imageryBoxNavara.js'; +import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { attachNavaraAnnotations, attachNavaraDraw, @@ -368,7 +368,7 @@ export function attachViewerlessTools( }); const recentImagery = dataManager.layers.get('recent-imagery')?.module; if (recentImagery) { - const tool = initNavaraImageryBoxTool({ + const tool = initImageryBoxTool({ engine, onBox: (box) => recentImagery.setBox(box), onPreview: (box) => recentImagery.setBoxPreview(box), @@ -397,7 +397,7 @@ export function attachViewerlessTools( } // Holds drive engine.render.setContinuous; requests go to the engine. A // parked engine uninstalls it (createNavaraScene suspend). - installRenderGovernor(engine); + installEngineRenderGovernor(engine); const syncVisibility = () => { if (document.hidden && !isEmbeddedInline()) return; data.presentation.flushVisible(); diff --git a/src/app/navaraScene.test.mjs b/src/app/navaraScene.test.mjs index e971bdd4d..bd7986c7a 100644 --- a/src/app/navaraScene.test.mjs +++ b/src/app/navaraScene.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { createApplicationScene } from './scene.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { - installRenderGovernor, + installEngineRenderGovernor, isRenderGovernorInstalled, } from '../renderGovernor.js'; @@ -164,7 +164,7 @@ test('the Navara scene tears down the controller, then the globe', async (t) => const run = await startScene(); // The tools install the governor on the engine (src/app/tools.js); the // scene removes it before the engine goes. - installRenderGovernor(run.engine); + installEngineRenderGovernor(run.engine); const view = globalThis.window; await run.teardown(); assert.equal(run.globeDestroyed(), 1); diff --git a/src/app/tools.js b/src/app/tools.js index 53acb9769..f52096ef3 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -275,7 +275,7 @@ function attachViewerTools( resolver: operations.annotationResolver, defer, }); - annotations.attachScene({ viewer, tileset }); + annotations.attachScene({ viewer, engine, tileset }); defer(() => annotations.detachScene()); // UTILITIES ▸ Draw on this viewer (src/app/sessionTools.js): the tool // claims this viewer's pointer while a sketch is open. @@ -285,7 +285,7 @@ function attachViewerTools( styleManager, defer, }); - drawTool?.attachScene({ viewer, navigation: styleManager }); + drawTool?.attachScene({ viewer, engine, navigation: styleManager }); defer(() => drawTool?.detachScene()); // UTILITIES ▸ Import on this viewer: the next Cesium viewer reads the // session's files again. The drop listeners belong to this viewer. @@ -309,8 +309,9 @@ function attachViewerTools( if (recentImagery) { recentImagery.attachTileset(tileset); const imageryBoxTool = initImageryBoxTool({ - viewer, + engine, onBox: (box) => recentImagery.setBox(box), + onPreview: (box) => recentImagery.setBoxPreview(box), onCancel: (reason, message, box) => { if (message) recentImagery.reportBoxRefusal(message, box); }, diff --git a/src/app/viewer.js b/src/app/viewer.js index f0bc03c12..f51ee7049 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -6,6 +6,16 @@ import { globeOutOfFrame, } from '../globeInView.js'; +// Dev convenience: expose the app's Cesium instance for console/preview probing +// (single shared module instance — avoids dual-Cesium state bugs when testing). +// A getter, because the production build's Cesium global loads only with the +// first Cesium globe. +if (typeof window !== 'undefined' && !('__CESIUM__' in window)) + Object.defineProperty(window, '__CESIUM__', { + configurable: true, + get: () => Cesium, + }); + const PINCH_ZOOM_MULTIPLIER = 8; const MAX_PINCH_PIXEL_DELTA = 120; diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index c06549f36..e08727cd6 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -3569,7 +3569,7 @@ export function createBhoteKoshiEventLayer({ else await _viewer.dataSources.add(_floodDataSource); if (!current()) return; } - registerDynamicCredit(engine ?? viewer, BHOTE_KOSHI_CREDIT); + registerDynamicCredit(engine, BHOTE_KOSHI_CREDIT); _scenePathPositionCache.clear(); _lastCinematicProgress = null; const terrain = syncTerrainComparison(); @@ -3640,7 +3640,7 @@ export function createBhoteKoshiEventLayer({ try { const event = await loadEvent(); throwIfEnableCancelled(signal, _enabled); - registerDynamicCredit(_engine ?? viewer, BHOTE_KOSHI_CREDIT); + registerDynamicCredit(_engine, BHOTE_KOSHI_CREDIT); if (_mapStackController) { _comparisonLease = acquireImageryComparison(_mapStackController, { owner: BHOTE_KOSHI_LAYER_ID, diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index 1e6ca9ce2..3b1cf7857 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -291,7 +291,6 @@ export function createBhoteKoshiLocatorLayer({ let _native = null; let _pausedApproach = null; const cameraForScene = () => _engine?.camera ?? _viewer?.camera; - const creditTarget = () => _engine ?? _viewer; const requestRender = (reason) => _engine?.kind === 'navara' ? _engine.render.request(reason) @@ -1049,8 +1048,8 @@ export function createBhoteKoshiLocatorLayer({ if (!_dataSource) return; if (_viewer) _viewer.dataSources.add(_dataSource); else if (_engine) _native = createNativeRenderer(_engine, _dataSource); - registerDynamicCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_CREDIT); - showOsmCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_LAYER_ID); + registerDynamicCredit(_engine, BHOTE_KOSHI_LOCATOR_CREDIT); + showOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID); } function attachRenderer(viewer, { engine = null } = {}) { @@ -1080,7 +1079,7 @@ export function createBhoteKoshiLocatorLayer({ _native?.destroy(); _native = null; if (_viewer && _dataSource) _viewer.dataSources.remove(_dataSource, false); - hideOsmCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_LAYER_ID); + hideOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID); _engine = _viewer = null; } @@ -1137,7 +1136,7 @@ export function createBhoteKoshiLocatorLayer({ } async function disable() { - hideOsmCredit(creditTarget(), BHOTE_KOSHI_LOCATOR_LAYER_ID); + hideOsmCredit(_engine, BHOTE_KOSHI_LOCATOR_LAYER_ID); _pausedApproach = null; _enabled = false; _boundaryReady = false; diff --git a/src/data/boundaryLabels.test.mjs b/src/data/boundaryLabels.test.mjs index a46a0aed5..06bee75c8 100644 --- a/src/data/boundaryLabels.test.mjs +++ b/src/data/boundaryLabels.test.mjs @@ -9,6 +9,7 @@ import * as Cesium from 'cesium'; import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js'; import { createAnnotationResolver } from '../annotations/resolver.js'; import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js'; +import { cameraOnlyEngine } from '../testSupport/viewerEngine.mjs'; const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8'); @@ -135,8 +136,8 @@ test('drawing bundled place and neighborhood outlines creates no Cesium label', 3000, ); const resolved = await resolveAnnotationTarget({ - // The resolver reads only the camera (no scene to pick from). - viewer: { camera: viewer.camera }, + // The resolver reads only the camera (no canvas to pick from). + engine: cameraOnlyEngine({ camera: viewer.camera }), target, footprint: true, deferFootprint: true, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 3c2026a9b..85ebc8a3e 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -1,8 +1,5 @@ -import { isGlobeEngine } from '../globe/contract.js'; -import { createCesiumCredits } from '../globe/cesium/credits.js'; - /** - * Per-layer data attribution registered into Cesium's credit display. + * Per-layer data attribution registered into the globe engine's credits. * * Legal requirement (see DATA_SOURCES.md, findings H10/H11 in * docs/pre-ship-audit-2026-07-01.md): every third-party data layer this app can @@ -13,7 +10,7 @@ import { createCesiumCredits } from '../globe/cesium/credits.js'; * * These credits are registered ONCE at init as STATIC credits with * showOnScreen=false, so they live in the expandable bottom-left "Data - * attribution" lightbox (Cesium's credit popover) rather than cluttering the + * attribution" lightbox (the engine's credit popover) rather than cluttering the * on-globe line. Always-present is intentional and reversible: the lightbox is * the app's canonical attribution surface and DATA_SOURCES.md is the * machine-readable index. Strings are copied verbatim from DATA_SOURCES.md — if @@ -49,29 +46,18 @@ export const ACARTIA_CREDIT = Object.freeze({ const osmDisplays = new WeakMap(); const googlePlacesDisplays = new WeakMap(); -const viewerCredits = new WeakMap(); /** - * Credits of a globe engine, or of a Cesium viewer's credit display for - * callers that still hold a viewer (Cesium layers); null without one. + * Credits of a globe engine (engine.credits, shown on its next frame); null + * without one. * @returns {{ addStatic: Function, request: () => void } | null} */ -function creditsOf(target) { - if (isGlobeEngine(target)) - return { - addStatic: (html, options) => target.credits.addStatic(html, options), - request: () => target.render.request('credits'), - }; - if (typeof target?.creditDisplay?.addStaticCredit !== 'function') return null; - let credits = viewerCredits.get(target); - if (!credits) { - credits = { - addStatic: createCesiumCredits(target).credits.addStatic, - request: () => target.scene?.requestRender?.(), - }; - viewerCredits.set(target, credits); - } - return credits; +function creditsOf(engine) { + if (typeof engine?.credits?.addStatic !== 'function') return null; + return { + addStatic: (html, options) => engine.credits.addStatic(html, options), + request: () => engine.render?.request('credits'), + }; } function updateOsmDisplay(credits, state) { @@ -713,7 +699,7 @@ const dynamicCreditKeys = new WeakMap(); * Register a conditional credit at the moment its data source activates. * Idempotent per `credit.key`; lands in the same "Data attribution" popover * as the static credits (showOnScreen=false). - * @param {object} target — the globe engine, or the initialized Cesium viewer + * @param {object} target — the globe engine * @param {{ key: string, html: string }} credit — e.g. `TOMTOM_CREDIT` * @returns {boolean} True when the credit is (now) registered. */ @@ -730,10 +716,10 @@ export function registerDynamicCredit(target, credit) { } /** - * Register every per-layer data credit into the viewer's credit display. + * Register every per-layer data credit into the engine's credit display. * Idempotent: safe to call once at init. Credits are static and always * present in the "Data attribution" popover. - * @param {object} target — the globe engine, or the initialized Cesium viewer + * @param {object} target — the globe engine */ export function registerDataCredits(target, credits = DATA_CREDITS) { const display = creditsOf(target); diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index 71a87dcfb..e8bb45587 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -56,17 +56,25 @@ test('OpenStreetMap has one generic data credit with separate tile and names dis ); }); +// A Cesium engine's credits over a viewer double's credit display. +async function cesiumCreditsEngine(viewer) { + const { createCesiumCredits } = await import('../globe/cesium/credits.js'); + return { + credits: createCesiumCredits(viewer).credits, + render: { request() {} }, + }; +} + test('inline OSM attribution persists until its last display owner leaves', async (t) => { const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js'); t.mock.timers.enable({ apis: ['setTimeout'] }); const visible = new Set(); - const viewer = { + const viewer = await cesiumCreditsEngine({ creditDisplay: { addStaticCredit: (c) => visible.add(c), removeStaticCredit: (c) => visible.delete(c), }, - scene: { requestRender() {} }, - }; + }); assert.equal(showOsmCredit(viewer, 'alpr'), true); assert.equal(showOsmCredit(viewer, 'alpr'), false); assert.equal(visible.size, 1); @@ -96,29 +104,31 @@ test('inline OSM attribution persists until its last display owner leaves', asyn assert.equal(visible.size, 1, 'reenabling restores the short credit'); }); -test('tile attribution follows source changes and viewers have independent ownership', async () => { +test('tile attribution follows source changes and engines have independent ownership', async () => { const { showOsmCredit, hideOsmCredit } = await import('./dataCredits.js'); - const create = () => { + const create = async () => { const credits = new Set(); return { - credits, - creditDisplay: { - addStaticCredit: (c) => credits.add(c), - removeStaticCredit: (c) => credits.delete(c), - }, + shown: credits, + ...(await cesiumCreditsEngine({ + creditDisplay: { + addStaticCredit: (c) => credits.add(c), + removeStaticCredit: (c) => credits.delete(c), + }, + })), }; }; - const a = create(), - b = create(); + const a = await create(), + b = await create(); showOsmCredit(a, 'installations', { openMapTiles: true }); showOsmCredit(b, 'installations'); - assert.equal(a.credits.size, 2); - assert.equal(b.credits.size, 1); + assert.equal(a.shown.size, 2); + assert.equal(b.shown.size, 1); showOsmCredit(a, 'installations'); - assert.equal(a.credits.size, 1, 'wide names no longer use tiles'); + assert.equal(a.shown.size, 1, 'wide names no longer use tiles'); hideOsmCredit(a, 'installations'); - assert.equal(a.credits.size, 0); - assert.equal(b.credits.size, 1); + assert.equal(a.shown.size, 0); + assert.equal(b.shown.size, 1); }); test('data credits go through the globe engine credits', async () => { diff --git a/src/data/focusDeemphasis.js b/src/data/focusDeemphasis.js index 4b91d484b..4471f38f2 100644 --- a/src/data/focusDeemphasis.js +++ b/src/data/focusDeemphasis.js @@ -1,15 +1,13 @@ import { worldToWindowCoordinates } from './render/cesiumProjection.js'; import * as geo from '../geo/index.js'; -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; -// A window position through the engine's projector when `scene` has a -// registered engine (a Cesium scene in the app, the Navara aircraft -// renderer's scene adapter), else Cesium's SceneTransforms (bare scenes in -// tests). The two agree on Cesium (the engine projector is SceneTransforms). +// A window position through the engine projector the scene carries (the +// layers' engine views, src/layers/shared/render/engineView.js), else +// Cesium's SceneTransforms (a Cesium scene). The two agree on Cesium (the +// engine projector is SceneTransforms). function worldToWindow(scene, position, result) { - const projector = engineFromViewer(scene)?.projector; - return projector - ? projector.worldToWindow(position, result) + return scene.projector + ? scene.projector.worldToWindow(position, result) : worldToWindowCoordinates(scene, position, result); } diff --git a/src/data/focusDeemphasis.test.mjs b/src/data/focusDeemphasis.test.mjs index 75698543a..93b68ee4f 100644 --- a/src/data/focusDeemphasis.test.mjs +++ b/src/data/focusDeemphasis.test.mjs @@ -163,12 +163,9 @@ test('NearFarScalar rendered-size interpolation matches Cesium clamp endpoints', assert.ok(middle > 0.5 && middle < 3); }); -test('a focus target projects through the engine registered for its scene', async () => { +test('a focus target projects through the projector its scene carries', async () => { const { publishFocusTargetFromCachedPosition, clearFocusTarget } = await import('./focusDeemphasis.js'); - const { registerViewerEngine } = await import( - '../globe/cesium/engineFromViewer.js' - ); const { createFakeEngine } = await import('../globe/testing/fakeEngine.js'); const engine = createFakeEngine(); const projected = []; @@ -176,9 +173,11 @@ test('a focus target projects through the engine registered for its scene', asyn projected.push(position); return Object.assign(result, { x: 300, y: 200 }); }; - // A scene with no Cesium behind it (the Navara renderer's adapter). - const scene = { frameState: { frameNumber: 7 } }; - const unregister = registerViewerEngine(scene, engine); + // A scene with no Cesium behind it (a layer's engine view). + const scene = { + frameState: { frameNumber: 7 }, + projector: engine.projector, + }; try { const target = publishFocusTargetFromCachedPosition({ ownerLayer: 'flights', @@ -201,7 +200,6 @@ test('a focus target projects through the engine registered for its scene', asyn assert.equal(target.frameNumber, 7); } finally { clearFocusTarget('flights'); - unregister(); engine.destroy(); } }); diff --git a/src/data/iconOrientation.js b/src/data/iconOrientation.js index a401a005f..126384ac8 100644 --- a/src/data/iconOrientation.js +++ b/src/data/iconOrientation.js @@ -26,14 +26,12 @@ import { horizonOccluder, } from './render/cesiumProjection.js'; import * as geo from '../geo/index.js'; -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; -// A window position through the engine's projector when `scene` has a -// registered engine (engineFromViewer), else Cesium's SceneTransforms. +// A window position through the engine projector the scene carries (the +// layers' engine views), else Cesium's SceneTransforms (a Cesium scene). function worldToWindow(scene, position, result) { - const projector = engineFromViewer(scene)?.projector; - return projector - ? projector.worldToWindow(position, result) + return scene.projector + ? scene.projector.worldToWindow(position, result) : worldToWindowCoordinates(scene, position, result); } diff --git a/src/data/iconOrientation.test.mjs b/src/data/iconOrientation.test.mjs index 31c627af9..ea707fafa 100644 --- a/src/data/iconOrientation.test.mjs +++ b/src/data/iconOrientation.test.mjs @@ -746,13 +746,12 @@ test('the perspective helper is continuous through the sub-pixel band, not a 23 ); }); -test('the exact projection asks the engine projector of a registered scene', async () => { +test('the exact projection asks the engine projector its scene carries', async () => { const geo = await import('../geo/index.js'); - const { registerViewerEngine } = await import('../globe/cesium/engineFromViewer.js'); const { perspectiveProjectedRotation: rotation } = await import('./iconOrientation.js'); - const scene = { camera: { rightWC: { x: 0, y: 1, z: 0 }, upWC: { x: 0, y: 0, z: 1 } } }; const asked = []; - const unregister = registerViewerEngine({ scene }, { + const scene = { + camera: { rightWC: { x: 0, y: 1, z: 0 }, upWC: { x: 0, y: 0, z: 1 } }, projector: { // Screen-right for every point further east (the probe), so 90° on screen. worldToWindow(position, result = geo.cartesian2.create()) { @@ -760,12 +759,8 @@ test('the exact projection asks the engine projector of a registered scene', asy return Object.assign(result, { x: asked.length === 1 ? 100 : 120, y: 100 }); }, }, - }); - try { - const value = rotation(scene, geo.cartesian3.fromDegrees(0, 0, 0), 90, null); - assert.equal(asked.length, 2, 'the anchor and the probe'); - assert.ok(Math.abs(value - -Math.PI / 2) < 1e-9, `rotation ${value}`); - } finally { - unregister(); - } + }; + const value = rotation(scene, geo.cartesian3.fromDegrees(0, 0, 0), 90, null); + assert.equal(asked.length, 2, 'the anchor and the probe'); + assert.ok(Math.abs(value - -Math.PI / 2) < 1e-9, `rotation ${value}`); }); diff --git a/src/data/scenePick.js b/src/data/scenePick.js index c1e164833..224028a02 100644 --- a/src/data/scenePick.js +++ b/src/data/scenePick.js @@ -1,5 +1,3 @@ -import { isGlobeEngine } from '../globe/contract.js'; - /** * Validation for positions that came back from a Cesium screen pick. * @@ -54,28 +52,18 @@ export function isPickedWorldPosition(position) { * The world position under a screen point: the rendered surface where the * depth buffer has it, else the ellipsoid; null over the sky. * - * @param {object} target The globe engine (`engine.picker.pickPosition`, then - * `engine.camera.pickEllipsoid`), or a Cesium viewer from callers that - * still hold one (`scene.pickPosition`, then `camera.pickEllipsoid`). + * @param {object} engine The globe engine (`engine.picker.pickPosition`, + * then `engine.camera.pickEllipsoid`); without one, null. * @param {{x: number, y: number}} screenPosition * @returns {{x: number, y: number, z: number}|null} A Cartesian3. */ -export function pickGroundPosition(target, screenPosition) { - if (isGlobeEngine(target)) { - const { picker, camera } = target; - return groundUnder( - screenPosition, - picker.pickPositionSupported ? picker : null, - camera, - ); - } - const scene = target?.scene; +export function pickGroundPosition(engine, screenPosition) { + if (!engine) return null; + const { picker, camera } = engine; return groundUnder( screenPosition, - scene?.pickPositionSupported && typeof scene.pickPosition === 'function' - ? scene - : null, - typeof target?.camera?.pickEllipsoid === 'function' ? target.camera : null, + picker.pickPositionSupported ? picker : null, + camera, ); } diff --git a/src/data/scenePick.test.mjs b/src/data/scenePick.test.mjs index 533ff214b..2d535fc27 100644 --- a/src/data/scenePick.test.mjs +++ b/src/data/scenePick.test.mjs @@ -162,20 +162,6 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => { assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - 30.2672) < 1e-6); }); -test('the ground under a click: the rendered surface, else the ellipsoid, never the sky', async () => { - const { pickGroundPosition } = await import('./scenePick.js'); - const ground = { x: 6378137, y: 0, z: 0 }; - const sky = { x: 500, y: 0, z: 0 }; - const viewer = (picked, ellipsoid) => ({ - scene: { pickPositionSupported: true, pickPosition: () => picked }, - camera: { pickEllipsoid: () => ellipsoid }, - }); - assert.equal(pickGroundPosition(viewer(ground, null), {}), ground); - assert.equal(pickGroundPosition(viewer(sky, ground), {}), ground); - assert.equal(pickGroundPosition(viewer(undefined, undefined), {}), null); - assert.equal(pickGroundPosition(null, {}), null); -}); - test('a surface pick is nothing, or Google 3D tile content without an id', async () => { const { isSurfacePick } = await import('./scenePick.js'); assert.equal(isSurfacePick(undefined), true); @@ -210,6 +196,9 @@ test('on a globe engine the ground is the picked surface, else the ellipsoid, ne unsupported.fake.script('pickEllipsoid', ground); assert.equal(pickGroundPosition(unsupported, screen), ground); assert.equal(unsupported.fake.callsOf('picker.pickPosition').length, 0); + unsupported.fake.script('pickEllipsoid', undefined); + assert.equal(pickGroundPosition(unsupported, screen), null); + assert.equal(pickGroundPosition(null, screen), null, 'no engine'); }); test('engine pick results classify like the Cesium picks they wrap', async () => { diff --git a/src/data/sharedOsmDisplays.test.mjs b/src/data/sharedOsmDisplays.test.mjs index 60bf0e2f2..963ab7b2d 100644 --- a/src/data/sharedOsmDisplays.test.mjs +++ b/src/data/sharedOsmDisplays.test.mjs @@ -10,6 +10,7 @@ import { createDirectionsSource } from '../layers/directions/source.js'; import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js'; import { createCctvDisplay } from '../layers/cctv/render/display.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { createCesiumCredits } from '../globe/cesium/credits.js'; import * as input from './inputOwnership.js'; import { createOverpassAlprSource } from '../layers/alpr/source.js'; @@ -34,6 +35,11 @@ function viewer(t) { }; return v; } +// The Cesium engine's credits over the viewer double's credit display. +const cesiumCredits = (v) => ({ + credits: createCesiumCredits(v).credits, + render: { request: noop }, +}); function assertCredit(v) { assert.ok(v.credits.some((credit) => credit.showOnScreen && credit.html.includes('>© OpenStreetMap<'))); } @@ -55,13 +61,13 @@ for (const index of [0, 1]) test(`${index ? 'dams' : 'datacenters'} introduce th test('Nepal locator introduces the shared OSM credit', async (t) => { const v = viewer(t); const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: async () => ({ name: 'Nepal', ring: [[80, 26], [88, 26], [88, 30], [80, 30]] }), overlayHost, requestRender: noop, scheduleFrame: () => 1, cancelFrame: noop }); - await layer.init(v); await layer.enable(v, { origin: 'scene' }); + await layer.init(v, { engine: cesiumCredits(v) }); await layer.enable(v, { origin: 'scene' }); assertCredit(v); await layer.destroy(v); assert.equal(v.credits.filter(c => c.showOnScreen).length, 0); }); test('voice route geometry introduces the shared OSM credit', (t) => { - const v = viewer(t), renderer = createWorldAnnotationRenderer(v); + const v = viewer(t), renderer = createWorldAnnotationRenderer(v, { engine: cesiumCredits(v) }); renderer.add({ id: 'route', type: 'route', color: 'primary', anchor: { lat: 0, lon: 0 }, path: [{ lat: 0, lon: 0 }, { lat: 0.01, lon: 0.01 }] }); assertCredit(v); renderer.destroy(); assert.equal(v.credits.filter(c => c.showOnScreen).length, 0); diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 3d7b6cf25..1296a4e05 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -173,8 +173,8 @@ function viewerAtHeight(height, { tilesLoaded = true, sampleHeight = () => undef trackedEntity: undefined, entities: { remove() {} }, }; - // The ground snap samples through the viewer's engine (engineFromViewer): - // the engine surface over this stub's tileset and scene.sampleHeight. + // The ground snap samples through the viewer's engine (the test + // renderer's surfaceOwner, engineFromViewer): the engine surface over this stub's tileset and scene.sampleHeight. registerViewerEngine(viewer, { isDestroyed: () => false, surface: { diff --git a/src/layers/aircraft/render/contract.js b/src/layers/aircraft/render/contract.js index 8ad000ccd..9d1661aab 100644 --- a/src/layers/aircraft/render/contract.js +++ b/src/layers/aircraft/render/contract.js @@ -34,8 +34,8 @@ // rightWC, heading, pitch, roll, frustum.fovy) and `scene` scene-shaped // ({ camera, canvas.clientHeight, frameState.frameNumber }) for the shared // helpers that take them (iconOrientation, focusDeemphasis): adapters over -// engine.camera.pose(), with the engine registered for the scene -// (engineFromViewer) so projections go through its projector. +// engine.camera.pose(), the scene carrying the engine's `projector` so +// projections go through it. /** * @typedef {object} AircraftView diff --git a/src/layers/recentImagery/index.test.mjs b/src/layers/recentImagery/index.test.mjs index 90dbf3a52..bf0d7761e 100644 --- a/src/layers/recentImagery/index.test.mjs +++ b/src/layers/recentImagery/index.test.mjs @@ -1364,8 +1364,7 @@ test('with SELECT BOX armed, Escape clears the preview first and the next Escape const fakes = boxToolFakes(); const cancels = []; const tool = initImageryBoxTool({ - viewer: fakes.viewer, - cesium: fakes.cesium, + engine: fakes.engine, pickWorld: fakes.pickWorld, documentRef: fakes.documentRef, onBox: (box) => f.layer.setBox(box), diff --git a/src/layers/recentImagery/testDoubles.mjs b/src/layers/recentImagery/testDoubles.mjs index 2b00e5b01..ae28db341 100644 --- a/src/layers/recentImagery/testDoubles.mjs +++ b/src/layers/recentImagery/testDoubles.mjs @@ -1,6 +1,6 @@ /** * Test doubles shared by the Recent Imagery tests: manual timers, fetch - * responses, and the Cesium/viewer/document fakes the box tool needs. + * responses, and the engine and document fakes the box tool needs. */ import { thumbnailOrder } from './model.js'; @@ -60,139 +60,116 @@ export function response({ }; } -const EVENT_TYPES = { - LEFT_DOWN: 'down', - MOUSE_MOVE: 'move', - LEFT_UP: 'up', - LEFT_CLICK: 'click', - LEFT_DOUBLE_CLICK: 'dblclick', -}; - -/** - * Cesium, viewer and document fakes for `initImageryBoxTool`. The canvas is - * 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky. - */ -export function boxToolFakes() { - const handlers = []; - class ScreenSpaceEventHandler { - constructor(canvas) { - this.canvas = canvas; - this.actions = new Map(); - this.destroyed = false; - handlers.push(this); - } - setInputAction(fn, type) { - this.actions.set(type, fn); - } - getInputAction(type) { - return this.actions.get(type); - } - removeInputAction(type) { - this.actions.delete(type); - } - destroy() { - this.destroyed = true; - } - } - class CustomDataSource { - constructor(name) { - this.name = name; - const values = []; - this.entities = { - values, - add: (entity) => (values.push(entity), entity), - removeAll: () => values.splice(0), - }; - } - } - const color = { withAlpha: (alpha) => ({ alpha }) }; - const cesium = { - ScreenSpaceEventHandler, - ScreenSpaceEventType: EVENT_TYPES, - CustomDataSource, - CallbackProperty: class {}, - PolylineDashMaterialProperty: class {}, - ClassificationType: { BOTH: 'both' }, - Color: { fromCssColorString: () => color }, - Rectangle: { fromDegrees: () => ({}) }, - Cartesian3: { fromDegreesArray: (values) => values }, - }; - - const stock = new ScreenSpaceEventHandler('stock'); - const originalClick = () => 'select'; - const originalDouble = () => 'track'; - stock.setInputAction(originalClick, EVENT_TYPES.LEFT_CLICK); - stock.setInputAction(originalDouble, EVENT_TYPES.LEFT_DOUBLE_CLICK); - const sources = []; - const viewer = { - renders: 0, - scene: { - canvas: { clientWidth: 1000, clientHeight: 500 }, - screenSpaceCameraController: { enableInputs: true }, - requestRender: () => { - viewer.renders += 1; - }, - }, - screenSpaceEventHandler: stock, - dataSources: { - sources, - add: (source) => (sources.push(source), Promise.resolve(source)), - remove: (source) => sources.splice(sources.indexOf(source), 1), - }, - }; - +/** A DOM event target that keeps its listeners by type. */ +function eventTarget(extra = {}) { const listeners = []; - const classes = new Set(); - const documentRef = { + return { listeners, - classes, addEventListener: (type, fn) => listeners.push([type, fn]), removeEventListener: (type, fn) => { - const index = listeners.findIndex((entry) => entry[1] === fn); + const index = listeners.findIndex( + (entry) => entry[0] === type && entry[1] === fn, + ); if (index >= 0) listeners.splice(index, 1); }, + dispatch(type, event) { + for (const [name, fn] of [...listeners]) if (name === type) fn(event); + return event; + }, + ...extra, + }; +} + +/** A pointer or key event that records whether it was consumed. */ +function domEvent(fields) { + return { + prevented: false, + stopped: false, + preventDefault() { + this.prevented = true; + }, + stopImmediatePropagation() { + this.stopped = true; + }, + ...fields, + }; +} + +/** + * Globe engine and document fakes for `initImageryBoxTool`. The canvas is + * 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky. + * `suspend: true` gives the engine input a counted suspendCameraInputs + * (Navara's); without it the tool turns setCameraInputs off and on. + */ +export function boxToolFakes({ suspend = false } = {}) { + const camera = { inputs: true, suspended: 0 }; + const captured = new Set(); + const canvas = eventTarget({ + getBoundingClientRect: () => ({ left: 0, top: 0 }), + setPointerCapture: (id) => captured.add(id), + hasPointerCapture: (id) => captured.has(id), + releasePointerCapture: (id) => captured.delete(id), + }); + const input = { + setCameraInputs(enabled) { + camera.inputs = Boolean(enabled); + }, + }; + if (suspend) + input.suspendCameraInputs = () => { + camera.suspended += 1; + return () => { + camera.suspended -= 1; + }; + }; + const engine = { + host: { canvas, size: () => ({ width: 1000, height: 500 }) }, + input, + }; + const windowRef = eventTarget(); + const classes = new Set(); + const documentRef = eventTarget({ + classes, + defaultView: windowRef, body: { classList: { add: (name) => classes.add(name), remove: (name) => classes.delete(name), }, }, - }; - - const pickWorld = (_viewer, nx, ny) => + }); + const pickWorld = (_engine, nx, ny) => ny * 500 < 40 ? null : { lon: -97.9 + nx * 0.2, lat: 30.4 - ny * 0.2 }; - /** The live (non-stock) canvas handler, if the tool has one bound. */ - const handler = () => - handlers.find((h) => h.canvas !== 'stock' && !h.destroyed) || null; - const fire = (type, event) => - handler()?.actions.get(EVENT_TYPES[type])?.(event); + const pointer = (at, fields = {}) => + domEvent({ + button: 0, + pointerId: 1, + clientX: at.x, + clientY: at.y, + ...fields, + }); + /** Pointer events as the browser sends them: a press on the canvas, moves and the release on the document. */ + const down = (at, fields) => + canvas.dispatch('pointerdown', pointer(at, fields)); + const move = (at, fields) => + documentRef.dispatch('pointermove', pointer(at, fields)); + const up = (at, fields) => + documentRef.dispatch('pointerup', pointer(at, fields)); /** Dispatch a keydown to the tool's document listener. */ - const key = (name) => { - const event = { - key: name, - prevented: false, - stopped: false, - preventDefault() { - this.prevented = true; - }, - stopImmediatePropagation() { - this.stopped = true; - }, - }; - listeners.find(([type]) => type === 'keydown')?.[1](event); - return event; - }; + const key = (name) => + documentRef.dispatch('keydown', domEvent({ key: name })); return { - cesium, - viewer, + engine, + canvas, + camera, + captured, documentRef, + windowRef, pickWorld, - handler, - fire, + down, + move, + up, key, - types: EVENT_TYPES, - originalClick, - originalDouble, }; } diff --git a/src/layers/shared/render/engineView.js b/src/layers/shared/render/engineView.js index d9ced61da..c675921bc 100644 --- a/src/layers/shared/render/engineView.js +++ b/src/layers/shared/render/engineView.js @@ -1,13 +1,12 @@ // An engine-contract view shared by layer renderers and the celestial HUD: // `camera` camera-shaped and `scene` // scene-shaped adapters over engine.camera.pose() (refreshed once per frame, -// and whenever the camera moved), with the engine registered for the scene -// (engineFromViewer) so the shared helpers (iconOrientation, focus) project -// through its projector; the frame number, frustum and horizon tests, the -// window projection and the frame/camera events. No three, no Cesium. +// and whenever the camera moved), the scene carrying the engine's projector +// so the shared helpers (iconOrientation, focus) project through it; the +// frame number, frustum and horizon tests, the window projection and the +// frame/camera events. No three, no Cesium. import * as geo from '../../../geo/index.js'; -import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js'; /** * @param {import('../../../globe/contract.js').GlobeEngine} engine @@ -87,9 +86,9 @@ export function createEngineLayerView( return frameNumber; }, }, + // What the shared helpers project through. + projector: engine.projector, }; - // Shared helpers project through the engine registered for their scene. - removers.push(registerViewerEngine(scene, engine)); const horizon = { isPointVisible: (position) => engine.projector.horizonVisible(position), }; diff --git a/src/layers/vessels/render/contract.js b/src/layers/vessels/render/contract.js index c224679f8..fb111915d 100644 --- a/src/layers/vessels/render/contract.js +++ b/src/layers/vessels/render/contract.js @@ -19,8 +19,8 @@ // The view: `camera` camera-shaped (positionWC, positionCartographic, // rightWC, upWC, heading, pitch, roll, pickEllipsoid(screen)) and `scene` // scene-shaped ({ camera, canvas }) for the shared helpers that take them -// (iconOrientation, the view-area request), with the engine registered for -// the scene (engineFromViewer) so projections go through its projector. +// (iconOrientation, the view-area request), the scene carrying the engine's +// `projector` so projections go through it. /** * @typedef {object} VesselView diff --git a/src/mapStackController.js b/src/mapStackController.js index 3af4de8da..382f73608 100644 --- a/src/mapStackController.js +++ b/src/mapStackController.js @@ -10,11 +10,11 @@ export class MapStackController extends MapSourceController { const googleApiKey = typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : ''; // Sources for the page's engine: Navara builds its own from the - // descriptions (src/maps/defaultSources.js). + // descriptions (src/maps/defaultSources.js). Without an engine, Cesium's. const registry = createDefaultMapSources({ ...options, googleApiKey, - engineKind: options.engine?.kind === 'navara' ? 'navara' : 'cesium', + engineKind: options.engine?.kind ?? 'cesium', }); super(viewer, { registry, diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index d5067e6b5..7696519b7 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1,5 +1,4 @@ import * as geo from '../geo/index.js'; -import { isGlobeEngine } from '../globe/contract.js'; import { WORLD_OVERLAY_OCCLUDER_SELECTORS } from './worldOverlayChrome.js'; export { WORLD_OVERLAY_OCCLUDER_SELECTORS } from './worldOverlayChrome.js'; import { @@ -2727,7 +2726,8 @@ function createDevFacade() { * @param {import('../globe/contract.js').GlobeEngine} engine */ export function initWorldOverlay(engine) { - if (!isGlobeEngine(engine)) + // What the host reads of the engine: a Cesium viewer has none of it. + if (!engine?.projector || !engine.frames?.postRender || !engine.host) throw new TypeError('initWorldOverlay requires a globe engine'); if (_engine === engine && _canvas && _removePostRender) return; if (_engine) destroyWorldOverlay(); diff --git a/src/overpassOffload.test.mjs b/src/overpassOffload.test.mjs index 7ef6b0c2b..60edcf178 100644 --- a/src/overpassOffload.test.mjs +++ b/src/overpassOffload.test.mjs @@ -410,7 +410,7 @@ test('annotation capability miss keeps its pin and exposes the unavailable label event, attempts = 0; const engine = createAnnotationEngine({ - viewer: {}, + globe: {}, renderer: { add() {}, update(mark) { diff --git a/src/renderGovernor.js b/src/renderGovernor.js index e8ce96918..72f469ad0 100644 --- a/src/renderGovernor.js +++ b/src/renderGovernor.js @@ -38,12 +38,10 @@ * `scene.requestRender()`. It needs no frame events (`engine.frames`). * * Any other globe engine (Navara) is the governor's backend instead: install - * it on the engine and the owner-counted holds drive - * `engine.render.setContinuous`, requests go to `engine.render.request`. + * it on the engine (installEngineRenderGovernor) and the owner-counted holds + * drive `engine.render.setContinuous`, requests go to `engine.render.request`. */ -import { isGlobeEngine } from './globe/contract.js'; - let _viewer = null; /** The non-Cesium engine the governor drives, or null (Cesium: `_viewer`). */ let _engine = null; @@ -75,22 +73,14 @@ function applyMode() { } /** - * Install the governor on the viewer. Idempotent. Before install, - * hold/release still record into the holds set (and apply at install time); - * requests are safe no-ops — so modules can call all three unconditionally - * in tests without a viewer. - * @param {Cesium.Viewer | import('./globe/contract.js').GlobeEngine} viewer - * The Cesium viewer, or a non-Cesium globe engine. + * Install the governor on a Cesium viewer, as the Cesium engine's render + * backend. Idempotent. Before install, hold/release still record into the + * holds set (and apply at install time); requests are safe no-ops — so + * modules can call all three unconditionally in tests without a viewer. + * @param {Cesium.Viewer} viewer * @returns {void} */ export function installRenderGovernor(viewer) { - if (isGlobeEngine(viewer) && viewer.kind !== 'cesium') { - _engine = viewer; - _viewer = viewer; - _installed = true; - applyMode(); - return; - } if (!viewer?.scene) throw new TypeError('installRenderGovernor requires a Cesium viewer'); _engine = null; @@ -103,6 +93,22 @@ export function installRenderGovernor(viewer) { applyMode(); } +/** + * Install the governor on a globe engine that renders by itself (Navara): + * the holds drive `engine.render.setContinuous`, requests go to + * `engine.render.request`. Idempotent, like installRenderGovernor. + * @param {import('./globe/contract.js').GlobeEngine} engine + * @returns {void} + */ +export function installEngineRenderGovernor(engine) { + if (typeof engine?.render?.setContinuous !== 'function') + throw new TypeError('installEngineRenderGovernor requires a globe engine'); + _engine = engine; + _viewer = engine; + _installed = true; + applyMode(); +} + /** * Register a continuous-render hold. Idempotent per owner. * Call where the owner's per-frame work BEGINS (scene listener installed, diff --git a/src/renderGovernor.test.mjs b/src/renderGovernor.test.mjs index e0856a8a8..369642ae0 100644 --- a/src/renderGovernor.test.mjs +++ b/src/renderGovernor.test.mjs @@ -2,6 +2,7 @@ import { test, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { installRenderGovernor, + installEngineRenderGovernor, holdContinuousRender, releaseContinuousRender, governorRequestRender, @@ -118,7 +119,7 @@ test('on a non-Cesium globe engine the governor drives engine.render with owner- _resetRenderGovernorForTest(); const engine = createFakeEngine({ kind: 'navara' }); holdContinuousRender('before-install'); - installRenderGovernor(engine); + installEngineRenderGovernor(engine); assert.equal(engine.render.isContinuous(), true, 'a hold taken before install applies'); releaseContinuousRender('before-install'); assert.equal(engine.render.isContinuous(), false); @@ -141,7 +142,7 @@ test('releasing an absent Navara hold does not wake an already idle engine', asy const { createFakeEngine } = await import('./globe/testing/fakeEngine.js'); _resetRenderGovernorForTest(); const engine = createFakeEngine({ kind: 'navara' }); - installRenderGovernor(engine); + installEngineRenderGovernor(engine); engine.fake.resetCalls(); for (let i = 0; i < 22; i++) releaseContinuousRender('cctv-projection'); assert.equal(engine.fake.callsOf('render.request').length, 0); diff --git a/src/scenes/director.js b/src/scenes/director.js index 4acda1a68..0b696eefb 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -116,7 +116,7 @@ export class SceneDirector { }); this._sharing = createSceneSharing(this); this._interactionTransitions = 0; - this._interactions = createSceneInteractions(viewer, { + this._interactions = createSceneInteractions({ engine, available: () => !this._destroyed && !this._running && !this.viewer?.trackedEntity, @@ -255,7 +255,7 @@ export class SceneDirector { }); this._cameraHandoffUnsubscribe?.(); this._removeCameraInput?.(); - this._interactions.setScene(null, null); + this._interactions.setScene(null); this._dataPacks.setScene(null, null); this._flight?.suspend(); this.engine = this.viewer = null; @@ -268,7 +268,7 @@ export class SceneDirector { this._isMapStackAvailable = isAvailable; try { this._dataPacks.setScene(viewer, native, engine.host.container); - this._interactions.setScene(viewer, engine, this._dataPacks.getTargets()); + this._interactions.setScene(engine, this._dataPacks.getTargets()); } catch (error) { // Release any packs already drawn before the caller parks the failed globe. this.suspendScene(); diff --git a/src/scenes/interactions.js b/src/scenes/interactions.js index c6b0aec32..9ba7fb691 100644 --- a/src/scenes/interactions.js +++ b/src/scenes/interactions.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { nextTick } from 'vue'; import { isPointerFree } from '../data/inputOwnership.js'; import { createInteractionSession } from '../director/interactions/session.js'; @@ -9,21 +8,19 @@ import { applicationPinia } from '../ui/vueRuntime.js'; /** * Own the active shot's pick handler, accessible actions and plain-text * feedback. The Scene actions panel is presented to the scene interactions - * store, which its view renders inside the viewer's container. + * store, which its view renders inside the globe engine's container. A + * feature is picked through engine.input (a click with its pick). */ // Keys each activation's panel, so a new one never reuses an old one's DOM. let activations = 0; -export function createSceneInteractions( - viewer, - { - execute, - engine = null, - available = () => true, - store = useSceneInteractionsStore(applicationPinia()), - }, -) { - let container = engine?.host?.container ?? viewer?.container; +export function createSceneInteractions({ + execute, + engine = null, + available = () => true, + store = useSceneInteractionsStore(applicationPinia()), +}) { + let container = engine?.host?.container; let panel = null, handler, items = [], @@ -79,7 +76,7 @@ export function createSceneInteractions( return ok; } function bindInput(owner) { - if (!engine && !viewer) return; + if (!engine) return; const click = (event) => { if ( panel?.owner !== owner || @@ -88,9 +85,7 @@ export function createSceneInteractions( !available() ) return; - const picked = engine - ? event.picked?.id - : viewer.scene.pick(event.position)?.id; + const picked = event.picked?.id; const matches = items.filter( (item) => targets.get( @@ -108,32 +103,26 @@ export function createSceneInteractions( void nextTick(() => { if (panel?.owner !== owner || !shown()) return; const id = globalThis.CSS?.escape?.(matches[0].id) ?? matches[0].id; - (engine?.host.container ?? viewer?.container) + engine?.host.container ?.querySelector?.( `[data-director-interactions] [data-director-action="${id}"]`, ) ?.focus({ preventScroll: true }); }); }; - if (engine) { - handler = { destroy: engine.input.on('click', click, { pick: true }) }; - } else { - handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - handler.setInputAction(click, Cesium.ScreenSpaceEventType.LEFT_CLICK); - } + handler = { destroy: engine.input.on('click', click, { pick: true }) }; } return { clear, destroy: clear, getState: session.getState, - setScene(nextViewer, nextEngine, registered) { + setScene(nextEngine, registered) { handler?.destroy(); handler = null; - viewer = nextViewer; engine = nextEngine; if (registered) targets = registered; - if (!panel || (!engine && !viewer)) return; - container = engine?.host?.container ?? viewer?.container ?? container; + if (!panel || !engine) return; + container = engine.host?.container ?? container; update({ container }); bindInput(panel.owner); }, diff --git a/src/scenes/interactionsView.test.mjs b/src/scenes/interactionsView.test.mjs index 089459b5d..1ec3e7d66 100644 --- a/src/scenes/interactionsView.test.mjs +++ b/src/scenes/interactionsView.test.mjs @@ -1,5 +1,5 @@ // src/scenes/interactionsView.test.mjs — the Scene actions panel renders in -// the viewer's container from what the scene interactions owner presents. +// the engine's container from what the scene interactions owner presents. import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -8,6 +8,7 @@ import { createPinia } from 'pinia'; import { useSceneInteractionsStore } from '../ui/stores/sceneInteractions.js'; import SceneInteractions from '../ui/views/panels/SceneInteractions.vue'; import { createSceneInteractions } from './interactions.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; const target = (featureId) => ({ packId: 'qa', featureId }); const key = (featureId) => JSON.stringify(['qa', featureId]); @@ -36,25 +37,25 @@ function fixture(t, { execute = async () => true } = {}) { const app = createApp(SceneInteractions).use(pinia); app.mount(document.body.appendChild(document.createElement('div'))); const executed = []; - const interactions = createSceneInteractions( - { container, scene: { canvas, pick: () => null } }, - { - execute: (action, signal) => { - executed.push(action.type); - return execute(action, signal); - }, - store: useSceneInteractionsStore(pinia), + const engine = createFakeEngine({ container, canvas }); + const interactions = createSceneInteractions({ + engine, + execute: (action, signal) => { + executed.push(action.type); + return execute(action, signal); }, - ); + store: useSceneInteractionsStore(pinia), + }); t.after(() => { interactions.destroy(); app.unmount(); + engine.destroy(); }); const $ = (selector) => container.querySelector(selector); const settle = async () => { for (let flush = 0; flush < 4; flush += 1) await nextTick(); }; - return { interactions, container, executed, $, settle, pinia }; + return { interactions, engine, container, executed, $, settle, pinia }; } test('a shot with actions shows its panel in the viewer container', async (t) => { @@ -175,11 +176,12 @@ test('a button reused before the next render still belongs to its own panel', as }); test("an earlier owner leaves a later owner's panel alone", async (t) => { - const { interactions, container, $, settle, pinia } = fixture(t); - const later = createSceneInteractions( - { container, scene: { canvas: $('canvas'), pick: () => null } }, - { execute: async () => true, store: useSceneInteractionsStore(pinia) }, - ); + const { interactions, engine, $, settle, pinia } = fixture(t); + const later = createSceneInteractions({ + engine, + execute: async () => true, + store: useSceneInteractionsStore(pinia), + }); t.after(() => later.destroy()); interactions.activate(SHOT, new Map([[key('point'), {}]])); later.activate(SHOT, new Map([[key('point'), {}]])); @@ -190,3 +192,28 @@ test("an earlier owner leaves a later owner's panel alone", async (t) => { await settle(); assert.equal(later.getState().selected, 'fly'); }); + +test('a click on a feature of the shot outlines its actions through engine.input', async (t) => { + const { interactions, engine, $, settle } = fixture(t); + const feature = { kind: 'pack-feature' }; + interactions.activate(SHOT, new Map([[key('point'), feature]])); + await settle(); + engine.fake.script('pick', { id: { kind: 'other' } }); + engine.fake.input('click', { position: { x: 10, y: 10 } }); + await settle(); + assert.deepEqual(interactions.getState().outlined ?? [], []); + assert.equal( + $('[data-director-action="card"]').style.outline, + '', + 'another object selects nothing', + ); + engine.fake.script('pick', { id: feature }); + engine.fake.input('click', { position: { x: 10, y: 10 } }); + await settle(); + assert.equal( + $('[role=status]').textContent, + 'Selected feature: point. Choose an action.', + ); + assert.ok($('[data-director-action="card"]').style.outline); + assert.ok($('[data-director-action="fly"]').style.outline); +}); diff --git a/src/services/groundSnap.js b/src/services/groundSnap.js index 3a0fb35fe..08ca74bd5 100644 --- a/src/services/groundSnap.js +++ b/src/services/groundSnap.js @@ -47,7 +47,6 @@ */ import * as geo from '../geo/index.js'; -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; import { acceptedSurfaceHeight } from '../globe/surfaceDetail.js'; import { MAX_FLOOR_TILE_SIZE_M } from './meshFloorSampler.js'; @@ -155,7 +154,7 @@ export function firstVisibleTileset(primitives) { /** * Create a per-layer ground-snap cache. * @returns {{ - * heightFor: (target: object, icao: string, pos: Cesium.Cartesian3, + * heightFor: (engine: object, icao: string, pos: Cesium.Cartesian3, * getExclusions?: () => Array) => number|null, * forget: (icao: string) => void, * clear: () => void, @@ -266,9 +265,8 @@ export function createGroundSnap({ groundFloor }) { * A contact that HAS resolved once keeps that answer through a resample outage; * see `heldSnapM`. * - * @param {object} target - The globe engine (`engine.surface`: - * sampleHeightDetailed and tilesLoaded), or a viewer whose engine is - * registered (engineFromViewer); without one, no sample. + * @param {object} engine - The globe engine (`engine.surface`: + * sampleHeightDetailed and tilesLoaded); without one, no sample. * @param {string} icao - Cache key (per-layer icao space). * @param {Cesium.Cartesian3} pos - Current display position. Cache movement * is measured after projecting this input to the WGS84 ellipsoid, so a @@ -279,8 +277,7 @@ export function createGroundSnap({ groundFloor }) { * lands on its icon/model otherwise). Only invoked when a sample fires. * @returns {number|null} */ - function heightFor(target, icao, pos, getExclusions) { - const engine = engineFromViewer(target); + function heightFor(engine, icao, pos, getExclusions) { const surfacePos = geo.ellipsoid.WGS84.scaleToGeodeticSurface( pos, scratchSurfacePos, diff --git a/src/services/groundSnap.test.mjs b/src/services/groundSnap.test.mjs index 1e2868c5d..f18bec803 100644 --- a/src/services/groundSnap.test.mjs +++ b/src/services/groundSnap.test.mjs @@ -52,9 +52,7 @@ test('on a globe engine loading tiles or a miss return null without a sample cac assert.equal(engine.fake.callsOf('surface.sampleHeightDetailed').length, 1); }); -test('a coarse tile answer or only terrain is a miss; a viewer reaches its engine', async () => { - const { registerViewerEngine } = - await import('../globe/cesium/engineFromViewer.js'); +test('a coarse tile answer or only terrain is a miss', async () => { const snap = createGroundSnap({ groundFloor: groundFloor() }); const engine = createFakeEngine(); engine.fake.script('sampleHeightDetailed', () => ({ @@ -73,14 +71,8 @@ test('a coarse tile answer or only terrain is a miss; a viewer reaches its engin source: 'tiles', tileSizeM: 241, })); - const viewer = {}; - const unregister = registerViewerEngine(viewer, engine); - try { - assert.equal(snap.heightFor(viewer, 'd3', KAUS), 122.5); - } finally { - unregister(); - } - assert.equal(snap.heightFor({}, 'd4', KAUS), null, 'no engine: no sample'); + assert.equal(snap.heightFor(engine, 'd3', KAUS), 122.5); + assert.equal(snap.heightFor(null, 'd4', KAUS), null, 'no engine: no sample'); }); const atDenverAirport = geo.cartesian3.fromDegrees(-104.6737, 39.8561, 0); diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js index 0fbf11822..f462e9be0 100644 --- a/src/services/meshFloorSampler.js +++ b/src/services/meshFloorSampler.js @@ -1,5 +1,4 @@ import * as geo from '../geo/index.js'; -import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; import { acceptedSurfaceHeight } from '../globe/surfaceDetail.js'; /** Answers from tiles larger than this are too coarse to latch (m). @@ -86,9 +85,8 @@ export function createMeshFloorSampler({ * layer poll (never per contact, never per frame). Synchronous — Cesium's * sampleHeight is a CPU-side query against loaded tiles. * - * @param {object|undefined} target - The globe engine (engine.surface, - * camera.height, maps.photorealActive), or a viewer or scene whose - * engine is registered (engineFromViewer); without one, nothing. + * @param {object|undefined} engine - The globe engine (engine.surface, + * camera.height, maps.photorealActive); without one, nothing. * @param {Array<{lat: number, lon: number}>} points - Contact/waypoint coords. * @param {object} [options] * @param {Array} [options.excludeObjects] - What the probe passes @@ -98,12 +96,11 @@ export function createMeshFloorSampler({ * Viewer subpoint (computed once by the caller's poll). */ function sampleMeshFloorCells( - target, + engine, points, { excludeObjects = [], viewerLat, viewerLon } = {}, ) { if (!meshFloorPreferred()) return; - const engine = engineFromViewer(target); if (!engine || engine.isDestroyed?.() || !engine.surface.supported) return; if (!Array.isArray(points) || !points.length) return; // Round-5 hardening (the CCTV projectionTilesReady lesson, ported late): diff --git a/src/testSupport/aircraftViewerRenderer.mjs b/src/testSupport/aircraftViewerRenderer.mjs index 2fbe95f43..69acc474d 100644 --- a/src/testSupport/aircraftViewerRenderer.mjs +++ b/src/testSupport/aircraftViewerRenderer.mjs @@ -74,11 +74,13 @@ export function aircraftViewerRenderer({ get scene() { return viewer?.scene; }, + // The engine registered for the viewer (the surface samplers take an + // engine). get surfaceOwner() { - return viewer; + return engine ?? engineFromViewer(viewer); }, get surfaceScene() { - return viewer?.scene; + return engine ?? engineFromViewer(viewer); }, frameNumber: () => viewer?.scene?.frameState?.frameNumber ?? -1, viewportHeightPx: () => viewer.scene.canvas.clientHeight, diff --git a/src/testSupport/viewerEngine.mjs b/src/testSupport/viewerEngine.mjs index 3f17cd558..6ae91b4c3 100644 --- a/src/testSupport/viewerEngine.mjs +++ b/src/testSupport/viewerEngine.mjs @@ -36,3 +36,19 @@ export function trackingEngine(viewer) { }); return engine; } + +/** + * A globe engine with only the camera of a viewer test double: its pose at + * the double's positionCartographic and its view rectangle. It has no canvas + * (screen picks miss) and no surface (ground heights are 0). + */ +export function cameraOnlyEngine(viewer) { + const camera = viewer?.camera; + return { + camera: { + pose: () => ({ cartographic: camera?.positionCartographic }), + viewRectangle: () => camera?.computeViewRectangle?.(), + }, + host: { size: () => ({ width: 0, height: 0 }) }, + }; +} diff --git a/src/ui/imageryBoxNavara.js b/src/ui/imageryBoxNavara.js deleted file mode 100644 index da80d19fb..000000000 --- a/src/ui/imageryBoxNavara.js +++ /dev/null @@ -1,159 +0,0 @@ -import * as geo from '../geo/index.js'; -import { - claimPointer, - pointerOwner, - releasePointer, -} from '../data/inputOwnership.js'; -import { validateBox } from '../layers/recentImagery/model.js'; - -/** Native DOM capture keeps a box drag out of Navara's camera controller. */ -export function initNavaraImageryBoxTool({ - engine, - onBox, - onPreview, - onCancel, - onActive, - onEscape, - documentRef = globalThis.document, -}) { - const canvas = engine.host.canvas; - let active = false, - destroyed = false, - anchor = null, - corner = null; - let lease = null, - thaw = null, - pointerId = null; - const box = () => - anchor && { - west: Math.min(anchor.lon, corner.lon), - east: Math.max(anchor.lon, corner.lon), - south: Math.min(anchor.lat, corner.lat), - north: Math.max(anchor.lat, corner.lat), - }; - function point(event) { - const rect = canvas.getBoundingClientRect(), - at = { x: event.clientX - rect.left, y: event.clientY - rect.top }; - const hit = engine.picker.pickPosition(at) || engine.picker.pickGlobe(at); - const c = hit && geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84); - return c - ? { - lon: (c.longitude * 180) / Math.PI, - lat: (c.latitude * 180) / Math.PI, - } - : null; - } - const consume = (event) => { - event.preventDefault?.(); - event.stopImmediatePropagation?.(); - }; - function endDrag() { - anchor = corner = null; - if (pointerId !== null && canvas.hasPointerCapture?.(pointerId)) - canvas.releasePointerCapture(pointerId); - pointerId = null; - thaw?.(); - thaw = null; - onPreview(null); - } - function down(event) { - if ( - !active || - anchor || - event.button !== 0 || - event.shiftKey || - event.ctrlKey || - event.altKey - ) - return; - consume(event); - const p = point(event); - if (!p) { - onCancel?.('sky', 'Press on the ground, not the sky'); - return; - } - anchor = corner = p; - pointerId = event.pointerId; - thaw = engine.input.suspendCameraInputs(); - canvas.setPointerCapture?.(pointerId); - onPreview(box()); - } - function move(event) { - if (!anchor || event.pointerId !== pointerId) return; - consume(event); - corner = point(event) || corner; - onPreview(box()); - } - function up(event) { - if (!anchor || event.pointerId !== pointerId) return; - consume(event); - corner = point(event) || corner; - const selected = box(), - result = validateBox(selected); - if (result.reason === 'degenerate') result.message = 'Drag to size the box'; - stop(); - if (result.ok) onBox(result.box); - else onCancel?.(result.reason, result.message, selected); - } - function key(event) { - if (!active || event.key !== 'Escape') return; - consume(event); - if (onEscape?.() !== true) cancel('escape'); - } - function pointerCancel(event) { - if (anchor && event.pointerId === pointerId) cancel('pointer-cancel'); - } - const bindings = [ - [canvas, 'pointerdown', down], - [documentRef, 'pointermove', move], - [documentRef, 'pointerup', up], - [documentRef, 'pointercancel', pointerCancel], - [documentRef, 'keydown', key], - ]; - if (documentRef.defaultView) - bindings.push([documentRef.defaultView, 'blur', () => cancel('blur')]); - function stop() { - if (!active) return; - for (const [target, type, listener] of bindings) - target.removeEventListener(type, listener, true); - endDrag(); - releasePointer(lease); - lease = null; - active = false; - documentRef.body?.classList.remove('gev-imagery-box'); - onActive?.(false); - } - function cancel(reason = 'cancel') { - if (active) { - stop(); - onCancel?.(reason); - } - } - return { - start() { - if (destroyed) return false; - if (active) return true; - lease = claimPointer('recent-imagery-box'); - if (!lease) { - onCancel?.( - 'pointer-busy', - `${pointerOwner()} is using the pointer — close it first`, - ); - return false; - } - active = true; - for (const [target, type, listener] of bindings) - target.addEventListener(type, listener, true); - documentRef.body?.classList.add('gev-imagery-box'); - onActive?.(true); - return true; - }, - cancel, - isActive: () => active, - destroy() { - if (destroyed) return; - stop(); - destroyed = true; - }, - }; -} diff --git a/src/ui/imageryBoxNavara.test.mjs b/src/ui/imageryBoxNavara.test.mjs deleted file mode 100644 index f0b8044cf..000000000 --- a/src/ui/imageryBoxNavara.test.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import * as geo from '../geo/index.js'; -import { pointerOwner } from '../data/inputOwnership.js'; -import { initNavaraImageryBoxTool } from './imageryBoxNavara.js'; - -test('blur and destruction during a Navara box drag return input and pointer ownership', () => { - const target = () => { - const listeners = new Map(); - return { - listeners, - addEventListener(type, fn) { - listeners.set(type, fn); - }, - removeEventListener(type) { - listeners.delete(type); - }, - }; - }; - const windowRef = target(), - documentRef = { - ...target(), - defaultView: windowRef, - body: { classList: { add() {}, remove() {} } }, - }; - const canvas = { - ...target(), - getBoundingClientRect: () => ({ left: 0, top: 0 }), - setPointerCapture() {}, - hasPointerCapture: () => true, - releasePointerCapture() {}, - }; - let suspended = 0, - preview = null; - const engine = { - host: { canvas }, - picker: { pickPosition: () => geo.cartesian3.fromDegrees(10, 20, 0) }, - input: { - suspendCameraInputs() { - suspended++; - return () => suspended--; - }, - }, - }; - const tool = initNavaraImageryBoxTool({ - engine, - documentRef, - onBox() {}, - onPreview: (b) => (preview = b), - }); - const down = () => - canvas.listeners.get('pointerdown')({ - button: 0, - pointerId: 1, - clientX: 10, - clientY: 20, - preventDefault() {}, - stopImmediatePropagation() {}, - }); - try { - assert.equal(tool.start(), true); - down(); - assert.equal(suspended, 1); - assert.ok(preview); - windowRef.listeners.get('blur')(); - assert.equal(suspended, 0); - assert.equal(preview, null); - assert.equal(pointerOwner(), null); - assert.equal(tool.isActive(), false); - tool.start(); - down(); - tool.destroy(); - tool.destroy(); - assert.equal(suspended, 0); - assert.equal(pointerOwner(), null); - assert.equal(canvas.listeners.size, 0); - assert.equal(documentRef.listeners.size, 0); - assert.equal(windowRef.listeners.size, 0); - } finally { - tool.destroy(); - } -}); diff --git a/src/ui/imageryBoxTool.js b/src/ui/imageryBoxTool.js index 5bd58a414..50798932d 100644 --- a/src/ui/imageryBoxTool.js +++ b/src/ui/imageryBoxTool.js @@ -1,11 +1,16 @@ /** * Box selection for the Recent Imagery layer: press on the world, drag, and * release to hand the layer a degrees box. Follows the draw tool's ownership - * rules — the pointer is claimed for the session, the viewer's own click - * actions are borrowed and given back, and `destroy()` returns everything — - * and freezes the camera only while a drag is in progress. + * rules — the pointer is claimed for the session and `destroy()` returns + * everything — and freezes the camera only while a drag is in progress. + * + * The same on every engine: the tool takes the pointer events of the + * engine's canvas in the capture phase, ahead of the engine's own handlers, + * so a press that starts a box and the drag it makes reach neither the + * camera nor any click (a layer's, or Cesium's stock select and track). + * Moves outside a drag pass through. The layer draws the preview + * (`onPreview`, a ground cell on engine.ground). */ -import * as Cesium from 'cesium'; import { pickWorldFromScreen } from '../annotations/annotationResolver.js'; import { claimPointer, @@ -18,35 +23,51 @@ import { msg } from '../i18n/index.js'; /** The id this tool claims the pointer under. */ export const IMAGERY_BOX_POINTER_OWNER = 'recent-imagery-box'; +/** + * The camera held still until the returned release: the engine's counted + * suspension where it has one (Navara keeps the app's own setCameraInputs + * choice under it), else camera inputs off and on again. + */ +function freezeCamera(input) { + if (typeof input.suspendCameraInputs === 'function') + return input.suspendCameraInputs(); + input.setCameraInputs(false); + return () => input.setCameraInputs(true); +} + /** * Wire the box tool. - * @param {{ viewer: object, onBox: (box: object) => void, onCancel?: (reason: string, message?: string, box?: object) => void, onActive?: (active: boolean) => void, onEscape?: () => boolean, cesium?: object, pickWorld?: Function, documentRef?: object }} deps - * `onEscape` runs before the tool cancels on Escape; returning `true` claims - * the key (it is still consumed) and leaves the tool armed. - * @returns {{ start: () => boolean, cancel: (reason?: string) => void, isActive: () => boolean, destroy: () => Promise } | null} + * @param {{ engine: import('../globe/contract.js').GlobeEngine, + * onBox: (box: object) => void, onPreview?: (box: object | null) => void, + * onCancel?: (reason: string, message?: string, box?: object) => void, + * onActive?: (active: boolean) => void, onEscape?: () => boolean, + * pickWorld?: Function, documentRef?: object }} deps + * `onPreview` gets the box being dragged, and null once the drag ends. + * `onEscape` runs before the tool cancels on Escape; returning `true` + * claims the key (it is still consumed) and leaves the tool armed. + * @returns {{ start: () => boolean, cancel: (reason?: string) => void, isActive: () => boolean, destroy: () => void } | null} */ export function initImageryBoxTool({ - viewer, + engine, onBox, + onPreview = () => {}, onCancel, onActive, onEscape, - cesium = Cesium, pickWorld = pickWorldFromScreen, documentRef = globalThis.document, } = {}) { - if (!viewer?.scene || typeof onBox !== 'function') return null; + const canvas = engine?.host?.canvas; + if (!canvas || typeof onBox !== 'function') return null; let active = false; let destroyed = false; /** Drag corners in degrees; `anchor` is null while no drag is live. */ let anchor = null; let corner = null; - let handler = null; let lease = null; - let savedSingleClick = null; - let savedDoubleClick = null; - let savedCameraInputs = null; + let thaw = null; + let pointerId = null; const dragBox = () => anchor && { @@ -55,241 +76,144 @@ export function initImageryBoxTool({ east: Math.max(anchor.lon, corner.lon), north: Math.max(anchor.lat, corner.lat), }; - - const dataSource = new cesium.CustomDataSource( - 'gev-recent-imagery-box-preview', - ); - let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch( - () => null, - ); - const stroke = cesium.Color.fromCssColorString('#8be9ff'); - const previewRectangle = () => { - const box = dragBox(); - return box - ? cesium.Rectangle.fromDegrees(box.west, box.south, box.east, box.north) - : undefined; - }; - const previewOutline = () => { - const box = dragBox(); - if (!box) return []; - return cesium.Cartesian3.fromDegreesArray([ - box.west, - box.south, - box.east, - box.south, - box.east, - box.north, - box.west, - box.north, - box.west, - box.south, - ]); - }; - const fill = dataSource.entities.add({ - show: false, - rectangle: { - coordinates: new cesium.CallbackProperty(previewRectangle, false), - material: stroke.withAlpha(0.12), - classificationType: cesium.ClassificationType.BOTH, - }, - }); - // A rectangle graphic cannot draw a dashed edge on the ground, so the - // outline is its own draped polyline traced round the same corners. - const outline = dataSource.entities.add({ - show: false, - polyline: { - positions: new cesium.CallbackProperty(previewOutline, false), - width: 2, - clampToGround: true, - classificationType: cesium.ClassificationType.BOTH, - material: new cesium.PolylineDashMaterialProperty({ - color: stroke.withAlpha(0.95), - dashLength: 12, - }), - }, - }); - - const requestRender = () => viewer.scene.requestRender?.(); - const setPreview = (visible) => { - fill.show = visible; - outline.show = visible; - requestRender(); - }; - const worldAt = (position) => { - if (!position) return null; - const canvas = viewer.scene.canvas; - const w = canvas?.clientWidth || canvas?.width || 1; - const h = canvas?.clientHeight || canvas?.height || 1; - const point = pickWorld(viewer, position.x / w, position.y / h); + const worldAt = (event) => { + const rect = canvas.getBoundingClientRect(); + const { width, height } = engine.host.size(); + const point = pickWorld( + engine, + (event.clientX - rect.left) / (width || 1), + (event.clientY - rect.top) / (height || 1), + ); const lon = Number(point?.lon); const lat = Number(point?.lat); return Number.isFinite(lon) && Number.isFinite(lat) ? { lon, lat } : null; }; - const cameraController = () => viewer.scene.screenSpaceCameraController; - - function freezeCamera() { - const controller = cameraController(); - if (!controller || savedCameraInputs !== null) return; - savedCameraInputs = controller.enableInputs; - controller.enableInputs = false; - } - function thawCamera() { - const controller = cameraController(); - if (controller && savedCameraInputs !== null) - controller.enableInputs = savedCameraInputs; - savedCameraInputs = null; - } + const consume = (event) => { + event.preventDefault?.(); + event.stopImmediatePropagation?.(); + }; function endDrag() { anchor = null; corner = null; - thawCamera(); - setPreview(false); + if (pointerId !== null && canvas.hasPointerCapture?.(pointerId)) + canvas.releasePointerCapture(pointerId); + pointerId = null; + thaw?.(); + thaw = null; + onPreview(null); } - const onDown = (event) => { - if (!active || destroyed || anchor) return; - const point = worldAt(event.position); + function down(event) { + if ( + !active || + anchor || + event.button !== 0 || + event.shiftKey || + event.ctrlKey || + event.altKey + ) + return; + consume(event); + const point = worldAt(event); if (!point) { onCancel?.('sky', msg('Press on the ground, not the sky')); return; } anchor = point; corner = point; - freezeCamera(); - setPreview(true); - }; - const onMove = (event) => { - const point = anchor && !destroyed && worldAt(event.endPosition); - if (!point) return; - corner = point; - requestRender(); - }; - const onUp = (event) => { - if (!anchor || destroyed) return; - corner = worldAt(event.position) || corner; + pointerId = event.pointerId; + thaw = freezeCamera(engine.input); + canvas.setPointerCapture?.(pointerId); + onPreview(dragBox()); + } + function move(event) { + if (!anchor || event.pointerId !== pointerId) return; + consume(event); + corner = worldAt(event) || corner; + onPreview(dragBox()); + } + function up(event) { + if (!anchor || event.pointerId !== pointerId) return; + consume(event); + corner = worldAt(event) || corner; const box = dragBox(); const result = validateBox(box); if (result.reason === 'degenerate') result.message = msg('Drag to size the box'); - endDrag(); - setActive(false); + stop(); if (result.ok) onBox(result.box); else onCancel?.(result.reason, result.message, box); - }; + } // Escape is taken from a document capture listener, ahead of every other // handler, so the interceptor runs first: when it claims the key (the // panel exits a live comparison), the tool stays armed and the key still // goes no further; otherwise Escape cancels the session. - const onKey = (event) => { - if (!active || destroyed || event.key !== 'Escape') return; - event.preventDefault?.(); - event.stopImmediatePropagation?.(); - if (onEscape?.() === true) return; - cancel('escape'); - }; - - function bindScene() { - if (handler) return; - handler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - handler.setInputAction(onDown, cesium.ScreenSpaceEventType.LEFT_DOWN); - handler.setInputAction(onMove, cesium.ScreenSpaceEventType.MOUSE_MOVE); - handler.setInputAction(onUp, cesium.ScreenSpaceEventType.LEFT_UP); - const stock = viewer.screenSpaceEventHandler; - if (stock) { - 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); - } - documentRef?.addEventListener?.('keydown', onKey, true); - documentRef?.body?.classList?.add?.('gev-imagery-box'); + function key(event) { + if (!active || event.key !== 'Escape') return; + consume(event); + if (onEscape?.() !== true) cancel('escape'); } + // A drag the browser takes away (a touch turned into a scroll, the window + // losing focus) ends the session and gives the camera back. + function pointerCancel(event) { + if (anchor && event.pointerId === pointerId) cancel('pointer-cancel'); + } + const bindings = [ + [canvas, 'pointerdown', down], + [documentRef, 'pointermove', move], + [documentRef, 'pointerup', up], + [documentRef, 'pointercancel', pointerCancel], + [documentRef, 'keydown', key], + ]; + if (documentRef?.defaultView) + bindings.push([documentRef.defaultView, 'blur', () => cancel('blur')]); - function releaseScene() { - if (handler) { - handler.destroy(); - handler = null; - } - const stock = viewer.screenSpaceEventHandler; - if (stock && savedSingleClick) { - stock.setInputAction( - savedSingleClick, - cesium.ScreenSpaceEventType.LEFT_CLICK, - ); - } - if (stock && savedDoubleClick) { - stock.setInputAction( - savedDoubleClick, - cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, - ); - } - savedSingleClick = null; - savedDoubleClick = null; - documentRef?.removeEventListener?.('keydown', onKey, true); + function stop() { + if (!active) return; + for (const [target, type, listener] of bindings) + target?.removeEventListener?.(type, listener, true); + endDrag(); + releasePointer(lease); + lease = null; + active = false; documentRef?.body?.classList?.remove?.('gev-imagery-box'); - } - - function setActive(next) { - if (next === active) return active; - if (next) { - lease = claimPointer(IMAGERY_BOX_POINTER_OWNER); - if (!lease) { - onCancel?.( - 'pointer-busy', - `${pointerOwner()} is using the pointer — close it first`, - ); - return active; - } - active = true; - bindScene(); - } else { - if (anchor) endDrag(); - releaseScene(); - releasePointer(lease); - lease = null; - active = false; - } - onActive?.(active); - return active; + onActive?.(false); } function cancel(reason = 'cancel') { if (!active || destroyed) return; - setActive(false); + stop(); onCancel?.(reason); } return { /** Begin a box session. False when the pointer is held elsewhere. */ start() { - return !destroyed && setActive(true); + if (destroyed) return false; + if (active) return true; + lease = claimPointer(IMAGERY_BOX_POINTER_OWNER); + if (!lease) { + onCancel?.( + 'pointer-busy', + `${pointerOwner()} is using the pointer — close it first`, + ); + return false; + } + active = true; + for (const [target, type, listener] of bindings) + target?.addEventListener?.(type, listener, true); + documentRef?.body?.classList?.add?.('gev-imagery-box'); + onActive?.(true); + return true; }, cancel, - isActive() { - return active; - }, - /** Returns everything; resolves once the preview is detached. */ + isActive: () => active, + /** Returns the pointer, the camera and every listener. */ destroy() { - if (destroyed) return attaching; - if (active) setActive(false); + if (destroyed) return; + stop(); destroyed = true; - releaseScene(); - releasePointer(lease); - lease = null; - thawCamera(); - dataSource.entities.removeAll(); - attaching = attaching.then(() => { - try { - viewer.dataSources.remove(dataSource, true); - } catch { - /* viewer already disposed */ - } - }); - return attaching; }, }; } diff --git a/src/ui/imageryBoxTool.test.mjs b/src/ui/imageryBoxTool.test.mjs index e84938ed7..1258e0407 100644 --- a/src/ui/imageryBoxTool.test.mjs +++ b/src/ui/imageryBoxTool.test.mjs @@ -12,53 +12,50 @@ import { } from './imageryBoxTool.js'; import { boxToolFakes } from '../layers/recentImagery/testDoubles.mjs'; -function fixture(options = {}) { +function fixture(options = {}, fakeOptions = {}) { resetPointerOwnership(); - const fakes = boxToolFakes(); + const fakes = boxToolFakes(fakeOptions); const boxes = []; const cancels = []; const actives = []; + const previews = []; const tool = initImageryBoxTool({ - viewer: fakes.viewer, - cesium: fakes.cesium, + engine: fakes.engine, pickWorld: fakes.pickWorld, documentRef: fakes.documentRef, onBox: (box) => boxes.push(box), + onPreview: (box) => previews.push(box), onCancel: (reason, message) => cancels.push([reason, message]), onActive: (active) => actives.push(active), ...options, }); - const camera = fakes.viewer.scene.screenSpaceCameraController; - const stockAction = (type) => - fakes.viewer.screenSpaceEventHandler.getInputAction(fakes.types[type]); - const preview = () => - fakes.viewer.dataSources.sources[0]?.entities.values.every((e) => e.show); - return { - ...fakes, - tool, - boxes, - cancels, - actives, - camera, - stockAction, - preview, - }; + return { ...fakes, tool, boxes, cancels, actives, previews }; } -test('a session claims the pointer, borrows the stock clicks, freezes the camera only mid-drag and returns everything with the box', async () => { +const listenerCount = (f) => + f.canvas.listeners.length + + f.documentRef.listeners.length + + f.windowRef.listeners.length; + +test('a session claims the pointer, keeps the drag from the engine, freezes the camera only mid-drag and returns everything with the box', () => { const f = fixture(); assert.equal(f.tool.start(), true); assert.equal(pointerOwner(), IMAGERY_BOX_POINTER_OWNER); - assert.equal(f.stockAction('LEFT_CLICK'), undefined); - assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), undefined); assert.ok(f.documentRef.classes.has('gev-imagery-box')); - assert.equal(f.camera.enableInputs, true, 'not frozen before the press'); - f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } }); - assert.equal(f.camera.enableInputs, false); - assert.equal(f.preview(), true); - f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 250 } }); - f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 10 } }); // sky: ignored - f.fire('LEFT_UP', { position: { x: 500, y: 250 } }); + assert.equal(f.camera.inputs, true, 'not frozen before the press'); + const press = f.down({ x: 250, y: 125 }); + assert.equal( + press.prevented && press.stopped, + true, + 'the press reaches no other handler (camera, clicks)', + ); + assert.equal(f.camera.inputs, false); + assert.ok(f.captured.has(1), 'the drag holds the pointer capture'); + assert.ok(f.previews.at(-1), 'the preview shows'); + const drag = f.move({ x: 500, y: 250 }); + assert.equal(drag.stopped, true); + f.move({ x: 500, y: 10 }); // sky: the corner stays + f.up({ x: 500, y: 250 }); assert.equal(f.boxes.length, 1); const { west, south, east, north } = f.boxes[0]; assert.deepEqual( @@ -68,37 +65,49 @@ test('a session claims the pointer, borrows the stock clicks, freezes the camera assert.deepEqual(f.cancels, []); assert.equal(f.tool.isActive(), false); assert.equal(pointerOwner(), null); - assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick); - assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), f.originalDouble); - assert.equal(f.camera.enableInputs, true); - assert.equal(f.preview(), false); + assert.equal(f.camera.inputs, true); + assert.equal(f.captured.size, 0); + assert.equal(f.previews.at(-1), null, 'the preview goes with the drag'); assert.equal(f.documentRef.classes.has('gev-imagery-box'), false); - assert.equal(f.documentRef.listeners.length, 0); - assert.equal(f.handler(), null); + assert.equal(listenerCount(f), 0); assert.deepEqual(f.actives, [true, false]); - await f.tool.destroy(); + f.tool.destroy(); }); -test('a sky press, a click without a drag and Escape each say why', async () => { +test('presses with a modifier or another button, and moves outside a drag, pass through', () => { const f = fixture(); f.tool.start(); - f.fire('LEFT_DOWN', { position: { x: 250, y: 10 } }); + for (const fields of [{ shiftKey: true }, { ctrlKey: true }, { button: 2 }]) { + const press = f.down({ x: 250, y: 125 }, fields); + assert.equal(press.stopped, false, JSON.stringify(fields)); + } + assert.equal(f.camera.inputs, true); + assert.equal(f.move({ x: 300, y: 200 }).stopped, false, 'a hover'); + assert.equal(f.tool.isActive(), true); + f.tool.destroy(); +}); + +test('a sky press, a click without a drag and Escape each say why', () => { + const f = fixture(); + f.tool.start(); + f.down({ x: 250, y: 10 }); assert.deepEqual(f.cancels.at(-1), [ 'sky', 'Press on the ground, not the sky', ]); assert.equal(f.tool.isActive(), true, 'a sky press keeps the session'); - f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } }); - f.fire('LEFT_UP', { position: { x: 250, y: 125 } }); + assert.equal(f.camera.inputs, true, 'and freezes nothing'); + f.down({ x: 250, y: 125 }); + f.up({ x: 250, y: 125 }); assert.deepEqual(f.cancels.at(-1), ['degenerate', 'Drag to size the box']); f.tool.start(); assert.equal(f.key('Escape').prevented, true); assert.deepEqual(f.cancels.at(-1), ['escape', undefined]); assert.equal(pointerOwner(), null); - await f.tool.destroy(); + f.tool.destroy(); }); -test('an Escape interceptor that claims the key leaves the tool armed; the next Escape cancels', async () => { +test('an Escape interceptor that claims the key leaves the tool armed; the next Escape cancels', () => { let claim = true; let asked = 0; const f = fixture({ @@ -122,33 +131,54 @@ test('an Escape interceptor that claims the key leaves the tool armed; the next f.tool.start(); f.key('Enter'); assert.equal(asked, 2, 'other keys never reach the interceptor'); - await f.tool.destroy(); + f.tool.destroy(); }); -test('a held pointer refuses the session and nothing is borrowed', async () => { +test('a held pointer refuses the session and nothing is bound', () => { const f = fixture(); const lease = claimPointer('draw'); assert.equal(f.tool.start(), false); assert.deepEqual(f.cancels, [ ['pointer-busy', 'draw is using the pointer — close it first'], ]); - assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick); - assert.equal(f.handler(), null); + assert.equal(listenerCount(f), 0); releasePointer(lease); assert.equal(f.tool.start(), true); - await f.tool.destroy(); + f.tool.destroy(); }); -test('destroy mid-drag restores the camera, releases the claim and detaches the preview', async () => { +test('destroy mid-drag restores the camera, releases the claim and clears the preview', () => { const f = fixture(); f.tool.start(); - f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } }); - assert.equal(f.viewer.dataSources.sources.length, 1); - await f.tool.destroy(); - assert.equal(f.camera.enableInputs, true); + f.down({ x: 250, y: 125 }); + assert.equal(f.camera.inputs, false); + f.tool.destroy(); + f.tool.destroy(); + assert.equal(f.camera.inputs, true); assert.equal(pointerOwner(), null); - assert.equal(f.viewer.dataSources.sources.length, 0); - assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick); + assert.equal(f.previews.at(-1), null); + assert.equal(listenerCount(f), 0); assert.equal(f.tool.start(), false); - assert.equal(initImageryBoxTool({ viewer: null, onBox() {} }), null); + assert.equal(initImageryBoxTool({ engine: null, onBox() {} }), null); +}); + +test('blur and a cancelled pointer during a drag return the camera, the preview and the pointer', () => { + const f = fixture({}, { suspend: true }); + f.tool.start(); + f.down({ x: 250, y: 125 }); + assert.equal(f.camera.suspended, 1, "the engine's counted suspension"); + assert.equal(f.camera.inputs, true, 'the app choice is left alone'); + f.windowRef.dispatch('blur', {}); + assert.equal(f.camera.suspended, 0); + assert.equal(f.previews.at(-1), null); + assert.equal(pointerOwner(), null); + assert.equal(f.tool.isActive(), false); + assert.deepEqual(f.cancels.at(-1), ['blur', undefined]); + f.tool.start(); + f.down({ x: 250, y: 125 }); + f.documentRef.dispatch('pointercancel', { pointerId: 1 }); + assert.equal(f.camera.suspended, 0); + assert.deepEqual(f.cancels.at(-1), ['pointer-cancel', undefined]); + assert.equal(listenerCount(f), 0); + f.tool.destroy(); }); diff --git a/src/voice/areaActions.js b/src/voice/areaActions.js index c66db7ee1..1aef6a8d8 100644 --- a/src/voice/areaActions.js +++ b/src/voice/areaActions.js @@ -149,13 +149,13 @@ const cancelledResult = () => ({ /** * Build the runner's area services. * @param {object} options - * @param {object} options.viewer + * @param {object} options.engine The globe engine (view centre, credits). * @param {object|null} options.annotations Annotation engine. * @param {object} [options.placeSearch] * @param {object} [options.annotationResolver] The annotation outline ladder. */ export function createVoiceAreas({ - viewer, + engine = null, annotations = null, placeSearch = unavailablePlaceSearch, annotationResolver = null, @@ -173,10 +173,10 @@ export function createVoiceAreas({ /** The ground at the centre of the view, else the camera's position. */ function viewCenter() { - const picked = annotationResolver?.pickWorldFromScreen?.(viewer, 0.5, 0.5); + const picked = annotationResolver?.pickWorldFromScreen?.(engine, 0.5, 0.5); if (Number.isFinite(picked?.lat) && Number.isFinite(picked?.lon)) return picked; - const carto = viewer?.camera?.positionCartographic; + const carto = engine?.camera.pose().cartographic; return carto ? { lat: (carto.latitude * 180) / Math.PI, @@ -223,7 +223,7 @@ export function createVoiceAreas({ try { resolved = await annotationResolver.resolveAnnotationTarget({ placeSearch, - viewer, + engine, target: query, footprint: true, deferFootprint: true, @@ -405,7 +405,7 @@ export function createVoiceAreas({ 'us-census': US_CENSUS_CREDIT, wof: WOF_CREDIT, }[source]; - if (credit) registerDynamicCredit(viewer, credit); + if (credit) registerDynamicCredit(engine, credit); }, }); @@ -632,7 +632,7 @@ export function createVoiceAreas({ // OpenFreeMap area holding it) is a bounded extra. resolved = await annotationResolver.resolveAnnotationTarget({ placeSearch, - viewer, + engine, target, footprint: true, deferFootprint: true, diff --git a/src/voice/areaActions.test.mjs b/src/voice/areaActions.test.mjs index b9a3ff944..ef605aa01 100644 --- a/src/voice/areaActions.test.mjs +++ b/src/voice/areaActions.test.mjs @@ -113,7 +113,6 @@ function fakeAnnotations() { function makeAreas(overrides = {}) { const annotations = overrides.annotations || fakeAnnotations(); const areas = createVoiceAreas({ - viewer: {}, annotations, annotationResolver: fakeLadder(), findRegion: async () => null, @@ -181,7 +180,6 @@ test('draw failures stay honest in the result, spoken line, and card', async (t) for (const entry of cases) { await t.test(entry.name, async () => { const areas = createVoiceAreas({ - viewer: {}, annotations: entry.annotations, annotationResolver: fakeLadder(), findRegion: async () => null, @@ -732,7 +730,6 @@ test('a clear during a slow lookup is not undone by a late outline', async () => release = resolve; }); const areas = createVoiceAreas({ - viewer: {}, annotations, findRegion: async () => null, findAdminCandidates: async () => [], @@ -781,7 +778,6 @@ test('an outlined area keeps its full geometry past store eviction', async () => test('an outline from before a page reload asks to resolve again, never counts its drawing', async () => { const annotations = fakeAnnotations(); const first = createVoiceAreas({ - viewer: {}, annotations, annotationResolver: fakeLadder(), findRegion: async () => null, @@ -792,7 +788,6 @@ test('an outline from before a page reload asks to resolve again, never counts i // outline stays on the map. A mic restart keeps the runner (and handles); // see integrationContract.test.mjs. const second = createVoiceAreas({ - viewer: {}, annotations, findRegion: async () => null, findAdminCandidates: async () => [], diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 9c7e5055f..c54f1f6ab 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -378,7 +378,7 @@ export function createGevActionRunner({ // analyst's region/area/drawn scopes, find_imagery and osm_query all read // the same store. const voiceAreas = createVoiceAreas({ - viewer, + engine, annotations, placeSearch, annotationResolver, @@ -1452,7 +1452,7 @@ export function createGevActionRunner({ runOptions, ), search: searchOsmFeatures, - onCredit: () => registerDynamicCredit(viewer, OSM_PLACES_CREDIT), + onCredit: () => registerDynamicCredit(engine, OSM_PLACES_CREDIT), // The listed places become the analyst's "last answer", so // "which of those…" filters them and never an older set. rememberResults: (rows, presentation, { truncated = false } = {}) => {