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:
German Khokhlov committed 2026-10-15 17:55:00 +04:00
1 parent d831cbd23a
commit ab2368f3f3
18 files changed
+658 -117

No files matched your search

+2
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -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,
},
+52 -11
View File
@@ -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],
);
}
});
+4
View File
@@ -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 = {}) {
+5 -1
View File
@@ -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;
+25
View File
@@ -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
View File
@@ -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);
+55 -2
View File
@@ -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 () => {
+13 -6
View File
@@ -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;
+17 -3
View File
@@ -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
View File
@@ -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
*/
+101 -3
View File
@@ -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 () => {
+53 -14
View File
@@ -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;
},
+75
View File
@@ -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
View File
@@ -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),
+58
View File
@@ -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;
}
+91
View File
@@ -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, []);
});