diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e49c8fe39..84777f813 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -599,6 +599,7 @@ "src/maps/imagery.js", "src/maps/registry.js", "src/maps/terrain.js", + "src/maps/sceneApply.js", "src/maps/terrainRetry.js", "src/maps/terrainRetry.test.mjs", "src/mapStackController.js", @@ -607,6 +608,7 @@ "src/mapStackUnavailableReason.test.mjs", "src/mapStartup.test.mjs", "src/maps/sourceFactories.test.mjs", + "src/maps/sceneApply.test.mjs", "src/search/geospatial.js", "src/search/http.js", "src/search/defaults.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 47753cd76..ed8a4fc4b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1611,6 +1611,7 @@ "src/maps/controller.js", "src/maps/imageryComparison.js", "src/maps/credits.js", + "src/maps/sceneApply.js", "src/maps/defaultSources.js", "src/maps/google3d.js", "src/maps/imagery.js", @@ -2111,6 +2112,7 @@ "src/maps/controller.js", "src/maps/imageryComparison.js", "src/maps/credits.js", + "src/maps/sceneApply.js", "src/maps/defaultSources.js", "src/maps/google3d.js", "src/maps/imagery.js", diff --git a/src/globe/cesium/camera.js b/src/globe/cesium/camera.js index ca8e714a6..6468bb023 100644 --- a/src/globe/cesium/camera.js +++ b/src/globe/cesium/camera.js @@ -7,7 +7,6 @@ import { bridgeEvent } from './events.js'; import { toCesium2, toCesium3, toGeo3 } from './convert.js'; const { math } = geo; -const MINIMUM_SPHERE_RANGE = 100; // Cesium's MINIMUM_ZOOM for flyToBoundingSphere const EASINGS = { cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT, sinusoidalInOut: Cesium.EasingFunction.SINUSOIDAL_IN_OUT, @@ -31,6 +30,19 @@ function cesiumOrientation(orientation) { return { heading, pitch, roll }; } +// A Cartesian3, or a {west, south, east, north} rectangle in radians. +const cesiumDestination = (destination) => + 'west' in destination + ? new Cesium.Rectangle( + destination.west, + destination.south, + destination.east, + destination.north, + ) + : toCesium3(destination); + +const cesiumEasing = (easing) => (easing ? EASINGS[easing] : undefined); + // A HeadingPitchRange or a Cartesian offset, as Cesium's lookAt takes them. const cesiumOffset = (offset) => 'heading' in offset @@ -102,7 +114,7 @@ export function createCesiumCamera(viewer) { if (!view?.destination) throw new TypeError('setView needs a destination'); cesium.setView({ - destination: toCesium3(view.destination), + destination: cesiumDestination(view.destination), orientation: cesiumOrientation(view.orientation), }); }, @@ -111,27 +123,21 @@ export function createCesiumCamera(viewer) { throw new TypeError('flyTo needs a destination'); startFlight(options, ({ complete, cancel }) => cesium.flyTo({ - destination: toCesium3(options.destination), + destination: cesiumDestination(options.destination), orientation: cesiumOrientation(options.orientation), duration: options.duration, - easingFunction: options.easing ? EASINGS[options.easing] : undefined, + easingFunction: cesiumEasing(options.easing), + endTransform: options.endTransform + ? Cesium.Matrix4.fromArray(options.endTransform) + : undefined, complete, cancel, }), ); }, flyToSphere(sphere, options = {}) { - let range = options.range; - if (!range) { - const { fovy, aspectRatio } = cesium.frustum; - const tanPhi = Math.tan(fovy / 2); - const tanTheta = aspectRatio * tanPhi; - range = Math.max( - MINIMUM_SPHERE_RANGE, - sphere.radius / tanTheta, - sphere.radius / tanPhi, - ); - } + // A missing or 0 range is Cesium's own: it fits the sphere in view and + // clamps to the camera controller's zoom distances. startFlight(options, ({ complete, cancel }) => cesium.flyToBoundingSphere( new Cesium.BoundingSphere(toCesium3(sphere.center), sphere.radius), @@ -139,9 +145,10 @@ export function createCesiumCamera(viewer) { offset: new Cesium.HeadingPitchRange( options.heading ?? 0, options.pitch ?? -Math.PI / 4, - range, + options.range ?? 0, ), duration: options.duration, + easingFunction: cesiumEasing(options.easing), complete, cancel, }, diff --git a/src/globe/cesium/camera.test.mjs b/src/globe/cesium/camera.test.mjs index 23b57aad7..c129f0976 100644 --- a/src/globe/cesium/camera.test.mjs +++ b/src/globe/cesium/camera.test.mjs @@ -149,7 +149,7 @@ test('a newer flight cancels the previous one and stays active', () => { }); test('flyToSphere builds a BoundingSphere and an offset with Cesium defaults', () => { - const { viewer, camera, cesium } = setup(); + const { viewer, camera } = setup(); const center = geo.cartesian3.fromDegrees(2.35, 48.85, 100); let done = false; camera.flyToSphere( @@ -166,24 +166,26 @@ test('flyToSphere builds a BoundingSphere and an offset with Cesium defaults', ( assert.ok(options.offset instanceof Cesium.HeadingPitchRange); assert.equal(options.offset.heading, 0); assert.equal(options.offset.pitch, -Math.PI / 4); - const tanPhi = Math.tan(cesium.frustum.fovy / 2); - const tanTheta = cesium.frustum.aspectRatio * tanPhi; assert.equal( options.offset.range, - Math.max(100, 1000 / tanTheta, 1000 / tanPhi), + 0, + "a missing range is left to Cesium's own fit and zoom-distance clamp", ); assert.equal(options.duration, 0); + assert.equal(options.easingFunction, undefined); - camera.flyToSphere({ center, radius: 0 }, { duration: 0 }); - assert.equal( - viewer.callsOf('camera.flyToBoundingSphere')[1].args[1].offset.range, - 100, - ); camera.flyToSphere( { center, radius: 10 }, - { heading: 0.3, pitch: -0.5, range: 5000, duration: 2 }, + { + heading: 0.3, + pitch: -0.5, + range: 5000, + duration: 2, + easing: 'linear', + }, ); - const explicit = viewer.callsOf('camera.flyToBoundingSphere')[2].args[1]; + const explicit = viewer.callsOf('camera.flyToBoundingSphere')[1].args[1]; + assert.equal(explicit.easingFunction, Cesium.EasingFunction.LINEAR_NONE); assert.deepEqual( [explicit.offset.heading, explicit.offset.pitch, explicit.offset.range], [0.3, -0.5, 5000], @@ -305,3 +307,42 @@ test('destroy cancels the engine flight and drops Cesium subscriptions', () => { assert.deepEqual(outcome, ['cancel']); assert.equal(cesium.changed.numberOfListeners, 0); }); + +test('flyTo passes endTransform as a Cesium Matrix4', () => { + const { viewer, camera } = setup(); + camera.flyTo({ + destination: geo.cartesian3.fromDegrees(0, 0, 1e6), + duration: 0, + endTransform: geo.matrix4.IDENTITY, + }); + const [options] = viewer.callsOf('camera.flyTo')[0].args; + assert.ok(options.endTransform instanceof Cesium.Matrix4); + assert.deepEqual(Cesium.Matrix4.toArray(options.endTransform), [ + ...geo.matrix4.IDENTITY, + ]); + camera.flyTo({ destination: geo.cartesian3.fromDegrees(0, 0, 1e6) }); + assert.equal( + viewer.callsOf('camera.flyTo')[1].args[0].endTransform, + undefined, + ); +}); + +test('rectangle destinations become a Cesium.Rectangle in flyTo and setView', () => { + const { viewer, camera } = setup(); + const rectangle = { west: -0.2, south: 0.6, east: -0.1, north: 0.7 }; + camera.flyTo({ destination: rectangle, duration: 0 }); + camera.setView({ destination: rectangle }); + for (const name of ['camera.flyTo', 'camera.setView']) { + const { destination } = viewer.callsOf(name)[0].args[0]; + assert.ok(destination instanceof Cesium.Rectangle, name); + assert.deepEqual( + [ + destination.west, + destination.south, + destination.east, + destination.north, + ], + [-0.2, 0.6, -0.1, 0.7], + ); + } +}); diff --git a/src/globe/cesium/createCesiumEngine.js b/src/globe/cesium/createCesiumEngine.js index 34ff8d8e5..d1e5513d8 100644 --- a/src/globe/cesium/createCesiumEngine.js +++ b/src/globe/cesium/createCesiumEngine.js @@ -23,6 +23,10 @@ import { createCesiumSurface } from './surface.js'; * loadPhotorealTileset?: Function, * }} [options] `requestRender` defaults to the render governor; the * `create*`/`load*` options are test seams. + * + * `render.setContinuous` takes a hold on the app's singleton renderGovernor + * (src/renderGovernor.js), which governs the one viewer it was installed on; + * so one Cesium engine per page, on that viewer. * @returns {import('../contract.js').GlobeEngine} */ export function createCesiumEngine(viewer, options = {}) { diff --git a/src/globe/cesium/input.js b/src/globe/cesium/input.js index d9ada81c5..6519ccbb7 100644 --- a/src/globe/cesium/input.js +++ b/src/globe/cesium/input.js @@ -39,6 +39,7 @@ export function createCesiumInput( ) { const { scene } = viewer; let handler = null; + let destroyed = false; const groups = new Map(); const input = { @@ -47,6 +48,7 @@ export function createCesiumInput( throw new TypeError(`Unknown input type: ${type}`); if (modifier !== undefined && !MODIFIERS.includes(modifier)) throw new TypeError(`Unknown input modifier: ${modifier}`); + if (destroyed) return () => {}; handler ??= createEventHandler(scene.canvas); const key = `${type}:${modifier}`; let listeners = groups.get(key); @@ -56,7 +58,8 @@ export function createCesiumInput( handler.setInputAction( (event) => { const converted = toEvent(event); - for (const entry of [...listeners]) entry.listener(converted); + for (const entry of [...listeners]) + if (listeners.has(entry)) entry.listener(converted); }, TYPES[type], KEYS[modifier], @@ -78,6 +81,7 @@ export function createCesiumInput( return { input, destroy() { + destroyed = true; groups.clear(); handler?.destroy(); handler = null; diff --git a/src/globe/cesium/input.test.mjs b/src/globe/cesium/input.test.mjs index dfabf2821..842e0e2eb 100644 --- a/src/globe/cesium/input.test.mjs +++ b/src/globe/cesium/input.test.mjs @@ -111,3 +111,28 @@ test('destroy destroys the handler', () => { destroy(); assert.equal(handlers[0].destroyed, true); }); + +test('a listener removed during dispatch is not called', () => { + const { handlers, input } = setup(); + const seen = []; + let offB; + input.on('click', () => { + seen.push('a'); + offB(); + }); + offB = input.on('click', () => seen.push('b')); + handlers[0].fire(Type.LEFT_CLICK, { position: new Cesium.Cartesian2() }); + assert.deepEqual(seen, ['a']); +}); + +test('on() after destroy() creates no handler', () => { + const { handlers, input, destroy } = setup(); + destroy(); + const off = input.on('click', () => {}); + assert.equal(handlers.length, 0); + off(); + off(); + input.on('click', () => {}); + destroy(); + assert.equal(handlers.length, 0); +}); diff --git a/src/globe/cesium/maps.js b/src/globe/cesium/maps.js index 0e4a17110..fe223324d 100644 --- a/src/globe/cesium/maps.js +++ b/src/globe/cesium/maps.js @@ -5,7 +5,7 @@ import * as Cesium from 'cesium'; import { loadPhotorealisticTileset } from '../../maps/google3d.js'; -import { createMapCredits } from '../../maps/credits.js'; +import { createMapScene } from '../../maps/sceneApply.js'; import { ESRI_ATTRIBUTION_HTML, imageryFactoryFor, @@ -22,6 +22,10 @@ function imageryCreator(source) { return imageryFactoryFor(descriptor, source.accessToken); } +// Registry sources are stable by id; plain descriptions by kind and style. +const imageryKey = (source) => + source?.descriptor?.id ?? `${source?.kind}:${source?.style ?? ''}`; + function imageryCredit(source) { if (source?.credit !== undefined) return source.credit; const kind = (source?.descriptor ?? source)?.kind; @@ -74,8 +78,8 @@ export function createCesiumMaps( } = {}, ) { const abort = new AbortController(); - let credits = null; - let layer = null; + const mapScene = createMapScene(viewer, { createImageryLayer }); + const providers = new Map(); let ownedTileset = null; let shownTileset = null; const generations = { imagery: 0, terrain: 0, photoreal: 0 }; @@ -88,25 +92,29 @@ export function createCesiumMaps( [startupTileset(), shownTileset, ownedTileset].filter( (tileset, index, all) => tileset && all.indexOf(tileset) === index, ); - const showCredit = (html) => { - credits ??= createMapCredits(viewer); - credits.show(html); - }; + + // One provider per source, as MapSourceController caches them, so applying + // the same source again keeps the live layer. Failures are not cached. + function providerFor(source) { + const key = imageryKey(source); + if (providers.has(key)) return providers.get(key); + const promise = Promise.resolve() + .then(() => imageryCreator(source)({ signal: abort.signal })) + .catch((error) => { + if (providers.get(key) === promise) providers.delete(key); + throw error; + }); + providers.set(key, promise); + return promise; + } const maps = { async setBaseImagery(source) { const generation = ++generations.imagery; - const create = imageryCreator(source); - const provider = await create({ signal: abort.signal }); - if (destroyed || generation !== generations.imagery) { - dispose(provider); - return; - } - const next = createImageryLayer(provider); - viewer.imageryLayers.add(next, 0); - if (layer) viewer.imageryLayers.remove(layer, true); - layer = next; - showCredit(imageryCredit(source)); + const provider = await providerFor(source); + if (destroyed || generation !== generations.imagery) return; + mapScene.showBaseImagery(provider); + mapScene.showCredit(imageryCredit(source)); requestRender('map-imagery'); }, async setTerrain(source) { @@ -120,9 +128,9 @@ export function createCesiumMaps( const generation = ++generations.photoreal; const { scene } = viewer; if (source == null) { - for (const tileset of tilesets()) tileset.show = false; + mapScene.showTileset(null, tilesets()); shownTileset = null; - scene.globe.show = true; + mapScene.showGlobe(true); requestRender('map-photoreal'); return; } @@ -148,10 +156,13 @@ export function createCesiumMaps( ownedTileset = tileset; } if (!scene.primitives.contains(tileset)) scene.primitives.add(tileset); - for (const other of tilesets()) other.show = other === tileset; - tileset.show = true; + // As MapSourceController activates a 3D stack: no base layer, the + // source's credit, one tileset shown and the globe hidden. + mapScene.removeBaseImagery(); + mapScene.showCredit(source.credit); shownTileset = tileset; - scene.globe.show = false; + mapScene.showTileset(tileset, tilesets()); + mapScene.showGlobe(false); requestRender('map-photoreal'); }, photorealActive: () => tilesets().some((tileset) => tileset.show === true), @@ -163,9 +174,10 @@ export function createCesiumMaps( if (destroyed) return; destroyed = true; abort.abort(); - if (layer) viewer.imageryLayers.remove(layer, true); - layer = null; - credits?.destroy(); + mapScene.destroy(); + for (const promise of providers.values()) + void promise.then(dispose, () => {}); + providers.clear(); if (ownedTileset) { viewer.scene.primitives.remove(ownedTileset); dispose(ownedTileset); diff --git a/src/globe/cesium/maps.test.mjs b/src/globe/cesium/maps.test.mjs index 3485199f9..f5c759c42 100644 --- a/src/globe/cesium/maps.test.mjs +++ b/src/globe/cesium/maps.test.mjs @@ -103,7 +103,7 @@ test('every catalog globe stack works as a registry source', async () => { }); test('a superseded imagery switch is dropped', async () => { - const { viewer, maps } = setup(); + const { viewer, maps, destroy } = setup(); let release; const slow = new Promise((resolve) => (release = resolve)); const first = maps.setBaseImagery({ @@ -127,7 +127,60 @@ test('a superseded imagery switch is dropped', async () => { viewer.imageryList.map((layer) => layer.provider.name), ['fast'], ); - assert.equal(stale.destroyed, true, 'the stale provider is disposed'); + assert.equal(stale.destroyed, false, 'the stale provider stays cached'); + destroy(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(stale.destroyed, true, 'destroy disposes cached providers'); +}); + +test('re-applying the same source keeps the live layer and its provider', async () => { + const { viewer, maps } = setup(); + let built = 0; + const esri = { + descriptor: { id: 'esri-imagery', kind: 'esri-imagery' }, + imagery: async () => ({ name: `esri ${++built}` }), + credit: ESRI_ATTRIBUTION_HTML, + }; + await maps.setBaseImagery(esri); + const live = viewer.imageryList[0]; + await maps.setBaseImagery(esri); + assert.equal(built, 1, 'the provider is cached per source'); + assert.equal(viewer.imageryList[0], live, 'no bare-globe rebuild'); + assert.equal(viewer.callsOf('imageryLayers.add').length, 1); + assert.equal(viewer.callsOf('imageryLayers.remove').length, 0); + await maps.setBaseImagery({ + descriptor: { id: 'osm', kind: 'osm' }, + imagery: () => ({ name: 'osm' }), + }); + await maps.setBaseImagery(esri); + assert.equal(built, 1); + assert.equal(viewer.imageryList[0].provider.name, 'esri 1'); +}); + +test('a failed provider keeps the current layer and can be retried', async () => { + const { viewer, maps } = setup(); + await maps.setBaseImagery({ + descriptor: { id: 'osm', kind: 'osm' }, + imagery: () => ({ name: 'osm' }), + }); + let attempts = 0; + const flaky = { + descriptor: { id: 'esri-imagery', kind: 'esri-imagery' }, + imagery: async () => { + if (++attempts === 1) throw new Error('Esri metadata refused'); + return { name: 'esri' }; + }, + credit: ESRI_ATTRIBUTION_HTML, + }; + await assert.rejects(maps.setBaseImagery(flaky), /metadata refused/); + assert.deepEqual( + viewer.imageryList.map((layer) => layer.provider.name), + ['osm'], + ); + assert.deepEqual(viewer.staticCredits, []); + await maps.setBaseImagery(flaky); + assert.equal(attempts, 2, 'the failure was not cached'); + assert.equal(viewer.imageryList[0].provider.name, 'esri'); }); test('setTerrain applies provider and terrain results', async () => { diff --git a/src/globe/cesium/picker.js b/src/globe/cesium/picker.js index ce0ed9bd0..51c5d3622 100644 --- a/src/globe/cesium/picker.js +++ b/src/globe/cesium/picker.js @@ -1,13 +1,18 @@ -// GlobePicker on a Cesium scene. Pick results keep Cesium's `id` and -// `primitive`; the untouched Cesium result rides along as `object` for -// Cesium-only callers. +// GlobePicker on a Cesium scene. Pick results keep Cesium's `id`, +// `primitive` and `content` (3D tile content, read by surface-pick checks); +// the untouched Cesium result rides along as `object` for Cesium-only callers. import { toCesium2, toGeo3 } from './convert.js'; const normalise = (picked) => picked == null ? undefined - : { id: picked.id, primitive: picked.primitive, object: picked }; + : { + id: picked.id, + primitive: picked.primitive, + content: picked.content, + object: picked, + }; /** * @param {Cesium.Viewer} viewer @@ -19,8 +24,10 @@ export function createCesiumPicker(viewer) { pick(screen, width, height) { return normalise(scene.pick(toCesium2(screen), width, height)); }, - drillPick(screen, limit) { - return scene.drillPick(toCesium2(screen), limit).map(normalise); + drillPick(screen, limit, width, height) { + return scene + .drillPick(toCesium2(screen), limit, width, height) + .map(normalise); }, pickPosition(screen) { if (!scene.pickPositionSupported) return undefined; diff --git a/src/globe/cesium/picker.test.mjs b/src/globe/cesium/picker.test.mjs index 000f3e0b2..5286b7d58 100644 --- a/src/globe/cesium/picker.test.mjs +++ b/src/globe/cesium/picker.test.mjs @@ -36,7 +36,7 @@ test('drillPick passes the limit and normalises every result', () => { viewer.calls.push({ name: 'scene.drillPick', args }); return [{ id: 'a', primitive: 1 }, { primitive: 2 }]; }; - const drilled = picker.drillPick(geo.cartesian2.create(1, 1), 3); + const drilled = picker.drillPick(geo.cartesian2.create(1, 1), 3, 5, 7); assert.deepEqual( drilled.map(({ id, primitive }) => [id, primitive]), [ @@ -45,9 +45,10 @@ test('drillPick passes the limit and normalises every result', () => { ], ); assert.ok('id' in drilled[1]); - const [screen, limit] = viewer.callsOf('scene.drillPick')[0].args; + const [screen, limit, width, height] = + viewer.callsOf('scene.drillPick')[0].args; assert.ok(screen instanceof Cesium.Cartesian2); - assert.equal(limit, 3); + assert.deepEqual([limit, width, height], [3, 5, 7]); }); test('pickPosition passes a new Cesium.Cartesian2 per call and returns geo', () => { @@ -78,3 +79,16 @@ test('pickPositionSupported follows the scene', () => { assert.equal(picker.pickPositionSupported, false); assert.equal(picker.pickPosition(geo.cartesian2.create(1, 1)), undefined); }); + +test('pick results carry 3D tile content for surface checks', () => { + const { picker, scene } = setup(); + const content = { name: 'tile content' }; + const tileset = { isCesium3DTileset: true }; + scene.pick = () => ({ content, primitive: tileset }); + const picked = picker.pick(geo.cartesian2.create(1, 1)); + assert.equal(picked.content, content); + assert.equal(picked.id, undefined); + assert.equal(picked.primitive, tileset); + scene.pick = () => ({ id: 'entity', primitive: {} }); + assert.equal(picker.pick(geo.cartesian2.create(1, 1)).content, undefined); +}); diff --git a/src/globe/contract.js b/src/globe/contract.js index e911b3c43..a0824bb79 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -44,14 +44,22 @@ * @typedef {{ heading: number, pitch: number, roll: number } * | { direction: Cartesian3, up: Cartesian3 }} Orientation * + * @typedef {{ west: number, south: number, east: number, north: number }} Rectangle + * Radians; west > east across the antimeridian. As a destination, the + * camera is placed so the rectangle fills the view (Cesium's + * getRectangleCameraCoordinates). + * * @typedef {object} FlightOptions - * @property {Cartesian3} destination + * @property {Cartesian3 | Rectangle} destination * @property {Orientation} [orientation] As in Cesium's setView and flyTo, * missing angles default to heading 0, pitch −π/2 and roll 0 (they do not * keep the current ones). * @property {number} [duration] Seconds. 0 (or less) sets the view and calls * `complete` synchronously, before flyTo returns, as Cesium does. - * @property {'cubicInOut'|'sinusoidalInOut'|'linear'} [easing] + * @property {'cubicInOut'|'sinusoidalInOut'|'linear'} [easing] Omitted: the + * engine's own flight easing (Cesium picks one from the flight's heights). + * @property {Matrix4} [endTransform] The camera transform once the flight + * lands; IDENTITY releases a lookAt lock. * @property {() => void} [complete] * @property {() => void} [cancel] Called by cancelFlight or a newer flight. * @@ -61,17 +69,21 @@ * * @typedef {object} SphereFlightOptions * Defaults are Cesium's: heading 0, pitch −π/4 and, when range is missing or - * 0, range = max(r / tan(fovx/2), r / tan(fovy/2)), never below 100 m. + * 0, range = 100 m for a sphere of radius 0, otherwise + * max(r / tan(fovx/2), r / tan(fovy/2)) clamped to the camera controller's + * zoom distances (Cesium: minimumZoomDistance 1 m to maximumZoomDistance ∞ + * by default). * @property {number} [heading] * @property {number} [pitch] * @property {number} [range] * @property {number} [duration] + * @property {'cubicInOut'|'sinusoidalInOut'|'linear'} [easing] As in FlightOptions. * @property {() => void} [complete] * @property {() => void} [cancel] * * @typedef {object} GlobeCamera * @property {() => CameraPose} pose Fresh values on every call. - * @property {(view: { destination: Cartesian3, orientation?: Orientation }) => void} setView + * @property {(view: { destination: Cartesian3 | Rectangle, orientation?: Orientation }) => void} setView * Missing angles default to heading 0, pitch −π/2, roll 0. * @property {(options: FlightOptions) => void} flyTo Cancels a running flight first. * @property {(sphere: { center: Cartesian3, radius: number }, options?: SphereFlightOptions) => void} flyToSphere @@ -135,11 +147,18 @@ * (Cesium: requestRenderMode and maximumRenderTimeChange = Infinity). * @property {() => boolean} isContinuous * + * @typedef {object} PickResult + * @property {any} id The picked entity or object id; undefined for map tiles. + * @property {any} primitive + * @property {any} [content] 3D tile content when map tiles were picked (what + * a surface-pick check reads), else undefined. + * @property {any} [object] The engine's own pick result, for engine-specific code. + * * @typedef {object} GlobeEngine * @property {'cesium'|'navara'} kind * @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host * @property {GlobeCamera} camera - * @property {{ pick: (screen: Cartesian2, width?: number, height?: number) => ({ id: any, primitive: any } | undefined), drillPick: (screen: Cartesian2, limit?: number) => any[], pickPosition: (screen: Cartesian2) => (Cartesian3 | undefined), pickPositionSupported: boolean }} picker + * @property {{ pick: (screen: Cartesian2, width?: number, height?: number) => (PickResult | undefined), drillPick: (screen: Cartesian2, limit?: number, width?: number, height?: number) => PickResult[], pickPosition: (screen: Cartesian2) => (Cartesian3 | undefined), pickPositionSupported: boolean }} picker * @property {GlobeProjector} projector * @property {{ preUpdate: EngineEvent, preRender: EngineEvent, postRender: EngineEvent }} frames Listeners receive a timestamp in ms. * @property {GlobeRender} render @@ -148,8 +167,10 @@ * 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) => void, setTerrain: (source: object) => void, setPhotorealTiles: (source: object | null) => void, photorealActive: () => boolean }} maps - * Sources are maps/registry.js descriptions; the engine builds its own providers. + * @property {{ setBaseImagery: (source: object) => Promise, setTerrain: (source: object) => Promise, setPhotorealTiles: (source: object | null) => Promise, 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). * @property {() => void} destroy */ diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index a77a095b0..be06e3936 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -238,8 +238,9 @@ export function createContractSuite( let rested = 0; const removeRest = camera.moveEnd.addEventListener(() => rested++); try { - await nadirView(); - await until(() => rested > 0, 'moveEnd after the nadir view'); + // A pose no other check uses, so this always is a move. + await nadirView(1.5e6); + await until(() => rested > 0, 'moveEnd after the resting move'); } finally { removeRest(); } @@ -900,7 +901,7 @@ export function createContractSuite( const { width, height } = engine.host.size(); const tanPhi = Math.tan(pose.fovy / 2); const tanTheta = (width / height) * tanPhi; - const range = Math.max(100, radius / tanTheta, radius / tanPhi); + const range = Math.max(radius / tanTheta, radius / tanPhi); near( cartesian3.distance(pose.position, center), range, @@ -1222,6 +1223,103 @@ export function createContractSuite( ensure(compared >= 2, 'too few screen points hit the globe'); }, ], + [ + 'camera.flyTo: endTransform IDENTITY releases a lookAt lock when it lands', + async () => { + const target = cartesian3.fromDegrees(-0.12, 51.5, 0); + const destination = cartesian3.fromDegrees(-0.12, 51.4, 20000); + const locked = () => { + camera.lookAt(target, { heading: 0, pitch: -0.7, range: 2000 }); + ensure( + camera + .transform() + .some((value, index) => value !== matrix4.IDENTITY[index]), + 'lookAt did not lock the camera', + ); + }; + const released = (label) => { + const transform = camera.transform(); + for (let index = 0; index < 16; index++) + near( + transform[index], + matrix4.IDENTITY[index], + 0, + `${label} transform[${index}]`, + ); + near( + cartesian3.distance(camera.pose().position, destination), + 0, + 0.01, + `${label} position error (m)`, + ); + }; + locked(); + camera.flyTo({ + destination, + duration: 0, + endTransform: matrix4.IDENTITY, + }); + released('duration 0'); + locked(); + let done = false; + camera.flyTo({ + destination, + duration: 0.25, + endTransform: matrix4.IDENTITY, + complete: () => (done = true), + }); + await until(() => done, 'the timed flight'); + released('timed'); + }, + ], + [ + 'camera.flyTo and setView: a rectangle destination fills the view', + async () => { + const rectangle = { + west: math.toRadians(2.2), + south: math.toRadians(48.8), + east: math.toRadians(2.5), + north: math.toRadians(48.9), + }; + const { width, height } = engine.host.size(); + const expectFit = (label) => { + const pose = camera.pose(); + near(pose.pitch, -math.PI_OVER_TWO, 1e-6, `${label} pitch`); + const middle = engine.projector.worldToWindow( + cartesian3.fromRadians(math.toRadians(2.35), math.toRadians(48.85)), + ); + ensure(middle, `${label}: the rectangle centre did not project`); + near(middle.x, width / 2, width * 0.05, `${label} centre x (px)`); + near(middle.y, height / 2, height * 0.05, `${label} centre y (px)`); + for (const [lon, lat] of [ + [rectangle.west, rectangle.south], + [rectangle.west, rectangle.north], + [rectangle.east, rectangle.south], + [rectangle.east, rectangle.north], + ]) { + const corner = engine.projector.worldToWindow( + cartesian3.fromRadians(lon, lat), + ); + ensure( + corner && + corner.x >= -1 && + corner.x <= width + 1 && + corner.y >= -1 && + corner.y <= height + 1, + `${label}: a corner is off screen (${corner?.x}, ${corner?.y})`, + ); + } + }; + await nadirView(); + camera.flyTo({ destination: rectangle, duration: 0 }); + await settle(); + expectFit('flyTo'); + await nadirView(); + camera.setView({ destination: rectangle }); + await settle(); + expectFit('setView'); + }, + ], [ 'projector.horizonVisible: the nadir is visible and the far side hidden', async () => { diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index b1d8bb0d6..2d5ee6b09 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -13,6 +13,9 @@ // (scene.cameraEventWaitTime) of wall time. // - nothing occludes picks or projection except where the contract says so // (horizonVisible); there is no terrain or scene geometry unless scripted. +// - a rectangle destination looks at the rectangle's centre from where its +// wider side fills the view; Cesium fits the corners. +// - zoom distances are Cesium's defaults (1 m to unbounded). import * as geo from '../../geo/index.js'; import { INPUT_TYPES, MODIFIERS, createEngineEvent } from '../contract.js'; @@ -23,7 +26,9 @@ const RADII = WGS84.radii; const MAXIMUM_RADIUS = Math.max(RADII.x, RADII.y, RADII.z); const DEFAULT_FLIGHT_SECONDS = 3; const DEFAULT_ZOOM_AMOUNT = 100000; // Cesium's camera.defaultZoomAmount -const MINIMUM_SPHERE_RANGE = 100; +const ZERO_RADIUS_SPHERE_RANGE = 100; // Cesium's MINIMUM_ZOOM +const MINIMUM_ZOOM_DISTANCE = 1; // Cesium's screenSpaceCameraController default +const MAXIMUM_ZOOM_DISTANCE = Infinity; const NEAR = 1; const FAR = 1e9; const SCRIPTABLE = new Set([ @@ -259,8 +264,31 @@ export function createFakeEngine(options = {}) { const poseAngles = () => headingPitchRoll(state.position, state.direction, state.up); + // Where a {west, south, east, north} destination is seen from. + function rectanglePosition({ west, south, east, north }) { + const lonSpan = east >= west ? east - west : east + math.TWO_PI - west; + const longitude = math.negativePiToPi(west + lonSpan / 2); + const latitude = (south + north) / 2; + const tanY = Math.tan(fovy / 2); + const radius = WGS84.maximumRadius; + // The widest parallel of the rectangle, and a small margin for curvature. + const widest = + south * north <= 0 + ? 1 + : Math.cos(Math.min(Math.abs(south), Math.abs(north))); + const across = + 1.05 * + Math.max( + (radius * widest * lonSpan) / 2 / (tanY * aspect()), + (radius * (north - south)) / 2 / tanY, + ); + return cartesian3.fromRadians(longitude, latitude, across, WGS84); + } + // The pose for a destination and orientation; missing angles use `fallback`. - function viewFor(position, orientation, fallback) { + function viewFor(destination, orientation, fallback) { + const position = + 'west' in destination ? rectanglePosition(destination) : destination; if (orientation && 'direction' in orientation) return { position: copy3(position), @@ -319,11 +347,17 @@ export function createFakeEngine(options = {}) { current?.cancel?.(); } + // Ends a flight: the end transform first, then the target pose. + function land(flightOptions, target) { + if (flightOptions.endTransform) setTransform(flightOptions.endTransform); + apply(target); + } + function startFlight(flightOptions, target) { cancelCurrentFlight(); const duration = flightOptions.duration ?? DEFAULT_FLIGHT_SECONDS; if (duration <= 0) { - apply(target); + land(flightOptions, target); flightOptions.complete?.(); return; } @@ -333,6 +367,7 @@ export function createFakeEngine(options = {}) { duration: duration * 1000, elapsed: 0, ease: EASINGS[flightOptions.easing] ?? EASINGS.cubicInOut, + endTransform: flightOptions.endTransform, complete: flightOptions.complete, cancel: flightOptions.cancel, }; @@ -353,7 +388,7 @@ export function createFakeEngine(options = {}) { } const current = flight; flight = null; - apply(current.target); + land(current, current.target); current.complete?.(); } @@ -593,10 +628,14 @@ export function createFakeEngine(options = {}) { if (!range) { const tanPhi = Math.tan(fovy / 2); const tanTheta = aspect() * tanPhi; - range = Math.max( - MINIMUM_SPHERE_RANGE, - Math.max(sphere.radius / tanTheta, sphere.radius / tanPhi), - ); + range = + sphere.radius === 0 + ? ZERO_RADIUS_SPHERE_RANGE + : math.clamp( + Math.max(sphere.radius / tanTheta, sphere.radius / tanPhi), + MINIMUM_ZOOM_DISTANCE, + MAXIMUM_ZOOM_DISTANCE, + ); } const frame = geo.transforms.eastNorthUpToFixedFrame( sphere.center, @@ -687,9 +726,9 @@ export function createFakeEngine(options = {}) { record('picker.pick', [screen, w, h]); return scripted('pick', [screen, w, h], () => undefined); }, - drillPick(screen, limit) { - record('picker.drillPick', [screen, limit]); - return scripted('drillPick', [screen, limit], () => []); + drillPick(screen, limit, w, h) { + record('picker.drillPick', [screen, limit, w, h]); + return scripted('drillPick', [screen, limit, w, h], () => []); }, pickPosition(screen) { record('picker.pickPosition', [screen]); @@ -774,15 +813,15 @@ export function createFakeEngine(options = {}) { }, }, maps: { - setBaseImagery(source) { + async setBaseImagery(source) { record('maps.setBaseImagery', [source]); fake.maps.imagery = source; }, - setTerrain(source) { + async setTerrain(source) { record('maps.setTerrain', [source]); fake.maps.terrain = source; }, - setPhotorealTiles(source) { + async setPhotorealTiles(source) { record('maps.setPhotorealTiles', [source]); fake.maps.photoreal = source ?? null; }, diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs index 133ef472a..a1bb820d4 100644 --- a/src/globe/testing/fakeEngine.test.mjs +++ b/src/globe/testing/fakeEngine.test.mjs @@ -278,3 +278,78 @@ test('fake terrain height and clamp results default to the bare ellipsoid and ca assert.equal(engine.surface.terrainHeightSupported, true); assert.equal(engine.surface.clampToHeightSupported, true); }); + +test('fake flights honour endTransform when they land', () => { + const engine = createFakeEngine(); + const target = geo.cartesian3.fromDegrees(10, 20, 0); + engine.camera.lookAt(target, { heading: 0, pitch: -0.5, range: 1000 }); + engine.camera.flyTo({ + destination: geo.cartesian3.fromDegrees(10, 20, 5000), + duration: 0.1, + endTransform: geo.matrix4.IDENTITY, + }); + assert.notDeepEqual(engine.camera.transform(), [...geo.matrix4.IDENTITY]); + engine.fake.frames(10, 16); + assert.deepEqual(engine.camera.transform(), [...geo.matrix4.IDENTITY]); +}); + +test('a fake rectangle destination looks down on the rectangle centre', () => { + const engine = createFakeEngine(); + const rectangle = { + west: geo.math.toRadians(2.2), + south: geo.math.toRadians(48.8), + east: geo.math.toRadians(2.5), + north: geo.math.toRadians(48.9), + }; + engine.camera.flyTo({ destination: rectangle, duration: 0 }); + const pose = engine.camera.pose(); + close(pose.pitch, -Math.PI / 2, 1e-9, 'pitch'); + close(pose.cartographic.longitude, geo.math.toRadians(2.35), 1e-9, 'lon'); + const { width, height } = engine.host.size(); + for (const [lon, lat] of [ + [rectangle.west, rectangle.south], + [rectangle.east, rectangle.north], + ]) { + const corner = engine.projector.worldToWindow( + geo.cartesian3.fromRadians(lon, lat, 0), + ); + assert.ok(corner.x >= -1 && corner.x <= width + 1, `x ${corner.x}`); + assert.ok(corner.y >= -1 && corner.y <= height + 1, `y ${corner.y}`); + } +}); + +test('fake sphere range follows Cesium: 100 m only for radius 0, no floor otherwise', () => { + const engine = createFakeEngine({ width: 1000, height: 500 }); + const center = geo.cartesian3.fromDegrees(0, 0, 0); + engine.camera.flyToSphere({ center, radius: 10 }, { duration: 0 }); + const tanPhi = Math.tan(engine.camera.pose().fovy / 2); + close( + geo.cartesian3.distance(engine.camera.pose().position, center), + Math.max(10 / (2 * tanPhi), 10 / tanPhi), + 1e-6, + 'range (m)', + ); + engine.camera.flyToSphere({ center, radius: 0 }, { duration: 0 }); + close( + geo.cartesian3.distance(engine.camera.pose().position, center), + 100, + 1e-6, + ); +}); + +test('fake drillPick records width and height; maps return promises', async () => { + const engine = createFakeEngine(); + engine.picker.drillPick(geo.cartesian2.create(1, 2), 3, 4, 5); + assert.deepEqual( + engine.fake.callsOf('picker.drillPick')[0].args.slice(1), + [3, 4, 5], + ); + for (const call of [ + engine.maps.setBaseImagery({ kind: 'osm' }), + engine.maps.setTerrain({ kind: 'ellipsoid' }), + engine.maps.setPhotorealTiles(null), + ]) { + assert.ok(call instanceof Promise); + await call; + } +}); diff --git a/src/maps/controller.js b/src/maps/controller.js index 1dad01ccf..7664442bc 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -1,6 +1,6 @@ import { indexMapSources } from './registry.js'; import * as Cesium from 'cesium'; -import { createMapCredits } from './credits.js'; +import { createMapScene } from './sceneApply.js'; import { applyTerrain } from './terrain.js'; import { acquireImageryComparison } from './imageryComparison.js'; @@ -32,8 +32,7 @@ export class MapSourceController { this._onChange = onChange; this._onError = onError; this._requestRender = requestRender; - this._createImageryLayer = createImageryLayer; - this._credits = createMapCredits(viewer); + this._scene = createMapScene(viewer, { createImageryLayer }); this._abort = new AbortController(); this._imageryProviders = new Map(); this._terrainProviders = new Map(); @@ -45,8 +44,6 @@ export class MapSourceController { this._switchOrigin = 'manual'; this._isSwitching = false; this._lastError = null; - this._imageryLayer = null; - this._activeImageryProvider = null; this._removeImageryErrorListener = null; this._terrainMode = null; this._subscribers = new Set(); @@ -245,43 +242,37 @@ export class MapSourceController { this._ownedTilesets.add(tileset); } this._removeImageryLayer(); - this._credits.show(source.credit || null); + this._scene.showCredit(source.credit); this._showTileset(tileset); - this.viewer.scene.globe.show = false; + this._scene.showGlobe(false); // Terrain is intentionally untouched while the globe is hidden. } _showTileset(active) { + const tilesets = []; for (const source of this._sources.values()) - if (source.tileset) source.tileset.show = source.tileset === active; - for (const tileset of this._ownedTilesets) - tileset.show = tileset === active; + if (source.tileset) tilesets.push(source.tileset); + this._scene.showTileset(active, [...tilesets, ...this._ownedTilesets]); } async _activateGlobeStack(stack, gen) { const resolution = await this._getImageryProvider(stack); if (gen !== this._switchGen) return; - // Scene shots reapply their map stack at every handoff. Keep the live - // layer (and its loaded tiles) when the resolved provider is unchanged; - // rebuilding it exposes the bare globe while imagery loads again. - if ( - !this._imageryLayer || - this._activeImageryProvider !== resolution.provider - ) { + // 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) { this._removeImageryLayer(); - this._imageryLayer = this._createImageryLayer(resolution.provider); - this._activeImageryProvider = resolution.provider; - this.viewer.imageryLayers.add(this._imageryLayer, 0); + this._scene.showBaseImagery(resolution.provider); } const source = this._sources.get(resolution.effectiveStackId); - this._credits.show(source?.credit || null); + this._scene.showCredit(source?.credit); // A repeated request still owns a new switch generation. Rebind its // failure listener so fallback remains live without accumulating listeners. this._removeImageryErrorListener?.(); this._removeImageryErrorListener = null; this._watchProvider(resolution, gen); this._showTileset(null); - this.viewer.scene.globe.show = true; + this._scene.showGlobe(true); this._activeId = resolution.effectiveStackId; if (source?.terrain && source.terrain.id !== this._terrainMode) { const terrain = source.terrain; @@ -346,7 +337,7 @@ export class MapSourceController { this._removeImageryErrorListener = errorEvent.addEventListener((error) => { if ( gen !== this._switchGen || - this._activeImageryProvider !== resolution.provider + this._scene.baseProvider !== resolution.provider ) return; const retryCount = Number(error?.timesRetried); @@ -381,10 +372,7 @@ export class MapSourceController { _removeImageryLayer() { this._removeImageryErrorListener?.(); this._removeImageryErrorListener = null; - if (this._imageryLayer) - this.viewer.imageryLayers.remove(this._imageryLayer, true); - this._imageryLayer = null; - this._activeImageryProvider = null; + this._scene.removeBaseImagery(); } _dispose(value) { if (!value || this._disposed.has(value)) return; @@ -400,7 +388,7 @@ export class MapSourceController { this._abort.abort(); this._isSwitching = false; this._removeImageryLayer(); - this._credits.destroy(); + this._scene.destroy(); for (const promise of this._imageryProviders.values()) void Promise.resolve(promise).then( (value) => this._dispose(value.provider), diff --git a/src/maps/sceneApply.js b/src/maps/sceneApply.js new file mode 100644 index 000000000..7f82a77af --- /dev/null +++ b/src/maps/sceneApply.js @@ -0,0 +1,58 @@ +import * as Cesium from 'cesium'; +import { createMapCredits } from './credits.js'; + +/** + * The scene changes a map source makes on a Cesium viewer: the base imagery + * layer, which 3D tileset shows, the globe, and the source's static credit. + * Shared by MapSourceController and the Cesium globe engine's `maps`, so + * both apply sources the same way. Creating it touches nothing. + * @param {Cesium.Viewer} viewer + * @param {{ createImageryLayer?: (provider: object) => object }} [options] + */ +export function createMapScene( + viewer, + { createImageryLayer = (provider) => new Cesium.ImageryLayer(provider) } = {}, +) { + const credits = createMapCredits(viewer); + let layer = null; + let provider = null; + const scene = { + /** The provider of the live base layer, or null. */ + get baseProvider() { + return provider; + }, + /** + * Show `next` as the bottom imagery layer. The live layer (and its loaded + * tiles) stays when its provider is unchanged: rebuilding it exposes the + * bare globe while imagery loads again. Returns whether it was replaced. + */ + showBaseImagery(next) { + if (layer && provider === next) return false; + scene.removeBaseImagery(); + layer = createImageryLayer(next); + provider = next; + viewer.imageryLayers.add(layer, 0); + return true; + }, + removeBaseImagery() { + if (layer) viewer.imageryLayers.remove(layer, true); + layer = null; + provider = null; + }, + /** Show `active` (or none) among `tilesets`. */ + showTileset(active, tilesets) { + for (const tileset of tilesets) tileset.show = tileset === active; + }, + showGlobe(visible) { + viewer.scene.globe.show = visible; + }, + showCredit(html) { + credits.show(html || null); + }, + destroy() { + scene.removeBaseImagery(); + credits.destroy(); + }, + }; + return scene; +} diff --git a/src/maps/sceneApply.test.mjs b/src/maps/sceneApply.test.mjs new file mode 100644 index 000000000..76aec8a5e --- /dev/null +++ b/src/maps/sceneApply.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createMapScene } from './sceneApply.js'; + +function fixture() { + const layers = []; + const removed = []; + const credits = []; + const viewer = { + scene: { + globe: { show: true }, + frameState: { + creditDisplay: { + addStaticCredit: (credit) => credits.push(credit), + removeStaticCredit: (credit) => + credits.splice(credits.indexOf(credit), 1), + }, + }, + }, + imageryLayers: { + add: (layer, index) => layers.splice(index ?? layers.length, 0, layer), + remove(layer, destroy) { + layers.splice(layers.indexOf(layer), 1); + removed.push({ layer, destroy }); + }, + }, + }; + const scene = createMapScene(viewer, { + createImageryLayer: (provider) => ({ provider }), + }); + return { viewer, scene, layers, removed, credits }; +} + +test('creating the map scene touches nothing', () => { + const { viewer, layers, credits } = fixture(); + assert.deepEqual(layers, []); + assert.deepEqual(credits, []); + assert.equal(viewer.scene.globe.show, true); +}); + +test('showBaseImagery puts a new layer at the bottom and replaces the old one', () => { + const { scene, layers, removed } = fixture(); + layers.push({ name: 'overlay' }); + const first = { name: 'esri' }; + assert.equal(scene.showBaseImagery(first), true); + assert.deepEqual(layers[0], { provider: first }); + assert.equal(scene.baseProvider, first); + const second = { name: 'osm' }; + assert.equal(scene.showBaseImagery(second), true); + assert.deepEqual( + layers.map((layer) => layer.provider?.name ?? layer.name), + ['osm', 'overlay'], + ); + assert.deepEqual(removed, [{ layer: { provider: first }, destroy: true }]); +}); + +test('re-applying the same provider keeps the live layer', () => { + const { scene, layers, removed } = fixture(); + const provider = { name: 'esri' }; + scene.showBaseImagery(provider); + const live = layers[0]; + assert.equal(scene.showBaseImagery(provider), false); + assert.equal(layers[0], live); + assert.deepEqual(removed, []); +}); + +test('removeBaseImagery, tilesets, globe and credits', () => { + const { viewer, scene, layers, credits } = fixture(); + scene.showBaseImagery({ name: 'esri' }); + scene.removeBaseImagery(); + scene.removeBaseImagery(); + assert.deepEqual(layers, []); + assert.equal(scene.baseProvider, null); + const a = { show: true }; + const b = { show: false }; + scene.showTileset(b, [a, b]); + assert.deepEqual([a.show, b.show], [false, true]); + scene.showTileset(null, [a, b]); + assert.deepEqual([a.show, b.show], [false, false]); + scene.showGlobe(false); + assert.equal(viewer.scene.globe.show, false); + scene.showCredit('Esri'); + assert.equal(credits.length, 1); + scene.showCredit(null); + assert.equal(credits.length, 0); + scene.showCredit('Esri'); + scene.showBaseImagery({ name: 'osm' }); + scene.destroy(); + assert.deepEqual(layers, []); + assert.deepEqual(credits, []); +});