mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix(globe): tighten the Cesium engine after review
Map sources are applied through one src/maps/sceneApply.js helper shared by MapSourceController and engine.maps (live layer kept for an unchanged provider, providers cached per source). The contract gains flyTo endTransform and rectangle destinations, flyToSphere easing, drillPick width/height and pick content, with Cesium's own flyToSphere range. Input skips listeners removed during dispatch and stays inert after destroy.
This commit is contained in:
1 parent
d831cbd23a
commit
ab2368f3f3
18 files changed
+658
-117
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+23
-16
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+38
-26
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+28
-7
@@ -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<void>, setTerrain: (source: object) => Promise<void>, setPhotorealTiles: (source: object | null) => Promise<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).
|
||||
* @property {() => void} destroy
|
||||
*/
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
+16
-28
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('<b>Esri</b>');
|
||||
assert.equal(credits.length, 1);
|
||||
scene.showCredit(null);
|
||||
assert.equal(credits.length, 0);
|
||||
scene.showCredit('<b>Esri</b>');
|
||||
scene.showBaseImagery({ name: 'osm' });
|
||||
scene.destroy();
|
||||
assert.deepEqual(layers, []);
|
||||
assert.deepEqual(credits, []);
|
||||
});
|
||||
Reference in new issue
Block a user