diff --git a/src/layers/installations/controls.js b/src/layers/installations/controls.js index 09ce43bf9..130e8839b 100644 --- a/src/layers/installations/controls.js +++ b/src/layers/installations/controls.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { installationFeedback } from '../../data/installationFeedback.js'; import { @@ -39,7 +38,7 @@ export function createControls({ state: layerState, services, parts, source }) { * only area that answers "what is near this contact". The window moves * only after the subject travels ANCHOR_REFRESH_M, and cached tiles make * that cheap. Passing null returns the layer to viewport loading. - * @param {Cesium.Cartesian3|{latitude:number, longitude:number}|null} position + * @param {object|{latitude:number, longitude:number}|null} position * Subject position. * @returns {boolean} Whether a new load was scheduled. */ @@ -145,19 +144,19 @@ export function createControls({ state: layerState, services, parts, source }) { */ focusById(id) { const record = layerState.recordById.get(String(id)); - if (!record || !layerState.viewer) return false; + if (!record || !layerState.engine?.camera?.flyToSphere) return false; // No camera flight without a real selection: a flight plus a stale subject // reads as success to Context navigation and strands NEXT on this item. if (!parts.selection.selectRecord(record.id)) return false; - layerState.viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere( - geo.cartesian3.fromDegrees( + layerState.engine.camera.flyToSphere( + { + center: geo.cartesian3.fromDegrees( record.longitude, record.latitude, parts.rendering.installationSurfaceHeightM(record), ), - 18000, - ), + radius: 18000, + }, { duration: 1.4 }, ); return true; diff --git a/src/layers/installations/controls.test.mjs b/src/layers/installations/controls.test.mjs new file mode 100644 index 000000000..4bfa6de34 --- /dev/null +++ b/src/layers/installations/controls.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../../geo/index.js'; +import { createControls } from './controls.js'; + +test('focus frames a selected installation through the globe engine camera', () => { + const record = { id: 'osm:node:12', latitude: 70, longitude: 179.8 }; + let flight = null; + const state = { + engine: { + camera: { + flyToSphere: (sphere, options) => { + flight = { sphere, options }; + }, + }, + }, + recordById: new Map([[record.id, record]]), + }; + const controls = createControls({ + state, + services: {}, + parts: { + selection: { selectRecord: (id) => id === record.id }, + rendering: { installationSurfaceHeightM: () => 126 }, + }, + source: {}, + }).methods; + + assert.equal(controls.focusById(record.id), true); + assert.deepEqual( + flight.sphere.center, + geo.cartesian3.fromDegrees(179.8, 70, 126), + ); + assert.equal(flight.sphere.radius, 18000); + assert.deepEqual(flight.options, { duration: 1.4 }); +}); diff --git a/src/layers/installations/lifecycle.js b/src/layers/installations/lifecycle.js index cded4ca2f..928645f0e 100644 --- a/src/layers/installations/lifecycle.js +++ b/src/layers/installations/lifecycle.js @@ -15,19 +15,20 @@ export function createLifecycle({ const { clearSelectedEntityContextForLayer } = services.context; const methods = { - init(viewer) { + init(viewer, { engine = null } = {}) { layerState.viewer = viewer; + layerState.engine = engine; layerState.dataSource = new Cesium.CustomDataSource( 'military-installations', ); viewer.dataSources.add(layerState.dataSource); + const camera = engine?.camera ?? viewer.camera; const cameraChanged = () => { if (!layerState.contextAnchor) parts.viewport.scheduleLoad(); }; - layerState.moveEndRemove = - viewer.camera.moveEnd.addEventListener(cameraChanged); + layerState.moveEndRemove = camera.moveEnd.addEventListener(cameraChanged); layerState.changedRemove = - viewer.camera.changed?.addEventListener(cameraChanged); + camera.changed?.addEventListener(cameraChanged); parts.selection.installInteraction(viewer); }, @@ -100,6 +101,7 @@ export function createLifecycle({ if (layerState.dataSource && viewer) viewer.dataSources.remove(layerState.dataSource, true); layerState.dataSource = null; + layerState.engine = null; }, }; diff --git a/src/layers/installations/navigation.test.mjs b/src/layers/installations/navigation.test.mjs index 8467eea02..dd3a4b0fe 100644 --- a/src/layers/installations/navigation.test.mjs +++ b/src/layers/installations/navigation.test.mjs @@ -189,3 +189,45 @@ test('wide views refresh screen decluttering when the globe rectangle stays unch t.mock.timers.tick(200); assert.equal(loads, 2, 'a different continent needs fresh projected labels'); }); + +test('the engine camera drives installation viewport requests without a viewer', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const rectangle = { + west: Cesium.Math.toRadians(179), + south: Cesium.Math.toRadians(-12), + east: Cesium.Math.toRadians(-178), + north: Cesium.Math.toRadians(16), + }; + const state = { + enabled: true, + viewer: null, + engine: { + camera: { + viewRectangle: () => rectangle, + pose: () => ({ + position: { x: 1, y: 2, z: 3 }, + direction: { x: -1, y: 0, z: 0 }, + }), + }, + }, + }; + const loads = []; + const parts = { + ingestion: { + loadInstallations() { + loads.push(parts.viewport.loadArea(null)); + }, + }, + }; + parts.viewport = createViewport({ state, parts, services: {}, source: {} }); + + parts.viewport.scheduleLoad(); + t.mock.timers.tick(200); + + assert.equal(loads.length, 1); + assert.ok(Math.abs(loads[0].box.south + 12) < 1e-10); + assert.equal(loads[0].box.west, 179); + assert.equal(loads[0].box.north, 16); + assert.equal(loads[0].box.east, -178); + assert.equal(loads[0].coverage.kind, 'viewport'); +}); diff --git a/src/layers/installations/state.js b/src/layers/installations/state.js index d07ce006c..82465cb68 100644 --- a/src/layers/installations/state.js +++ b/src/layers/installations/state.js @@ -9,6 +9,7 @@ export function createState({ services }) { Object.assign(state, { viewer: null, + engine: null, dataSource: null, enabled: false, records: [], diff --git a/src/layers/installations/viewport.js b/src/layers/installations/viewport.js index 7667152a6..709627d1d 100644 --- a/src/layers/installations/viewport.js +++ b/src/layers/installations/viewport.js @@ -23,9 +23,9 @@ export function createViewport({ state: layerState, services, parts, source }) { } function viewportBox(viewer) { - const rectangle = viewer?.camera?.computeViewRectangle( - viewer.scene.globe.ellipsoid, - ); + const rectangle = + layerState.engine?.camera?.viewRectangle?.() ?? + viewer?.camera?.computeViewRectangle(viewer.scene.globe.ellipsoid); if (!rectangle) return null; const south = geo.math.toDegrees(rectangle.south); const north = geo.math.toDegrees(rectangle.north); @@ -89,14 +89,17 @@ export function createViewport({ state: layerState, services, parts, source }) { function scheduleLoad() { if (!layerState.enabled) return; const { box, coverage } = loadArea(layerState.viewer); - const camera = layerState.viewer.camera; + const pose = layerState.engine?.camera?.pose?.(); + const camera = layerState.viewer?.camera; + const position = pose?.position ?? camera?.positionWC; + const direction = pose?.direction ?? camera?.directionWC; const widePose = coverage.kind === 'viewport' && box && (box.east < box.west || box.east - box.west > 10 || box.north - box.south > 10) - ? [camera.positionWC, camera.directionWC] + ? [position, direction] .map((v) => v ? [v.x, v.y, v.z].map((n) => n.toFixed(4)).join(',') : '', )