refactor(maps): use the globe engine

MapSourceController (and MapStackController) take the page's globe
engine: a globe stack becomes engine.maps.setBaseImagery with the
controller's ready provider and credit, then setPhotorealTiles(null),
then setTerrain with the cached terrain; a 3D stack becomes
setPhotorealTiles with its tileset and credit; owned tilesets are
released through maps.releasePhotorealTiles before disposal. The
controller keeps its provider/terrain/tileset caches, construction and
tile-failure fallbacks, switch generations and error watch. The scene
builds its map stack controller with its engine; without an engine the
controller applies sources to the viewer as before. The photoreal-to-
globe sequence (imagery, credit, globe, tileset) is tested end to end on
the Cesium engine.
This commit is contained in:
German Khokhlov committed 2026-10-15 18:40:19 +04:00
1 parent 83c13f330d
commit 3e3aa0bd9f
4 files changed
+258 -7

No files matched your search

+2
View File
@@ -105,6 +105,8 @@ export async function createApplicationScene({
const mapStackController = new MapController(viewer, { const mapStackController = new MapController(viewer, {
requestRender: governorRequestRender, requestRender: governorRequestRender,
// Map sources reach the scene through the globe engine's maps.
engine,
...mapOptions, ...mapOptions,
googleTileset: tileset, googleTileset: tileset,
cesiumToken, cesiumToken,
+24
View File
@@ -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();
});
+78 -7
View File
@@ -10,7 +10,18 @@ import { acquireImageryComparison } from './imageryComparison.js';
*/ */
const AUTOMATIC = Symbol('automatic switch'); 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 { export class MapSourceController {
constructor( constructor(
viewer, viewer,
@@ -19,7 +30,10 @@ export class MapSourceController {
initialStack, initialStack,
onChange = null, onChange = null,
onError = 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), createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
}, },
) { ) {
@@ -32,7 +46,12 @@ export class MapSourceController {
this._onChange = onChange; this._onChange = onChange;
this._onError = onError; this._onError = onError;
this._requestRender = requestRender; 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._abort = new AbortController();
this._imageryProviders = new Map(); this._imageryProviders = new Map();
this._terrainProviders = new Map(); this._terrainProviders = new Map();
@@ -236,6 +255,16 @@ export class MapSourceController {
); );
} }
if (gen !== this._switchGen) return; 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)) { if (!source.tileset && !this._ownedTilesets.has(tileset)) {
tileset.show = false; tileset.show = false;
this.viewer.scene.primitives.add(tileset); this.viewer.scene.primitives.add(tileset);
@@ -258,6 +287,7 @@ export class MapSourceController {
async _activateGlobeStack(stack, gen) { async _activateGlobeStack(stack, gen) {
const resolution = await this._getImageryProvider(stack); const resolution = await this._getImageryProvider(stack);
if (gen !== this._switchGen) return; 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 // Scene shots reapply their map stack at every handoff; the map scene
// keeps the live layer when the resolved provider is unchanged. // keeps the live layer when the resolved provider is unchanged.
if (this._scene.baseProvider !== resolution.provider) { if (this._scene.baseProvider !== resolution.provider) {
@@ -288,6 +318,44 @@ export class MapSourceController {
return resolution; 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) { _cached(cache, id, create) {
if (cache.has(id)) return cache.get(id); if (cache.has(id)) return cache.get(id);
const promise = Promise.resolve() const promise = Promise.resolve()
@@ -337,7 +405,7 @@ export class MapSourceController {
this._removeImageryErrorListener = errorEvent.addEventListener((error) => { this._removeImageryErrorListener = errorEvent.addEventListener((error) => {
if ( if (
gen !== this._switchGen || gen !== this._switchGen ||
this._scene.baseProvider !== resolution.provider this._liveBaseProvider() !== resolution.provider
) )
return; return;
const retryCount = Number(error?.timesRetried); const retryCount = Number(error?.timesRetried);
@@ -372,7 +440,9 @@ export class MapSourceController {
_removeImageryLayer() { _removeImageryLayer() {
this._removeImageryErrorListener?.(); this._removeImageryErrorListener?.();
this._removeImageryErrorListener = null; 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) { _dispose(value) {
if (!value || this._disposed.has(value)) return; if (!value || this._disposed.has(value)) return;
@@ -388,7 +458,7 @@ export class MapSourceController {
this._abort.abort(); this._abort.abort();
this._isSwitching = false; this._isSwitching = false;
this._removeImageryLayer(); this._removeImageryLayer();
this._scene.destroy(); this._scene?.destroy();
for (const promise of this._imageryProviders.values()) for (const promise of this._imageryProviders.values())
void Promise.resolve(promise).then( void Promise.resolve(promise).then(
(value) => this._dispose(value.provider), (value) => this._dispose(value.provider),
@@ -400,7 +470,8 @@ export class MapSourceController {
() => {}, () => {},
); );
for (const tileset of this._ownedTilesets) { 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); this._dispose(tileset);
} }
for (const promise of this._tilesets.values()) for (const promise of this._tilesets.values())
+154
View File
@@ -839,3 +839,157 @@ test('each switch generation reports its origin: an outside setStack is manual,
]); ]);
recovering.controller.destroy(); 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(), []);
});