diff --git a/src/globe/cesium/maps.js b/src/globe/cesium/maps.js index 291d139ab..eaf2cbd00 100644 --- a/src/globe/cesium/maps.js +++ b/src/globe/cesium/maps.js @@ -95,6 +95,9 @@ export function createCesiumMaps( // decides what the globe shows. Terrain has its own. const generations = { display: 0, terrain: 0 }; let baseSource = null; + // The source objects on screen, as passed (maps.current()). + let currentBase = null; + let currentTiles = null; let creditOwner = null; let destroyed = false; @@ -130,6 +133,7 @@ export function createCesiumMaps( } function showBase(source, provider) { + currentBase = source; mapScene.showBaseImagery(provider); showCredit('imagery', imageryCredit(source)); } @@ -156,6 +160,7 @@ export function createCesiumMaps( if (source == null) { mapScene.showTileset(null, tilesets()); shownTileset = null; + currentTiles = null; // The globe comes back with the last base imagery and its credit; // the photoreal credit goes. if (baseSource) { @@ -195,6 +200,8 @@ export function createCesiumMaps( // As MapSourceController activates a 3D stack: no base layer, the // source's credit, one tileset shown and the globe hidden. mapScene.removeBaseImagery(); + currentBase = null; + currentTiles = source; showCredit('photoreal', source.credit); shownTileset = tileset; mapScene.showTileset(tileset, tilesets()); @@ -205,9 +212,11 @@ export function createCesiumMaps( const tileset = source?.tileset; if (!tileset || !known.delete(tileset)) return; if (shownTileset === tileset) shownTileset = null; + if (currentTiles?.tileset === tileset) currentTiles = null; if (added.delete(tileset)) viewer.scene.primitives.remove(tileset); }, photorealActive: () => tilesets().some((tileset) => tileset.show === true), + current: () => ({ baseImagery: currentBase, photoreal: currentTiles }), }; return { diff --git a/src/globe/cesium/maps.test.mjs b/src/globe/cesium/maps.test.mjs index fa2955bb5..82d417361 100644 --- a/src/globe/cesium/maps.test.mjs +++ b/src/globe/cesium/maps.test.mjs @@ -378,3 +378,21 @@ test('releasePhotorealTiles leaves a tileset the engine did not add in the scene maps.releasePhotorealTiles({ tileset: startup }); assert.equal(viewer.primitiveList.includes(startup), true); }); + +test('current names the sources on screen as passed', async () => { + const { maps } = setup(); + assert.deepEqual(maps.current(), { baseImagery: null, photoreal: null }); + const esri = esriSource(); + const tiles = { tileset: tileset(), credit: 'Google' }; + await maps.setBaseImagery(esri); + assert.deepEqual(maps.current(), { baseImagery: esri, photoreal: null }); + await maps.setPhotorealTiles(tiles); + assert.equal(maps.current().photoreal, tiles); + assert.equal(maps.current().baseImagery, null); + await maps.setPhotorealTiles(null); + assert.equal(maps.current().baseImagery, esri); + assert.equal(maps.current().photoreal, null); + await maps.setPhotorealTiles(tiles); + maps.releasePhotorealTiles(tiles); + assert.equal(maps.current().photoreal, null, 'a released source is gone'); +}); diff --git a/src/globe/contract.js b/src/globe/contract.js index efde38fff..237bd2522 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -161,6 +161,37 @@ * a surface-pick check reads), else undefined. * @property {any} [object] The engine's own pick result, for engine-specific code. * + * @typedef {object} GlobeMaps + * Map sources are engine-neutral descriptions, as maps/registry.js sources + * carry them; each engine builds its own providers from them: + * - imagery `{ kind: 'osm'|'esri-imagery'|'ion', style?, accessToken?, + * credit?, descriptor? }`; + * - photoreal tiles `{ kind: 'photoreal', googleApiKey?, cesiumToken?, + * credit?, descriptor? }`; + * - terrain `{ kind: 'ellipsoid'|'keyless'|'world', accessToken?, id? }`. + * Optional prebuilt members (`provider`, `tileset`, terrain `create`, an + * `imagery()` factory) are engine objects their caller built and owns: only + * the engine kind that built them uses them (the Cesium engine for the + * registry's Cesium factories), any other ignores them and builds from the + * description. The engine neither caches nor disposes them. + * Each setter settles once the source is on screen and rejects when it + * cannot be built (the current source then stays). Base imagery and + * photoreal tiles share one switch order: the call made last decides what + * shows, and a superseded call settles without showing anything. + * @property {(source: object) => Promise} setBaseImagery + * @property {(source: object) => Promise} setTerrain + * @property {(source: object | null) => Promise} setPhotorealTiles + * Tiles replace the base imagery and hide the globe; null hides every + * tileset and shows the globe again with the last base imagery and its + * credit (no credit when none was set). + * @property {(source: object) => void} releasePhotorealTiles Forgets a + * photoreal source shown before (it no longer counts for photorealActive) + * and takes engine-added tiles out of the scene; its caller disposes them. + * @property {() => boolean} photorealActive + * @property {() => { baseImagery: object | null, photoreal: object | null }} current + * The source objects on screen, as passed: the base imagery layer on the + * globe and the photoreal tiles shown (null for none). + * * @typedef {object} GlobeEngine * @property {'cesium'|'navara'} kind * @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host @@ -174,17 +205,7 @@ * As in Cesium, a listener with a modifier fires only while that key is * held and one without fires only while no modifier is held. * @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits - * @property {{ setBaseImagery: (source: object) => Promise, setTerrain: (source: object) => Promise, setPhotorealTiles: (source: object | null) => Promise, releasePhotorealTiles: (source: object) => void, photorealActive: () => boolean }} maps - * Sources are maps/registry.js descriptions; the engine builds its own - * providers. Each setter settles once the source is on screen and rejects - * when it cannot be built (the current source then stays). A source may - * carry a ready engine `provider` its caller built and owns (the engine - * neither caches nor disposes it). Base imagery and photoreal tiles share - * one switch order: the one asked for last wins, a superseded call settles - * without showing anything. setPhotorealTiles(null) shows the globe again - * with the last base imagery and its credit (no credit when none was set). - * releasePhotorealTiles forgets a tileset source shown before and takes it - * out of the scene when the engine put it there; its caller disposes it. + * @property {GlobeMaps} maps * @property {() => void} destroy */ @@ -364,6 +385,7 @@ const SHAPE = { setPhotorealTiles: isFunction, releasePhotorealTiles: isFunction, photorealActive: isFunction, + current: isFunction, }, }; diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index f128974e2..364ab00a1 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -191,6 +191,7 @@ test('the contract carries what the shell infrastructure needs', () => { 'camera.livePosition', 'camera.height', 'maps.releasePhotorealTiles', + 'maps.current', ]) assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member); const engine = createFakeEngine(); @@ -209,8 +210,13 @@ test('the fake engine passes the engine contract suite', async (t) => { const engine = createFakeEngine(); const checks = createContractSuite(engine, { waitFrames: async (count) => engine.fake.frames(count), + mapSources: { + imagery: [{ kind: 'osm' }, { kind: 'esri-imagery' }], + photoreal: { kind: 'photoreal' }, + }, }); assert.ok(checks.length >= 15); + assert.ok(checks.some(({ name }) => name.startsWith('maps:'))); assert.equal(new Set(checks.map((check) => check.name)).size, checks.length); for (const check of checks) await t.test(check.name, () => check.run()); }); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 309c8778b..d4cf4b74d 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -69,12 +69,15 @@ const along = (origin, direction, distance) => /** * @param {import('../contract.js').GlobeEngine} engine - * @param {{ waitFrames: (count: number) => Promise, maxWaitFrames?: number }} options + * @param {{ waitFrames: (count: number) => Promise, maxWaitFrames?: number, + * mapSources?: { imagery: object[], photoreal: object } }} options + * `mapSources` (two imagery sources and a photoreal one the engine can + * show) adds the maps checks; they leave photoreal tiles hidden. * @returns {{ name: string, run: () => Promise }[]} */ export function createContractSuite( engine, - { waitFrames, maxWaitFrames = 240 }, + { waitFrames, maxWaitFrames = 240, mapSources = null }, ) { if (typeof waitFrames !== 'function') throw new TypeError('createContractSuite needs a waitFrames(count) helper'); @@ -1378,9 +1381,82 @@ export function createContractSuite( }, ], ]; + if (mapSources?.photoreal && mapSources.imagery?.length >= 2) + checks.push(...mapChecks(engine.maps, mapSources)); return checks.map(([name, run]) => ({ name, run })); } +// The maps switch order, observed through maps.current(). +function mapChecks(maps, { imagery: [first, second], photoreal }) { + const shows = (baseImagery, tiles, label) => { + const current = maps.current(); + ensure( + current.baseImagery === baseImagery, + `${label}: base imagery is not the expected source`, + ); + ensure( + current.photoreal === tiles, + `${label}: photoreal tiles are not the expected source`, + ); + ensure( + maps.photorealActive() === (tiles !== null), + `${label}: photorealActive is ${maps.photorealActive()}`, + ); + }; + return [ + [ + 'maps: setPhotorealTiles(null) brings back the last base imagery', + async () => { + await maps.setBaseImagery(first); + shows(first, null, 'after setBaseImagery'); + await maps.setPhotorealTiles(photoreal); + shows(null, photoreal, 'tiles replace the base imagery'); + await maps.setPhotorealTiles(null); + shows(first, null, 'null'); + }, + ], + [ + 'maps: the last imagery or photoreal call decides what shows', + async () => { + await maps.setBaseImagery(first); + await Promise.all([ + maps.setBaseImagery(second), + maps.setPhotorealTiles(photoreal), + ]); + shows(null, photoreal, 'tiles last'); + await maps.setPhotorealTiles(null); + const restored = maps.current().baseImagery; + // The second source was superseded before or after it showed. + ensure( + restored === first || restored === second, + 'null: the last base imagery shown is not back', + ); + await Promise.all([ + maps.setPhotorealTiles(photoreal), + maps.setBaseImagery(second), + ]); + ensure( + maps.current().baseImagery === second, + 'imagery last: the second source is not on the globe', + ); + await maps.setPhotorealTiles(null); + shows(second, null, 'null after imagery'); + }, + ], + [ + 'maps.releasePhotorealTiles: a released source no longer shows or counts', + async () => { + await maps.setPhotorealTiles(photoreal); + await maps.setPhotorealTiles(null); + maps.releasePhotorealTiles(photoreal); + maps.releasePhotorealTiles(photoreal); + ensure(!maps.photorealActive(), 'photorealActive after release'); + ensure(maps.current().photoreal === null, 'current after release'); + }, + ], + ]; +} + /** Runs every check in order; never throws for a failing check. */ export async function runContractSuite(engine, options) { const results = []; diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index b20fa9da5..54b66bb53 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -241,6 +241,7 @@ export function createFakeEngine(options = {}) { }; const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()])); const livePosition = cartesian3.create(); + let lastBaseImagery = null; const scripts = new Map(); let transform = [...matrix4.IDENTITY]; let percentageChanged = 0.5; @@ -819,10 +820,13 @@ export function createFakeEngine(options = {}) { }; }, }, + // The contract's switch order: a call shows its source when made (the + // fake builds nothing), so the call made last decides what shows. maps: { async setBaseImagery(source) { record('maps.setBaseImagery', [source]); fake.maps.imagery = source; + lastBaseImagery = source; }, async setTerrain(source) { record('maps.setTerrain', [source]); @@ -830,12 +834,23 @@ export function createFakeEngine(options = {}) { }, async setPhotorealTiles(source) { record('maps.setPhotorealTiles', [source]); - fake.maps.photoreal = source ?? null; + if (source == null) { + fake.maps.photoreal = null; + fake.maps.imagery = lastBaseImagery; + return; + } + fake.maps.photoreal = source; + fake.maps.imagery = null; }, releasePhotorealTiles(source) { record('maps.releasePhotorealTiles', [source]); + if (fake.maps.photoreal === source) fake.maps.photoreal = null; }, photorealActive: () => fake.maps.photoreal != null, + current: () => ({ + baseImagery: fake.maps.imagery ?? null, + photoreal: fake.maps.photoreal, + }), }, destroy() { record('destroy', []);