diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index de5f8bd54..d12933d96 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2566,7 +2566,9 @@ "src/geo/index.js", "src/maps/catalog.js", "src/maps/controller.js", + "src/render/cesium/globe.js", "src/render/cesium/credits.js", + "src/render/cesium/tileRequests.js", "src/render/cesium/cameraInput.js", "src/render/cesium/camera.js", "src/render/cesium/cameraControls.js", @@ -2584,6 +2586,7 @@ "src/render/cesium/common.js", "src/render/creditMarkup.js", "src/maps/defaultSources.js", + "src/render/cesium/imagery.js", "src/maps/google3d.js", "src/maps/imagery.js", "src/maps/registry.js", diff --git a/src/data/osmOverlays.js b/src/data/osmOverlays.js index dc3de45fc..9fab90c14 100644 --- a/src/data/osmOverlays.js +++ b/src/data/osmOverlays.js @@ -1,5 +1,7 @@ -import * as Cesium from 'cesium'; -import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js'; +import { NO_IMAGERY_HOST } from '../maps/imageryHost.js'; +// The application's renderer gives the globe port; a viewer with no +// renderer bound gets one from the factory its Cesium side loads. +import { findGlobe } from '../render/rendererOf.js'; /** * @file Open-data raster overlays: OpenSeaMap sea marks and OpenSnowMap ski @@ -16,10 +18,11 @@ import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js'; * - OpenInfraMap serves MapLibre vector tiles, not raster XYZ tiles. That * needs a vector-tile rendering pipeline, not an imagery-layer drop-in. * - * The overlays drape on whatever surface the active map stack offers (see - * `maps/imageryHost.js`): the globe on the OSM and Bing stacks, the Google - * Photorealistic tileset's own `imageryLayers` on Google 3D. Only a stack with - * neither leaves them hidden, and the row says so. + * Each overlay is an imagery layer of the renderer's globe port, draped on + * whatever is drawn: the globe on the OSM and Bing stacks, the Google + * Photorealistic tiles on Google 3D. The map stack's own record says which + * (`getDrapeSurface`); only a stack with neither leaves them hidden, and the + * row says so. * @module data/osmOverlays */ @@ -54,76 +57,53 @@ export function osmOverlayHiddenReason(enabled, hostKind) { return enabled && hostKind === 'none' ? NO_IMAGERY_HOST : null; } +/** The imagery spec of one overlay. */ +export function osmOverlaySpec(overlay) { + return { + kind: 'xyz', + url: overlay.urlTemplate, + maxLevel: overlay.maximumLevel, + credit: { text: overlay.credit }, + }; +} + /** * Create the layer for one overlay descriptor. Each application catalog gets - * its own instances; the Cesium factories are injectable so tests run without - * WebGL. + * its own instances; `globeOf` (the viewer's globe port) is injectable so + * tests run without WebGL. */ -export function createOsmOverlayLayer( - overlay, - { - host = resolveImageryHost, - createProvider = (descriptor) => - new Cesium.UrlTemplateImageryProvider({ - url: descriptor.urlTemplate, - credit: descriptor.credit, - maximumLevel: descriptor.maximumLevel, - }), - createImageryLayer = (provider) => new Cesium.ImageryLayer(provider), - } = {}, -) { +export function createOsmOverlayLayer(overlay, { globeOf = findGlobe } = {}) { if (!OSM_OVERLAYS.includes(overlay)) throw new TypeError(`Unknown open map overlay: ${overlay?.id}`); let viewer = null; let mapStack = null; let unsubscribeMap = null; let enabled = false; - let hostInfo = { collection: null, kind: 'none' }; - /** The imagery layer this instance owns while enabled, and where it sits. */ + let surface = 'none'; + /** The imagery layer this instance owns while enabled. */ let owned = null; - const usable = (collection) => collection && !collection.isDestroyed?.(); - - function detach(destroy) { - if (!owned) return; - const { collection, layer } = owned; - if (usable(collection) && collection.contains(layer)) - collection.remove(layer, destroy); - else if (destroy && !layer.isDestroyed?.()) layer.destroy?.(); - owned.collection = null; - if (destroy) owned = null; + /** + * What the overlay drapes on: the map stack's record, or with no map + * stack, whether the viewer's globe is shown. + */ + function drapeSurface() { + const recorded = mapStack?.getDrapeSurface?.(); + if (recorded) return recorded; + return viewer?.scene?.globe?.show === true ? 'globe' : 'none'; } - function resolveHost() { - return ( - host({ viewer, tileset: mapStack?.getImageryHostTileset?.() }) || { - collection: null, - kind: 'none', - } - ); - } - - /** Create, move or drop the owned imagery layer to match the state. */ + /** Add or remove the owned imagery layer to match the state. */ function sync() { if (!viewer) return; - hostInfo = enabled ? resolveHost() : { collection: null, kind: 'none' }; + surface = enabled ? drapeSurface() : 'none'; if (!enabled) { - detach(true); - } else { - owned ||= { - layer: createImageryLayer(createProvider(overlay)), - collection: null, - }; - if (owned.collection !== hostInfo.collection) { - detach(false); - if (usable(hostInfo.collection)) { - // Appended, so it draws above the map stack's base imagery. - hostInfo.collection.add(owned.layer); - owned.collection = hostInfo.collection; - } - } + owned?.remove(); + owned = null; + } else if (!owned) { + // Added last, so it draws above the map stack's base imagery. + owned = globeOf(viewer)?.imagery.add(osmOverlaySpec(overlay)) ?? null; } - viewer.scene?.requestRender?.(); } return { @@ -135,7 +115,7 @@ export function createOsmOverlayLayer( init(nextViewer) { viewer = nextViewer; }, - /** Follow map-stack switches so the drape moves with the surface. */ + /** Follow map-stack switches, for the row's hidden reason. */ attachMapStackController(controller) { unsubscribeMap?.(); unsubscribeMap = null; @@ -159,27 +139,27 @@ export function createOsmOverlayLayer( getStats() { // Without a controller subscription the stats poll doubles as the // map-stack watch. - if (enabled && !unsubscribeMap) { - const next = resolveHost(); - if (next.collection !== hostInfo.collection) sync(); - } + if (enabled && !unsubscribeMap) surface = drapeSurface(); + // No globe port yet (a viewer whose renderer came later): try again. + if (enabled && !owned) sync(); return { count: enabled ? 1 : 0, - error: osmOverlayHiddenReason(enabled, hostInfo.kind), + error: osmOverlayHiddenReason(enabled, surface), }; }, - /** Owned layer count and host kind, for tests and live gates. */ + /** Owned layer count and drape surface, for tests and live gates. */ diagnostics() { - return { host: hostInfo.kind, ownedCount: owned ? 1 : 0 }; + return { host: surface, ownedCount: owned ? 1 : 0 }; }, destroy() { enabled = false; - detach(true); + owned?.remove(); + owned = null; unsubscribeMap?.(); unsubscribeMap = null; mapStack = null; viewer = null; - hostInfo = { collection: null, kind: 'none' }; + surface = 'none'; }, }; } diff --git a/src/data/osmOverlays.test.mjs b/src/data/osmOverlays.test.mjs index ee2792ff4..5bb77e78c 100644 --- a/src/data/osmOverlays.test.mjs +++ b/src/data/osmOverlays.test.mjs @@ -5,28 +5,29 @@ import { createOsmOverlayLayer, createOsmOverlayLayers, osmOverlayHiddenReason, + osmOverlaySpec, } from './osmOverlays.js'; import { NO_IMAGERY_HOST } from '../maps/imageryHost.js'; -function fakeCollection() { +/** A globe port recording the imagery layers added and removed through it. */ +function fakeGlobe() { const layers = []; - const removed = []; return { layers, - removed, - add(layer) { - layers.push(layer); - }, - contains(layer) { - return layers.includes(layer); - }, - remove(layer, destroy) { - const index = layers.indexOf(layer); - if (index < 0) return false; - layers.splice(index, 1); - removed.push({ layer, destroy }); - if (destroy) layer.destroyed = true; - return true; + imagery: { + add(spec) { + const layer = { + spec, + removed: false, + remove() { + layer.removed = true; + layers.splice(layers.indexOf(layer), 1); + }, + }; + layers.push(layer); + return layer; + }, + list: () => layers.slice(), }, }; } @@ -34,9 +35,9 @@ function fakeCollection() { function fakeMapStack() { const subscribers = new Set(); return { - tileset: null, - getImageryHostTileset() { - return this.tileset; + surface: 'globe', + getDrapeSurface() { + return this.surface; }, subscribe(fn) { subscribers.add(fn); @@ -50,24 +51,15 @@ function fakeMapStack() { } function setup(overlay = OSM_OVERLAYS[0]) { - const globe = fakeCollection(); - const viewer = { - imageryLayers: globe, - scene: { globe: { show: true }, requestRender() {} }, - }; - const created = []; + const globe = fakeGlobe(); + const viewer = { scene: { globe: { show: true } } }; const layer = createOsmOverlayLayer(overlay, { - createProvider: (descriptor) => ({ descriptor }), - createImageryLayer: (provider) => { - const imagery = { provider, destroyed: false }; - created.push(imagery); - return imagery; - }, + globeOf: (given) => (given === viewer ? globe : null), }); const mapStack = fakeMapStack(); layer.init(viewer); layer.attachMapStackController(mapStack); - return { layer, viewer, globe, created, mapStack }; + return { layer, viewer, globe, mapStack }; } test('OSM_OVERLAYS: the two shipped layers, each with a tile template and credit', () => { @@ -103,65 +95,94 @@ test('osmOverlayHiddenReason: only when enabled with no imagery host', () => { assert.equal(osmOverlayHiddenReason(true, 'none'), NO_IMAGERY_HOST); }); -test('a layer owns no imagery until enabled and destroys it on disable', () => { - const { layer, globe, created } = setup(OSM_OVERLAYS[1]); - assert.equal(created.length, 0); +test('a layer owns no imagery until enabled and removes it on disable', () => { + const { layer, globe } = setup(OSM_OVERLAYS[1]); + assert.equal(globe.layers.length, 0); layer.enable(); assert.equal(globe.layers.length, 1); - assert.equal(globe.layers[0].provider.descriptor.id, 'ski-pistes'); + assert.deepEqual(globe.layers[0].spec, osmOverlaySpec(OSM_OVERLAYS[1])); + assert.deepEqual(globe.layers[0].spec, { + kind: 'xyz', + url: 'https://www.opensnowmap.org/pistes/{z}/{x}/{y}.png', + maxLevel: 18, + credit: { text: OSM_OVERLAYS[1].credit }, + }); assert.deepEqual(layer.getStats(), { count: 1, error: null }); + const [first] = globe.layers; layer.disable(); assert.equal(globe.layers.length, 0); - assert.equal(created[0].destroyed, true); + assert.equal(first.removed, true); assert.deepEqual(layer.diagnostics(), { host: 'none', ownedCount: 0 }); assert.deepEqual(layer.getStats(), { count: 0, error: null }); layer.enable(); - assert.equal(created.length, 2, 're-enabling creates a fresh imagery layer'); + assert.notEqual(globe.layers[0], first, 're-enabling adds a fresh layer'); }); -test('the overlay follows a map-stack switch onto the Google 3D tileset and back', () => { - const { layer, viewer, globe, mapStack } = setup(); +test('a map-stack switch onto Google 3D keeps the one layer, which the renderer drapes there', () => { + const { layer, globe, mapStack } = setup(); layer.enable(); const [imagery] = globe.layers; - const tileset = { imageryLayers: fakeCollection() }; - viewer.scene.globe.show = false; - mapStack.tileset = tileset; + mapStack.surface = 'tileset'; mapStack.emit(); - assert.equal(globe.layers.length, 0); - assert.deepEqual(tileset.imageryLayers.layers, [imagery]); - assert.equal(globe.removed[0].destroy, false); + assert.deepEqual(globe.layers, [imagery]); assert.equal(layer.diagnostics().host, 'tileset'); assert.equal(layer.getStats().error, null); - viewer.scene.globe.show = true; - mapStack.tileset = null; + mapStack.surface = 'globe'; mapStack.emit(); assert.deepEqual(globe.layers, [imagery]); - assert.equal(tileset.imageryLayers.layers.length, 0); + assert.equal(layer.diagnostics().host, 'globe'); }); test('with no surface to drape on the row explains itself, and recovers when one appears', () => { - const { layer, viewer, globe, mapStack } = setup(); - viewer.scene.globe.show = false; + const { layer, globe, mapStack } = setup(); + mapStack.surface = 'none'; layer.enable(); - assert.equal(globe.layers.length, 0); + assert.equal(globe.layers.length, 1, 'kept, drawn when a surface is'); assert.equal(layer.getStats().error, NO_IMAGERY_HOST); - viewer.scene.globe.show = true; + mapStack.surface = 'globe'; mapStack.emit(); - assert.equal(globe.layers.length, 1); + assert.equal(layer.getStats().error, null); +}); + +test('without a map stack, the viewer’s globe decides, polled by the stats', () => { + const globe = fakeGlobe(); + const viewer = { scene: { globe: { show: false } } }; + const layer = createOsmOverlayLayer(OSM_OVERLAYS[0], { + globeOf: () => globe, + }); + layer.init(viewer); + layer.enable(); + assert.equal(layer.getStats().error, NO_IMAGERY_HOST); + viewer.scene.globe.show = true; assert.equal(layer.getStats().error, null); }); test('destroy removes owned imagery and unsubscribes from the map stack', () => { - const { layer, globe, created, mapStack } = setup(); + const { layer, globe, mapStack } = setup(); layer.enable(); + const [imagery] = globe.layers; layer.destroy(); assert.equal(globe.layers.length, 0); - assert.equal(created[0].destroyed, true); + assert.equal(imagery.removed, true); assert.equal(mapStack.subscribers.size, 0); }); + +test('an overlay enabled before its viewer has a globe port adds it once one does', () => { + const globe = fakeGlobe(); + let port = null; + const layer = createOsmOverlayLayer(OSM_OVERLAYS[0], { globeOf: () => port }); + layer.init({ scene: { globe: { show: true } } }); + layer.enable(); + assert.equal(layer.diagnostics().ownedCount, 0); + port = globe; + layer.getStats(); + assert.equal(globe.layers.length, 1); + layer.getStats(); + assert.equal(globe.layers.length, 1, 'added once'); +}); diff --git a/src/maps/controller.js b/src/maps/controller.js index 40a66d6b5..c820cad14 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -4,6 +4,7 @@ import { createMapCredits } from './credits.js'; // A viewer of a consumer's own gets its credits port (and camera) from the // factories this loads. import '../render/cesium/camera.js'; +import { markBaseTileset } from '../render/cesium/globe.js'; import { acquireImageryComparison } from './imageryComparison.js'; /** @@ -75,6 +76,9 @@ export class MapSourceController { this._replacedTerrain = null; this._subscribers = new Set(); this._destroyed = false; + // The renderer's imagery drapes on these while they are shown. + for (const source of this._sources.values()) + if (source.tileset) markBaseTileset(source.tileset); } getStack(id) { @@ -112,6 +116,18 @@ export class MapSourceController { if (tileset.show === true) return tileset; return null; } + /** + * What draped imagery lands on now: `'globe'` while the globe is shown + * (a globe stack), `'tileset'` for a shown 3D stack, `'none'` when + * neither is drawn; null once destroyed, when it knows nothing. + * @returns {'globe' | 'tileset' | 'none' | null} + */ + getDrapeSurface() { + if (this._destroyed) return null; + if (this.viewer?.scene?.globe?.show === true) return 'globe'; + const tileset = this.getImageryHostTileset(); + return tileset && !tileset.isDestroyed?.() ? 'tileset' : 'none'; + } getActiveId() { return this._activeId; } @@ -270,6 +286,7 @@ export class MapSourceController { if (gen !== this._switchGen) return; if (!source.tileset && !this._ownedTilesets.has(tileset)) { tileset.show = false; + markBaseTileset(tileset); this.viewer.scene.primitives.add(tileset); this._ownedTilesets.add(tileset); } diff --git a/src/maps/controller.test.mjs b/src/maps/controller.test.mjs index a43f9d8fc..657257b6a 100644 --- a/src/maps/controller.test.mjs +++ b/src/maps/controller.test.mjs @@ -453,12 +453,18 @@ test('imagery host tileset follows supplied, owned and globe map sources', async assert.equal(controller.getImageryHostTileset(), supplied); await controller.setStack('owned'); assert.equal(controller.getImageryHostTileset(), owned); + assert.equal(controller.getDrapeSurface(), 'tileset'); await controller.setStack('globe'); assert.equal(controller.getImageryHostTileset(), null); + assert.equal(controller.getDrapeSurface(), 'globe'); await controller.setStack('supplied'); assert.equal(controller.getImageryHostTileset(), supplied); + assert.equal(controller.getDrapeSurface(), 'tileset'); + supplied.show = false; + assert.equal(controller.getDrapeSurface(), 'none'); controller.destroy(); assert.equal(controller.getImageryHostTileset(), null); + assert.equal(controller.getDrapeSurface(), null); }); function leaseFixture() { diff --git a/src/render/cesiumAllowList.json b/src/render/cesiumAllowList.json index 9c20cd375..afd65e7e9 100644 --- a/src/render/cesiumAllowList.json +++ b/src/render/cesiumAllowList.json @@ -36,7 +36,6 @@ "src/data/modelRecord.js", "src/data/modelVisualAnchor.js", "src/data/motionModel.js", - "src/data/osmOverlays.js", "src/data/trailRenderer.js", "src/input/dispatcher.js", "src/input/sensorPick.js",