diff --git a/src/app/scene.js b/src/app/scene.js index 524e1804e..9de1ccf6c 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -105,6 +105,8 @@ export async function createApplicationScene({ const mapStackController = new MapController(viewer, { requestRender: governorRequestRender, + // Map sources reach the scene through the globe engine's maps. + engine, ...mapOptions, googleTileset: tileset, cesiumToken, diff --git a/src/mapStackController.test.mjs b/src/mapStackController.test.mjs new file mode 100644 index 000000000..417577fd1 --- /dev/null +++ b/src/mapStackController.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { MapStackController } from './mapStackController.js'; +import { createFakeEngine } from './globe/testing/fakeEngine.js'; + +test('the map stack controller applies its stacks through a given globe engine', async () => { + const engine = createFakeEngine(); + const tileset = { show: true }; + const controller = new MapStackController(null, { + engine, + googleTileset: tileset, + }); + await controller.setStack('photoreal'); + assert.equal( + engine.fake.callsOf('maps.setPhotorealTiles')[0].args[0].tileset, + tileset, + ); + await controller.setStack('osm'); + const [imagery] = engine.fake.callsOf('maps.setBaseImagery')[0].args; + assert.equal(imagery.descriptor.id, 'osm'); + assert.ok(imagery.provider, 'a ready provider the controller built'); + assert.equal(engine.fake.callsOf('maps.setPhotorealTiles')[1].args[0], null); + controller.destroy(); +}); diff --git a/src/maps/controller.js b/src/maps/controller.js index 7664442bc..470284155 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -10,7 +10,18 @@ import { acquireImageryComparison } from './imageryComparison.js'; */ const AUTOMATIC = Symbol('automatic switch'); -/** Coordinate source lifetimes and scene changes; the registry owns provider choices. */ +/** + * Coordinate source lifetimes and scene changes; the registry owns provider + * choices. + * + * With `engine` (the page's globe engine) every scene change goes through + * `engine.maps`: the controller keeps resolving, caching and watching + * providers and tilesets, and hands the engine ready sources. Supplied + * tilesets must then be ones the engine knows (its startup tileset) or has + * shown. On destroy the engine keeps what it shows; owned tilesets are + * released and disposed. Without an engine the controller applies sources + * to the viewer itself (standalone use and tests). + */ export class MapSourceController { constructor( viewer, @@ -19,7 +30,10 @@ export class MapSourceController { initialStack, onChange = null, onError = null, - requestRender = () => viewer?.scene?.requestRender?.(), + engine = null, + requestRender = engine + ? () => engine.render.request('map-stack') + : () => viewer?.scene?.requestRender?.(), createImageryLayer = (provider) => new Cesium.ImageryLayer(provider), }, ) { @@ -32,7 +46,12 @@ export class MapSourceController { this._onChange = onChange; this._onError = onError; this._requestRender = requestRender; - this._scene = createMapScene(viewer, { createImageryLayer }); + this._engine = engine; + this._scene = engine + ? null + : createMapScene(viewer, { createImageryLayer }); + /** Engine mode: the provider last handed to engine.maps as base imagery. */ + this._baseProvider = null; this._abort = new AbortController(); this._imageryProviders = new Map(); this._terrainProviders = new Map(); @@ -236,6 +255,16 @@ export class MapSourceController { ); } if (gen !== this._switchGen) return; + if (this._engine) { + // The engine adds an owned tileset to the scene when it shows it. + if (!source.tileset) this._ownedTilesets.add(tileset); + this._removeImageryLayer(); + await this._engine.maps.setPhotorealTiles({ + tileset, + credit: source.credit ?? null, + }); + return; + } if (!source.tileset && !this._ownedTilesets.has(tileset)) { tileset.show = false; this.viewer.scene.primitives.add(tileset); @@ -258,6 +287,7 @@ export class MapSourceController { async _activateGlobeStack(stack, gen) { const resolution = await this._getImageryProvider(stack); if (gen !== this._switchGen) return; + if (this._engine) return this._activateEngineGlobeStack(resolution, gen); // Scene shots reapply their map stack at every handoff; the map scene // keeps the live layer when the resolved provider is unchanged. if (this._scene.baseProvider !== resolution.provider) { @@ -288,6 +318,44 @@ export class MapSourceController { return resolution; } + // engine.maps in the order the viewer path applies them: base imagery and + // its credit, no photoreal tiles (the globe shows), then terrain. + async _activateEngineGlobeStack(resolution, gen) { + const { maps } = this._engine; + const source = this._sources.get(resolution.effectiveStackId); + this._removeImageryErrorListener?.(); + this._removeImageryErrorListener = null; + await maps.setBaseImagery({ + descriptor: source?.descriptor, + provider: resolution.provider, + credit: source?.credit ?? null, + }); + if (gen !== this._switchGen) return; + this._baseProvider = resolution.provider; + this._watchProvider(resolution, gen); + await maps.setPhotorealTiles(null); + if (gen !== this._switchGen) return; + this._activeId = resolution.effectiveStackId; + if (source?.terrain && source.terrain.id !== this._terrainMode) { + const terrain = source.terrain; + const result = await this._cached( + this._terrainProviders, + terrain.id, + () => terrain.create({ signal: this._abort.signal }), + ); + if (gen !== this._switchGen) return; + await maps.setTerrain({ id: terrain.id, create: () => result }); + if (gen !== this._switchGen) return; + this._terrainMode = terrain.id; + } + return resolution; + } + + /** The live base imagery provider, or null. */ + _liveBaseProvider() { + return this._engine ? this._baseProvider : this._scene.baseProvider; + } + _cached(cache, id, create) { if (cache.has(id)) return cache.get(id); const promise = Promise.resolve() @@ -337,7 +405,7 @@ export class MapSourceController { this._removeImageryErrorListener = errorEvent.addEventListener((error) => { if ( gen !== this._switchGen || - this._scene.baseProvider !== resolution.provider + this._liveBaseProvider() !== resolution.provider ) return; const retryCount = Number(error?.timesRetried); @@ -372,7 +440,9 @@ export class MapSourceController { _removeImageryLayer() { this._removeImageryErrorListener?.(); this._removeImageryErrorListener = null; - this._scene.removeBaseImagery(); + // Engine mode: engine.maps drops the base layer when tiles replace it. + if (this._engine) this._baseProvider = null; + else this._scene.removeBaseImagery(); } _dispose(value) { if (!value || this._disposed.has(value)) return; @@ -388,7 +458,7 @@ export class MapSourceController { this._abort.abort(); this._isSwitching = false; this._removeImageryLayer(); - this._scene.destroy(); + this._scene?.destroy(); for (const promise of this._imageryProviders.values()) void Promise.resolve(promise).then( (value) => this._dispose(value.provider), @@ -400,7 +470,8 @@ export class MapSourceController { () => {}, ); for (const tileset of this._ownedTilesets) { - this.viewer.scene.primitives.remove(tileset); + if (this._engine) this._engine.maps.releasePhotorealTiles({ tileset }); + else this.viewer.scene.primitives.remove(tileset); this._dispose(tileset); } for (const promise of this._tilesets.values()) diff --git a/src/maps/controller.test.mjs b/src/maps/controller.test.mjs index 1e6c1413f..95ecfce63 100644 --- a/src/maps/controller.test.mjs +++ b/src/maps/controller.test.mjs @@ -839,3 +839,157 @@ test('each switch generation reports its origin: an outside setStack is manual, ]); recovering.controller.destroy(); }); + +// --- On a globe engine: scene changes go through engine.maps ---------------- + +async function engineFixture({ googleTileset = { show: true }, engine } = {}) { + const { createFakeEngine } = await import('../globe/testing/fakeEngine.js'); + engine ??= createFakeEngine(); + const registry = createDefaultMapSources({ googleTileset }); + const providers = new Map(); + for (const source of registry.sources) { + if (!source.imagery) continue; + const provider = { id: source.descriptor.id, errorEvent: event() }; + providers.set(source.descriptor.id, provider); + source.imagery = async () => provider; + source.terrain = { + id: 'keyless', + create: async () => ({ provider: { id: 'terrain' } }), + }; + } + const changes = []; + const controller = new MapSourceController(null, { + registry, + engine, + onChange: (state) => changes.push(state), + }); + return { engine, registry, providers, controller, changes, googleTileset }; +} + +test('on an engine a globe stack is base imagery, then no photoreal tiles, then terrain', async () => { + const env = await engineFixture(); + await env.controller.setStack('esri-imagery'); + const calls = env.engine.fake.calls.filter(({ method }) => + method.startsWith('maps.'), + ); + assert.deepEqual( + calls.map(({ method }) => method), + ['maps.setBaseImagery', 'maps.setPhotorealTiles', 'maps.setTerrain'], + ); + const [imagery] = calls[0].args; + assert.equal(imagery.provider, env.providers.get('esri-imagery')); + assert.equal(imagery.descriptor.id, 'esri-imagery'); + assert.match(imagery.credit, /Esri/); + assert.equal(calls[1].args[0], null); + assert.deepEqual(await calls[2].args[0].create(), { + provider: { id: 'terrain' }, + }); + assert.equal(env.controller.getActiveId(), 'esri-imagery'); + env.engine.fake.resetCalls(); + await env.controller.setStack('esri-imagery'); + assert.equal( + env.engine.fake.callsOf('maps.setTerrain').length, + 0, + 'terrain stays applied', + ); + env.controller.destroy(); +}); + +test('on an engine a photoreal stack hands the engine its tileset and credit', async () => { + const env = await engineFixture(); + await env.controller.setStack('osm'); + env.engine.fake.resetCalls(); + await env.controller.setStack('photoreal'); + const [call] = env.engine.fake.callsOf('maps.setPhotorealTiles'); + assert.equal(call.args[0].tileset, env.googleTileset); + assert.equal(env.engine.fake.callsOf('maps.setBaseImagery').length, 0); + assert.equal(env.controller.getActiveId(), 'photoreal'); + env.controller.destroy(); +}); + +test('on an engine tile failures still fall back through the error watch', async () => { + const env = await engineFixture(); + await env.controller.setStack('esri-imagery'); + const errors = env.providers.get('esri-imagery').errorEvent; + assert.equal(errors.size, 1); + errors.raise(); + errors.raise(); + await settle(); + assert.equal(env.controller.getActiveId(), 'osm'); + assert.equal(errors.size, 0); + assert.equal( + env.engine.fake.callsOf('maps.setBaseImagery').at(-1).args[0].provider, + env.providers.get('osm'), + ); + env.controller.destroy(); +}); + +test('on an engine owned tilesets are released through engine.maps and disposed', async () => { + const { createFakeEngine } = await import('../globe/testing/fakeEngine.js'); + const engine = createFakeEngine(); + let destroyed = 0; + const owned = { show: false, destroy: () => destroyed++ }; + const controller = new MapSourceController(null, { + engine, + registry: { + defaultId: 'owned', + sources: [ + { descriptor: descriptor('owned'), createTileset: async () => owned }, + ], + }, + }); + await controller.setStack('owned'); + assert.equal( + engine.fake.callsOf('maps.setPhotorealTiles')[0].args[0].tileset, + owned, + ); + controller.destroy(); + assert.deepEqual(engine.fake.callsOf('maps.releasePhotorealTiles')[0].args, [ + { tileset: owned }, + ]); + assert.equal(destroyed, 1); +}); + +test('controller and Cesium engine: photoreal to a globe stack restores imagery and credit and back', async () => { + const { createCesiumEngine } = + await import('../globe/cesium/createCesiumEngine.js'); + const { createFakeViewer } = + await import('../globe/cesium/testing/fakeViewer.mjs'); + const viewer = createFakeViewer(); + const google = { show: true }; + viewer.primitiveList.push(google); + viewer.scene.globe.show = false; + const engine = createCesiumEngine(viewer, { + tilesetRef: () => google, + requestRender: () => {}, + createImageryLayer: (provider) => ({ provider }), + }); + const env = await engineFixture({ googleTileset: google, engine }); + const credits = () => viewer.staticCredits.map((credit) => credit.html); + await env.controller.setStack('photoreal'); + assert.equal(google.show, true); + assert.equal(viewer.scene.globe.show, false); + const photorealCredits = credits(); + await env.controller.setStack('esri-imagery'); + assert.equal(google.show, false); + assert.equal(viewer.scene.globe.show, true); + assert.deepEqual(viewer.imageryList, [ + { provider: env.providers.get('esri-imagery') }, + ]); + assert.equal(credits().length, 1); + assert.match(credits()[0], /Esri/); + assert.equal(viewer.terrainProvider?.id, 'terrain', 'terrain applied'); + await env.controller.setStack('osm'); + assert.deepEqual(viewer.imageryList, [ + { provider: env.providers.get('osm') }, + ]); + assert.deepEqual(credits(), [], 'OSM carries no static credit'); + await env.controller.setStack('photoreal'); + assert.deepEqual(viewer.imageryList, []); + assert.equal(google.show, true); + assert.equal(viewer.scene.globe.show, false); + assert.deepEqual(credits(), photorealCredits); + env.controller.destroy(); + engine.destroy(); + assert.deepEqual(credits(), []); +});