mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 20:34:02 +00:00
Complete the Cesium query port
The Cesium renderer now has every QueryPort member: picks and drill picks, depth and globe picks, rays, projection, occlusion, heights now and streamed, clamping and ray casts. Capabilities come from what the scene supports (depth textures for pickPosition, sampleHeight, finestHeights and rayCast), in both the conformance and app renderers. - Picks skip unpickable and hidden items, and report each item once. A pick under an unpickable item stops at anything that hides the rest. drillPick's limit counts after that filtering, so 0 gives none. - Height queries and ray casts see through sets not drawn with includeInHeightQueries, and through the sets a caller excludes, by their items' pick ids. The list is built once per change to the sets. - `tiles` samples and casts run with the globe hidden for the call; `terrain` casts use the globe alone. A sample reports whether it came from tiles, terrain, the ellipsoid or a set. - A position clampToSurface cannot read is a miss, not a failed batch. - Terrain on an ellipsoid is height 0. Before, sampling it threw, for want of tile availability. whenSettled also waits for icon images, and fails when one cannot load. The conformance checks that pick or query settle first, because a renderer answers from what it has loaded; the stub settles at once. The Cesium renderer now passes 14 checks in the browser, and its visual references are unchanged.
This commit is contained in:
1 parent
92fd2b0c2e
commit
bdc7692292
11 files changed
+618
-48
No files matched your search
@@ -135,6 +135,10 @@ function createHeights(scene) {
|
||||
if (against === 'terrain') {
|
||||
const provider = terrainProvider();
|
||||
if (!provider) return cartos.map(() => null);
|
||||
// An ellipsoid has no tiles to sample (Cesium throws for want of
|
||||
// their availability): its terrain is the ellipsoid, height 0.
|
||||
if (provider instanceof Cesium.EllipsoidTerrainProvider)
|
||||
return cartos.map((carto) => (carto ? 0 : null));
|
||||
await Cesium.sampleTerrainMostDetailed(provider, valid);
|
||||
} else {
|
||||
if (typeof scene.sampleHeightMostDetailed !== 'function')
|
||||
|
||||
@@ -128,6 +128,16 @@ test('batch leaves the caller’s Cartographics alone', async () => {
|
||||
assert.equal(carto.height, 999);
|
||||
});
|
||||
|
||||
test('terrain on an ellipsoid is height 0, without sampling tiles', async () => {
|
||||
const s = scene({ terrainProvider: new Cesium.EllipsoidTerrainProvider() });
|
||||
assert.deepEqual(
|
||||
await heightsFor(s).batch([AUSTIN, { lon: 'x', lat: 1 }], {
|
||||
against: 'terrain',
|
||||
}),
|
||||
[0, null],
|
||||
);
|
||||
});
|
||||
|
||||
test('batch misses everything when the scene cannot stream, and rejects when it fails', async () => {
|
||||
assert.deepEqual(await heightsFor(scene()).batch([AUSTIN]), [null]);
|
||||
const failing = scene({
|
||||
@@ -193,9 +203,10 @@ test('height queries go through the service', () => {
|
||||
// A renderer that answers heights from a cache or asynchronously plugs in
|
||||
// at src/queries/heights.js; a site that asks Cesium directly bypasses it.
|
||||
const SERVICE = 'src/queries/heights.js';
|
||||
// The renderer contract's own height members, and the stub and suite that
|
||||
// implement and call them, are not Cesium.
|
||||
const CONTRACT = /^src\/render\/(contract|stub|conformance)\//;
|
||||
// The renderer contract's own height members, and the renderers and suite
|
||||
// that implement and call them: the Cesium adapter answers them through
|
||||
// this service.
|
||||
const CONTRACT = /^src\/render\/(contract|stub|conformance|cesium)\//;
|
||||
const NAMES =
|
||||
'sampleHeight|clampToHeight|sampleHeightMostDetailed|clampToHeightMostDetailed|getHeight';
|
||||
const direct = new RegExp(
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { createApplicationViewer } from '../../app/viewer.js';
|
||||
import { CONTRACT_VERSION } from '../contract/index.js';
|
||||
import { createCesiumPorts } from './ports.js';
|
||||
import { cesiumCapabilities, createCesiumPorts } from './ports.js';
|
||||
|
||||
/**
|
||||
* The members a transitional renderer has today.
|
||||
@@ -83,9 +83,11 @@ export function createCesiumRendererPlugin({
|
||||
document.body.appendChild(creditContainer);
|
||||
let viewer;
|
||||
let ports;
|
||||
let capabilities;
|
||||
try {
|
||||
viewer = createViewer({ container, creditContainer });
|
||||
ports = createCesiumPorts(viewer);
|
||||
capabilities = new ReadonlySet(cesiumCapabilities(viewer.scene));
|
||||
ports = createCesiumPorts(viewer, { capabilities });
|
||||
} catch (error) {
|
||||
if (viewer && !viewer.isDestroyed()) viewer.destroy();
|
||||
creditContainer.remove();
|
||||
@@ -94,7 +96,7 @@ export function createCesiumRendererPlugin({
|
||||
const renderer = Object.freeze({
|
||||
id: 'cesium',
|
||||
contractVersion: CONTRACT_VERSION,
|
||||
capabilities: new ReadonlySet(['models']),
|
||||
capabilities,
|
||||
canvas: viewer.canvas,
|
||||
frame: ports.frame,
|
||||
draw: ports.draw,
|
||||
|
||||
@@ -343,17 +343,6 @@ const EXPECTED_MISSING = [
|
||||
'frame.setTargetFps',
|
||||
'frame.setTime',
|
||||
'frame.suspend',
|
||||
'query.clampToSurface',
|
||||
'query.drillPick',
|
||||
'query.isOccluded',
|
||||
'query.pickGlobe',
|
||||
'query.pickPosition',
|
||||
'query.pickRay',
|
||||
'query.projectMany',
|
||||
'query.rayCast',
|
||||
'query.sampleHeight',
|
||||
'query.sampleHeightNow',
|
||||
'query.sampleHeightNowMany',
|
||||
'renderer.credits',
|
||||
'renderer.effects',
|
||||
'renderer.globe',
|
||||
|
||||
@@ -14,13 +14,20 @@ import { createCesiumSceneRenderer } from './sceneRenderer.js';
|
||||
|
||||
/** @type {ReadonlySet<string>} */
|
||||
export const CESIUM_CHECKS = new Set([
|
||||
'draw.drillPick',
|
||||
'draw.ids',
|
||||
'draw.models',
|
||||
'draw.pick',
|
||||
'draw.sets',
|
||||
'frame.order',
|
||||
'frame.renderOnce',
|
||||
'frame.time',
|
||||
'globe.sky',
|
||||
'query.heights',
|
||||
'query.heights.sets',
|
||||
'query.projectMany',
|
||||
'query.rays',
|
||||
'query.withoutCapability',
|
||||
'renderer.whenSettled',
|
||||
]);
|
||||
|
||||
|
||||
@@ -93,3 +93,14 @@ export function cameraFlightActive(camera) {
|
||||
export function updateCameraChanged(camera) {
|
||||
camera._updateCameraChanged();
|
||||
}
|
||||
|
||||
/**
|
||||
* The error a billboard's image failed to load with, if it did: Cesium
|
||||
* keeps `ready` false then, so `whenSettled` reports the failure instead of
|
||||
* waiting out its timeout.
|
||||
* @param {object} billboard Cesium Billboard.
|
||||
* @returns {Error|undefined}
|
||||
*/
|
||||
export function billboardLoadError(billboard) {
|
||||
return billboard.loadError;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
billboardCollectionOf,
|
||||
billboardLoadError,
|
||||
cameraFlightActive,
|
||||
closeRenderLoopLatch,
|
||||
clusterPointCollection,
|
||||
@@ -50,6 +51,11 @@ test('every private field Core reaches is still declared by the installed Cesium
|
||||
assert.deepEqual(missing, []);
|
||||
const camera = fs.readFileSync(path.join(ENGINE, 'Scene/Camera.js'), 'utf8');
|
||||
assert.match(camera, /Camera\.prototype\._updateCameraChanged = function/);
|
||||
const billboard = fs.readFileSync(
|
||||
path.join(ENGINE, 'Scene/Billboard.js'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(billboard, /\n {2}loadError: \{/, 'Billboard#loadError');
|
||||
});
|
||||
|
||||
test('the helpers read and write exactly those fields', () => {
|
||||
@@ -75,6 +81,8 @@ test('the helpers read and write exactly those fields', () => {
|
||||
closeRenderLoopLatch({ _cesiumWidget: widget });
|
||||
assert.equal(widget._renderLoopRunning, false);
|
||||
closeRenderLoopLatch({});
|
||||
const error = new Error('404');
|
||||
assert.equal(billboardLoadError({ loadError: error }), error);
|
||||
const gl = {};
|
||||
assert.equal(webglContext({ context: { _gl: gl } }), gl);
|
||||
assert.equal(cameraFlightActive({ _currentFlight: {} }), true);
|
||||
|
||||
+359
-21
@@ -11,6 +11,8 @@
|
||||
* absent, never stubbed.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { heightsFor } from '../../queries/heights.js';
|
||||
import { billboardLoadError } from './internals.js';
|
||||
|
||||
/** The contract's colour as Cesium's. */
|
||||
export function toColor(color, alpha = 1) {
|
||||
@@ -45,9 +47,29 @@ const ANCHORS = {
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {object} widget A Cesium `CesiumWidget` or `Viewer`.
|
||||
* What a Cesium scene can do, as contract capabilities; `only` limits them
|
||||
* to those it lists. Depth picks, height samples and ray casts all need
|
||||
* depth textures, which `sampleHeightSupported` and `pickPositionSupported`
|
||||
* report.
|
||||
* @param {object} scene
|
||||
* @param {Iterable<string>} [only]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function createCesiumPorts(widget) {
|
||||
export function cesiumCapabilities(scene, only) {
|
||||
const all = ['models'];
|
||||
if (scene.pickPositionSupported) all.push('pickPosition');
|
||||
if (scene.sampleHeightSupported)
|
||||
all.push('sampleHeight', 'finestHeights', 'rayCast');
|
||||
const allowed = only && new Set(only);
|
||||
return all.filter((c) => !allowed || allowed.has(c));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} widget A Cesium `CesiumWidget` or `Viewer`.
|
||||
* @param {{capabilities: ReadonlySet<string>}} options The renderer's
|
||||
* capabilities, which gate the members that need them.
|
||||
*/
|
||||
export function createCesiumPorts(widget, { capabilities }) {
|
||||
const scene = widget.scene;
|
||||
const camera = scene.camera;
|
||||
const sets = new Map();
|
||||
@@ -132,32 +154,40 @@ export function createCesiumPorts(widget) {
|
||||
if (sets.has(spec.id)) throw new Error(`a set with id ${spec.id} is live`);
|
||||
const items = new Map();
|
||||
const listeners = { ready: [] };
|
||||
const record = { kind, spec, items, listeners, host: null, rebuild: null };
|
||||
const record = { kind, spec, items, listeners, host: null, pickIds: [] };
|
||||
// Each build records its items' pick ids, for height queries to see
|
||||
// through.
|
||||
const rebuild = () => {
|
||||
record.pickIds = [];
|
||||
build(record);
|
||||
setsVersion += 1;
|
||||
};
|
||||
const handle = {
|
||||
id: spec.id,
|
||||
set(list) {
|
||||
items.clear();
|
||||
for (const item of list) items.set(item.id, item);
|
||||
build(record);
|
||||
rebuild();
|
||||
scene.requestRender();
|
||||
},
|
||||
upsert(list) {
|
||||
for (const item of list) items.set(item.id, item);
|
||||
build(record);
|
||||
rebuild();
|
||||
scene.requestRender();
|
||||
},
|
||||
remove(ids) {
|
||||
for (const id of ids) items.delete(id);
|
||||
build(record);
|
||||
rebuild();
|
||||
scene.requestRender();
|
||||
},
|
||||
clear() {
|
||||
items.clear();
|
||||
build(record);
|
||||
rebuild();
|
||||
scene.requestRender();
|
||||
},
|
||||
setOptions(options) {
|
||||
Object.assign(spec, options);
|
||||
setsVersion += 1;
|
||||
if (record.host) record.host.show = spec.show !== false;
|
||||
scene.requestRender();
|
||||
},
|
||||
@@ -184,18 +214,25 @@ export function createCesiumPorts(widget) {
|
||||
record.host = null;
|
||||
// A newer set under the same id is not this handle's to remove.
|
||||
if (sets.get(spec.id) === record) sets.delete(spec.id);
|
||||
setsVersion += 1;
|
||||
scene.requestRender();
|
||||
},
|
||||
};
|
||||
sets.set(spec.id, record);
|
||||
setsVersion += 1;
|
||||
return handle;
|
||||
}
|
||||
|
||||
const pickId = (record, item) => ({
|
||||
set: record.spec.id,
|
||||
id: item.id,
|
||||
meta: item.meta,
|
||||
});
|
||||
function pickId(record, item) {
|
||||
const id = {
|
||||
set: record.spec.id,
|
||||
id: item.id,
|
||||
meta: item.meta,
|
||||
lodGroup: item.lodGroup,
|
||||
};
|
||||
record.pickIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function replaceHost(record, host) {
|
||||
if (record.host) scene.primitives.remove(record.host);
|
||||
@@ -542,13 +579,196 @@ export function createCesiumPorts(widget) {
|
||||
},
|
||||
};
|
||||
|
||||
// --- Query -----------------------------------------------------------------
|
||||
|
||||
const screen = (x, y) => new Cesium.Cartesian2(x, y);
|
||||
const ecef = (c) => (c ? { x: c.x, y: c.y, z: c.z } : null);
|
||||
const finite = (p) =>
|
||||
Number.isFinite(p?.x) && Number.isFinite(p?.y) && Number.isFinite(p?.z);
|
||||
const cesiumRay = (ray) =>
|
||||
new Cesium.Ray(toCartesian(ray.origin), toCartesian(ray.direction));
|
||||
|
||||
/** The live set and item a picked object belongs to, or null. */
|
||||
function ownerOf(picked) {
|
||||
const id = picked?.id;
|
||||
if (!id || typeof id.set !== 'string') return null;
|
||||
const record = sets.get(id.set);
|
||||
const item = record?.items.get(id.id);
|
||||
return record && item ? { record, item, id } : null;
|
||||
}
|
||||
|
||||
const pickable = ({ record, item }) =>
|
||||
record.host?.show !== false &&
|
||||
record.spec.pickable !== false &&
|
||||
item.show !== false &&
|
||||
item.pickable !== false;
|
||||
|
||||
function hitOf({ id }) {
|
||||
return {
|
||||
set: id.set,
|
||||
id: id.id,
|
||||
...(id.meta === undefined ? {} : { meta: id.meta }),
|
||||
...(id.lodGroup === undefined ? {} : { lodGroup: id.lodGroup }),
|
||||
surface: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The globe (or ellipsoid) under a pixel, as a surface hit. */
|
||||
function surfaceHit(x, y) {
|
||||
const position = query.pickPosition(x, y) ?? query.pickEllipsoid(x, y);
|
||||
return position ? { set: '', id: '', surface: true, position } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pickable draw-set items under a pixel, nearest first, once each (a
|
||||
* shape's fill and outline share one id). `untilForeign` stops at the first
|
||||
* thing that is not a draw-set item (a tile, the app's own primitives):
|
||||
* what is behind it is hidden.
|
||||
*/
|
||||
function drill(x, y, { width = 3, height = 3, limit, untilForeign } = {}) {
|
||||
const hits = [];
|
||||
if (limit === 0) return hits;
|
||||
const seen = new Set();
|
||||
for (const picked of scene.drillPick(
|
||||
screen(x, y),
|
||||
undefined,
|
||||
width,
|
||||
height,
|
||||
)) {
|
||||
const owner = ownerOf(picked);
|
||||
if (!owner) {
|
||||
if (untilForeign) break;
|
||||
continue;
|
||||
}
|
||||
const key = `${owner.id.set}\u0000${owner.id.id}`;
|
||||
if (!pickable(owner) || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
hits.push(hitOf(owner));
|
||||
if (limit !== undefined && hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
// What height queries see through, by their items' pick ids: the sets not
|
||||
// drawn as ground, and the sets a caller excludes. Built once per change
|
||||
// to the sets, not per query.
|
||||
let setsVersion = 0;
|
||||
const seeThroughCache = new Map();
|
||||
let seeThroughVersion = -1;
|
||||
function seeThrough(exclude, { allSets = false } = {}) {
|
||||
if (seeThroughVersion !== setsVersion) {
|
||||
seeThroughCache.clear();
|
||||
seeThroughVersion = setsVersion;
|
||||
}
|
||||
const key = `${allSets}\u0000${exclude?.join('\u0000') ?? ''}`;
|
||||
let out = seeThroughCache.get(key);
|
||||
if (!out) {
|
||||
out = [];
|
||||
for (const record of sets.values())
|
||||
if (
|
||||
allSets ||
|
||||
!record.spec.includeInHeightQueries ||
|
||||
exclude?.includes(record.spec.id)
|
||||
)
|
||||
for (const id of record.pickIds) out.push(id);
|
||||
seeThroughCache.set(key, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether a set drawn as ground is in the scene, other than `exclude`. */
|
||||
const groundSets = (exclude) =>
|
||||
[...sets.values()].some(
|
||||
(record) =>
|
||||
record.spec.includeInHeightQueries &&
|
||||
record.host?.show !== false &&
|
||||
!exclude?.includes(record.spec.id),
|
||||
);
|
||||
|
||||
const heights = () => heightsFor(scene);
|
||||
const can = (capability) => capabilities.has(capability);
|
||||
const globeShown = () => Boolean(scene.globe?.show);
|
||||
|
||||
/**
|
||||
* `fn` with the globe hidden, so a pick or sample sees tiles and sets
|
||||
* alone. Synchronous: the globe is shown again before anything draws.
|
||||
*/
|
||||
function withoutGlobe(fn) {
|
||||
if (!globeShown()) return fn();
|
||||
scene.globe.show = false;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
scene.globe.show = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** A height now from what is loaded, against the surface asked for. */
|
||||
function heightNow(point, against, excluded) {
|
||||
if (against === 'terrain')
|
||||
return heights().now(point, { against: 'terrain' });
|
||||
const sample = () => heights().now(point, { exclude: excluded });
|
||||
return against === 'tiles' ? withoutGlobe(sample) : sample();
|
||||
}
|
||||
|
||||
const close = (a, b) => a !== null && b !== null && Math.abs(a - b) <= 0.01;
|
||||
const ellipsoidTerrain = () =>
|
||||
scene.terrainProvider instanceof Cesium.EllipsoidTerrainProvider;
|
||||
|
||||
/**
|
||||
* Where a height came from, told apart by sampling again without the
|
||||
* ground sets and against the terrain alone.
|
||||
*/
|
||||
function sourceOf(point, height, against, exclude) {
|
||||
const terrain = () => (ellipsoidTerrain() ? 'ellipsoid' : 'terrain');
|
||||
if (against === 'terrain') return terrain();
|
||||
if (groundSets(exclude)) {
|
||||
const bare = heightNow(
|
||||
point,
|
||||
against,
|
||||
seeThrough(exclude, { allSets: true }),
|
||||
);
|
||||
if (!close(bare, height)) return 'set';
|
||||
}
|
||||
if (against === 'tiles' || !globeShown()) return 'tiles';
|
||||
return close(heights().now(point, { against: 'terrain' }), height)
|
||||
? terrain()
|
||||
: 'tiles';
|
||||
}
|
||||
|
||||
const query = {
|
||||
pick(x, y) {
|
||||
const picked = scene.pick(new Cesium.Cartesian2(x, y));
|
||||
const id = picked?.id;
|
||||
return id && typeof id.set === 'string'
|
||||
? { ...id, surface: false }
|
||||
: null;
|
||||
pick(x, y, window = {}) {
|
||||
const owner = ownerOf(
|
||||
scene.pick(screen(x, y), window.width ?? 3, window.height ?? 3),
|
||||
);
|
||||
if (owner && pickable(owner)) return hitOf(owner);
|
||||
// Something unpickable is on top: the first pickable item under it,
|
||||
// unless something else hides it.
|
||||
if (owner) {
|
||||
const [under] = drill(x, y, {
|
||||
...window,
|
||||
limit: 1,
|
||||
untilForeign: true,
|
||||
});
|
||||
if (under) return under;
|
||||
}
|
||||
return surfaceHit(x, y);
|
||||
},
|
||||
drillPick: (x, y, options = {}) =>
|
||||
drill(x, y, { ...options, untilForeign: false }),
|
||||
pickPosition(x, y) {
|
||||
if (!can('pickPosition') || !scene.pickPositionSupported) return null;
|
||||
return ecef(scene.pickPosition(screen(x, y)));
|
||||
},
|
||||
pickEllipsoid(x, y) {
|
||||
return ecef(camera.pickEllipsoid(screen(x, y)));
|
||||
},
|
||||
pickGlobe(ray) {
|
||||
return ecef(scene.globe?.pick(cesiumRay(ray), scene));
|
||||
},
|
||||
pickRay(x, y) {
|
||||
const ray = camera.getPickRay(screen(x, y));
|
||||
return { origin: ecef(ray.origin), direction: ecef(ray.direction) };
|
||||
},
|
||||
project(position) {
|
||||
const s = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
@@ -557,9 +777,107 @@ export function createCesiumPorts(widget) {
|
||||
);
|
||||
return s ? { x: s.x, y: s.y } : null;
|
||||
},
|
||||
pickEllipsoid(x, y) {
|
||||
const c = camera.pickEllipsoid(new Cesium.Cartesian2(x, y));
|
||||
return c ? { x: c.x, y: c.y, z: c.z } : null;
|
||||
projectMany(source, out) {
|
||||
const p = new Cesium.Cartesian3();
|
||||
const s = new Cesium.Cartesian2();
|
||||
for (let i = 0, j = 0; i + 2 < source.length; i += 3, j += 2) {
|
||||
p.x = source[i];
|
||||
p.y = source[i + 1];
|
||||
p.z = source[i + 2];
|
||||
const w = Cesium.SceneTransforms.worldToWindowCoordinates(scene, p, s);
|
||||
out[j] = w ? w.x : NaN;
|
||||
out[j + 1] = w ? w.y : NaN;
|
||||
}
|
||||
},
|
||||
isOccluded(position) {
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84,
|
||||
camera.positionWC,
|
||||
);
|
||||
return !occluder.isPointVisible(toCartesian(position));
|
||||
},
|
||||
sampleHeightNow(point, { against = 'surface', exclude } = {}) {
|
||||
if (!can('sampleHeight')) return null;
|
||||
return heightNow(point, against, seeThrough(exclude));
|
||||
},
|
||||
sampleHeightNowMany(points, out, { against = 'surface', exclude } = {}) {
|
||||
const excluded = can('sampleHeight') && seeThrough(exclude);
|
||||
const point = { lon: 0, lat: 0 };
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
point.lon = points[i * 2];
|
||||
point.lat = points[i * 2 + 1];
|
||||
out[i] = excluded ? (heightNow(point, against, excluded) ?? NaN) : NaN;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Cesium streams the finest tiles for every sample, `mostDetailed` or
|
||||
* not. Against `tiles` it streams them, then samples them with the
|
||||
* globe hidden, which only a synchronous sample can do.
|
||||
*/
|
||||
async sampleHeight(points, { against = 'surface', exclude } = {}) {
|
||||
if (!can('sampleHeight')) return points.map(() => null);
|
||||
const excluded = seeThrough(exclude);
|
||||
let found = await heights().batch(
|
||||
points,
|
||||
against === 'terrain' ? { against: 'terrain' } : { exclude: excluded },
|
||||
);
|
||||
if (against === 'tiles')
|
||||
found = points.map((point) => heightNow(point, 'tiles', excluded));
|
||||
const lod = can('finestHeights') ? 'finest' : 'visible';
|
||||
return found.map((height, i) =>
|
||||
height === null
|
||||
? null
|
||||
: {
|
||||
height,
|
||||
confidence: 1,
|
||||
source: sourceOf(points[i], height, against, exclude),
|
||||
lod,
|
||||
},
|
||||
);
|
||||
},
|
||||
async clampToSurface(positions, options = {}) {
|
||||
const points = positions.map((p) => {
|
||||
const c =
|
||||
finite(p) && Cesium.Cartographic.fromCartesian(toCartesian(p));
|
||||
return c
|
||||
? {
|
||||
lon: Cesium.Math.toDegrees(c.longitude),
|
||||
lat: Cesium.Math.toDegrees(c.latitude),
|
||||
}
|
||||
: null;
|
||||
});
|
||||
const samples = await query.sampleHeight(
|
||||
points.map((p) => p ?? { lon: NaN, lat: NaN }),
|
||||
options,
|
||||
);
|
||||
return samples.map((sample, i) =>
|
||||
sample && points[i]
|
||||
? ecef(
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
points[i].lon,
|
||||
points[i].lat,
|
||||
sample.height,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
async rayCast(
|
||||
ray,
|
||||
{ against = 'surface', exclude, maxDistance = Infinity } = {},
|
||||
) {
|
||||
if (!can('rayCast')) return null;
|
||||
const r = cesiumRay(ray);
|
||||
let position;
|
||||
if (against === 'terrain') position = scene.globe?.pick(r, scene);
|
||||
else {
|
||||
const cast = () => scene.pickFromRay(r, seeThrough(exclude))?.position;
|
||||
position = against === 'tiles' ? withoutGlobe(cast) : cast();
|
||||
}
|
||||
if (!position) return null;
|
||||
return Cesium.Cartesian3.distance(r.origin, position) <= maxDistance
|
||||
? ecef(position)
|
||||
: null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -574,6 +892,25 @@ export function createCesiumPorts(widget) {
|
||||
const modelSets = () =>
|
||||
[...sets.values()].filter((r) => r.kind === 'models' && r.host);
|
||||
|
||||
/**
|
||||
* The shown icons' images, for `whenSettled`: `failed` when one could not
|
||||
* load, `loading` while one still is, otherwise `settled`.
|
||||
*/
|
||||
function iconState() {
|
||||
let loading = false;
|
||||
for (const record of sets.values()) {
|
||||
const host = record.kind === 'icons' && record.host;
|
||||
if (!host || !host.show) continue;
|
||||
for (let i = 0; i < host.length; i++) {
|
||||
const billboard = host.get(i);
|
||||
if (!billboard.show || billboard.ready) continue;
|
||||
if (billboardLoadError(billboard)) return 'failed';
|
||||
loading = true;
|
||||
}
|
||||
}
|
||||
return loading ? 'loading' : 'settled';
|
||||
}
|
||||
|
||||
return {
|
||||
frame,
|
||||
draw,
|
||||
@@ -581,6 +918,7 @@ export function createCesiumPorts(widget) {
|
||||
query,
|
||||
capture,
|
||||
modelSets,
|
||||
iconState,
|
||||
nextPostRender,
|
||||
/** Detach from the scene; the widget's owner destroys the widget. */
|
||||
dispose() {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { cesiumCapabilities, createCesiumPorts } from './ports.js';
|
||||
|
||||
test('capabilities follow what the scene supports', () => {
|
||||
assert.deepEqual(cesiumCapabilities({}), ['models']);
|
||||
assert.deepEqual(cesiumCapabilities({ pickPositionSupported: true }), [
|
||||
'models',
|
||||
'pickPosition',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
cesiumCapabilities({
|
||||
pickPositionSupported: true,
|
||||
sampleHeightSupported: true,
|
||||
}),
|
||||
['models', 'pickPosition', 'sampleHeight', 'finestHeights', 'rayCast'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a caller can ask for fewer capabilities, never more', () => {
|
||||
const scene = { pickPositionSupported: true, sampleHeightSupported: true };
|
||||
assert.deepEqual(cesiumCapabilities(scene, ['models', 'rayCast']), [
|
||||
'models',
|
||||
'rayCast',
|
||||
]);
|
||||
assert.deepEqual(cesiumCapabilities({}, new Set(['sampleHeight'])), []);
|
||||
});
|
||||
|
||||
/** A widget whose scene records what the query port asks of it. */
|
||||
function fakeWidget(overrides = {}) {
|
||||
const listeners = new Set();
|
||||
const event = () => ({
|
||||
addEventListener(cb) {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
},
|
||||
});
|
||||
const calls = [];
|
||||
const primitives = [];
|
||||
const scene = {
|
||||
camera: {},
|
||||
primitives: {
|
||||
add: (p) => primitives.push(p),
|
||||
remove: (p) => primitives.splice(primitives.indexOf(p), 1),
|
||||
},
|
||||
requestRender() {},
|
||||
preUpdate: event(),
|
||||
preRender: event(),
|
||||
postRender: event(),
|
||||
sampleHeightSupported: true,
|
||||
pickPositionSupported: true,
|
||||
globe: { show: true, getHeight: () => 0 },
|
||||
sampleHeight(carto, exclude) {
|
||||
calls.push({ exclude, globe: scene.globe.show });
|
||||
return 10;
|
||||
},
|
||||
drillPick: () => [],
|
||||
pick: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
return { widget: { scene, clock: {} }, scene, calls };
|
||||
}
|
||||
|
||||
const all = new Set(['models', 'pickPosition', 'sampleHeight', 'rayCast']);
|
||||
const point = (id) => ({
|
||||
id,
|
||||
pos: { x: 6378137, y: 0, z: 0 },
|
||||
pixelSize: 4,
|
||||
color: '#fff',
|
||||
});
|
||||
|
||||
test('height queries see through very large sets, built once per change', () => {
|
||||
const { widget, calls } = fakeWidget();
|
||||
const ports = createCesiumPorts(widget, { capabilities: all });
|
||||
const big = ports.draw.points({ id: 'big' });
|
||||
big.set(Array.from({ length: 150_000 }, (_, i) => point(String(i))));
|
||||
assert.equal(ports.query.sampleHeightNow({ lon: 0, lat: 0 }), 10);
|
||||
assert.equal(calls[0].exclude.length, 150_000, 'every item seen through');
|
||||
const out = new Float64Array(3);
|
||||
ports.query.sampleHeightNowMany(new Float64Array(6), out);
|
||||
assert.ok(calls.slice(1).every((c) => c.exclude === calls[0].exclude));
|
||||
big.setOptions({ includeInHeightQueries: true });
|
||||
ports.query.sampleHeightNow({ lon: 0, lat: 0 });
|
||||
assert.equal(calls.at(-1).exclude.length, 0, 'a ground set is not');
|
||||
ports.query.sampleHeightNow({ lon: 0, lat: 0 }, { exclude: ['big'] });
|
||||
assert.equal(calls.at(-1).exclude.length, 150_000, 'unless excluded');
|
||||
ports.dispose();
|
||||
});
|
||||
|
||||
test('tiles are sampled with the globe hidden, and the globe comes back', () => {
|
||||
const { widget, scene, calls } = fakeWidget();
|
||||
const ports = createCesiumPorts(widget, { capabilities: all });
|
||||
ports.query.sampleHeightNow({ lon: 0, lat: 0 }, { against: 'tiles' });
|
||||
assert.equal(calls[0].globe, false);
|
||||
assert.equal(scene.globe.show, true);
|
||||
ports.dispose();
|
||||
});
|
||||
|
||||
test('drillPick: limit 0 is none, each item once, unpickable skipped', () => {
|
||||
let picked = [];
|
||||
const { widget } = fakeWidget({ drillPick: () => picked });
|
||||
const ports = createCesiumPorts(widget, { capabilities: all });
|
||||
const set = ports.draw.points({ id: 's' });
|
||||
set.set([point('a'), point('b'), { ...point('c'), pickable: false }]);
|
||||
const id = (item) => ({ id: { set: 's', id: item } });
|
||||
picked = [id('a'), id('a'), id('c'), id('b')];
|
||||
assert.deepEqual(ports.query.drillPick(1, 1, { limit: 0 }), []);
|
||||
assert.deepEqual(
|
||||
ports.query.drillPick(1, 1).map((h) => h.id),
|
||||
['a', 'b'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
ports.query.drillPick(1, 1, { limit: 1 }).map((h) => h.id),
|
||||
['a'],
|
||||
);
|
||||
ports.dispose();
|
||||
});
|
||||
|
||||
test('pick under something unpickable stops at what hides the rest', () => {
|
||||
let picked = [];
|
||||
const { widget } = fakeWidget({
|
||||
pick: () => picked[0],
|
||||
drillPick: () => picked,
|
||||
pickPosition: () => ({ x: 1, y: 2, z: 3 }),
|
||||
});
|
||||
const ports = createCesiumPorts(widget, { capabilities: all });
|
||||
const set = ports.draw.points({ id: 's', pickable: false });
|
||||
set.set([point('top')]);
|
||||
const under = ports.draw.points({ id: 'u' });
|
||||
under.set([point('under')]);
|
||||
const top = { id: { set: 's', id: 'top' } };
|
||||
picked = [top, { id: { set: 'u', id: 'under' } }];
|
||||
assert.equal(ports.query.pick(1, 1).id, 'under');
|
||||
picked = [top, { primitive: 'a tile' }, { id: { set: 'u', id: 'under' } }];
|
||||
const hit = ports.query.pick(1, 1);
|
||||
assert.equal(hit.surface, true, 'the tile hides it: the surface');
|
||||
ports.dispose();
|
||||
});
|
||||
|
||||
test('rayCast answers against the surface asked for', async () => {
|
||||
const seen = [];
|
||||
const { widget } = fakeWidget({
|
||||
pickFromRay: () => {
|
||||
seen.push(widget.scene.globe.show);
|
||||
return { position: { x: 6378137, y: 0, z: 0 } };
|
||||
},
|
||||
});
|
||||
widget.scene.globe.pick = () => ({ x: 6378000, y: 0, z: 0 });
|
||||
const ports = createCesiumPorts(widget, { capabilities: all });
|
||||
const ray = {
|
||||
origin: { x: 7e6, y: 0, z: 0 },
|
||||
direction: { x: -1, y: 0, z: 0 },
|
||||
};
|
||||
assert.equal(
|
||||
(await ports.query.rayCast(ray, { against: 'terrain' })).x,
|
||||
6378000,
|
||||
);
|
||||
assert.equal(
|
||||
(await ports.query.rayCast(ray, { against: 'tiles' })).x,
|
||||
6378137,
|
||||
);
|
||||
assert.deepEqual(seen, [false], 'tiles cast with the globe hidden');
|
||||
assert.equal(
|
||||
await ports.query.rayCast(ray, { maxDistance: 100 }),
|
||||
null,
|
||||
'beyond maxDistance',
|
||||
);
|
||||
ports.dispose();
|
||||
});
|
||||
|
||||
test('clampToSurface misses a position it cannot read, not the batch', async () => {
|
||||
const { widget } = fakeWidget({
|
||||
sampleHeightMostDetailed: async (cartos) => {
|
||||
for (const c of cartos) c.height = 5;
|
||||
return cartos;
|
||||
},
|
||||
});
|
||||
const ports = createCesiumPorts(widget, { capabilities: all });
|
||||
const [bad, nan, good] = await ports.query.clampToSurface([
|
||||
{ x: Infinity, y: 0, z: 0 },
|
||||
{ x: NaN, y: 0, z: 0 },
|
||||
{ x: 6378137, y: 0, z: 0 },
|
||||
]);
|
||||
assert.equal(bad, null);
|
||||
assert.equal(nan, null);
|
||||
assert.ok(Math.abs(Math.hypot(good.x, good.y, good.z) - 6378142) < 1e-6);
|
||||
ports.dispose();
|
||||
});
|
||||
@@ -15,7 +15,7 @@
|
||||
* Members it does not have are absent, never stubbed.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { createCesiumPorts } from './ports.js';
|
||||
import { cesiumCapabilities, createCesiumPorts } from './ports.js';
|
||||
|
||||
export { toCartesian, toColor, toImage } from './ports.js';
|
||||
|
||||
@@ -29,9 +29,6 @@ const SKY_DEFAULTS = Object.freeze({
|
||||
lighting: false,
|
||||
});
|
||||
|
||||
/** What this renderer can do; a caller may ask for fewer. */
|
||||
const CAPABILITIES = ['models'];
|
||||
|
||||
/**
|
||||
* @param {{container: HTMLElement, msaa?: number, capabilities?: Iterable<string>}} options
|
||||
* `capabilities` limits the renderer to those it lists.
|
||||
@@ -68,10 +65,7 @@ export async function createCesiumSceneRenderer({
|
||||
if (scene.moon) scene.moon.show = false;
|
||||
|
||||
const sky = { ...SKY_DEFAULTS };
|
||||
const allowed = only && new Set(only);
|
||||
const capabilities = new Set(
|
||||
CAPABILITIES.filter((c) => !allowed || allowed.has(c)),
|
||||
);
|
||||
const capabilities = new Set(cesiumCapabilities(scene, only));
|
||||
|
||||
function applySky() {
|
||||
scene.globe.enableLighting = sky.lighting;
|
||||
@@ -82,7 +76,7 @@ export async function createCesiumSceneRenderer({
|
||||
scene.requestRender();
|
||||
}
|
||||
|
||||
const ports = createCesiumPorts(widget);
|
||||
const ports = createCesiumPorts(widget, { capabilities });
|
||||
const frame = {
|
||||
...ports.frame,
|
||||
setTime(ms) {
|
||||
@@ -128,13 +122,17 @@ export async function createCesiumSceneRenderer({
|
||||
await ports.nextPostRender();
|
||||
if (models().some((r) => r.host.failed > 0))
|
||||
return { state: 'failed', blockers: ['models'] };
|
||||
const loading = models().some((r) => r.host.loading > 0);
|
||||
const icons = ports.iconState();
|
||||
if (icons === 'failed') return { state: 'failed', blockers: ['icons'] };
|
||||
const loading =
|
||||
models().some((r) => r.host.loading > 0) || icons === 'loading';
|
||||
quiet = scene.globe.tilesLoaded && !loading ? quiet + 1 : 0;
|
||||
if (quiet >= 3) return { state: 'settled', blockers: [] };
|
||||
}
|
||||
const blockers = [];
|
||||
if (!scene.globe.tilesLoaded) blockers.push('globe');
|
||||
if (models().some((r) => r.host.loading > 0)) blockers.push('models');
|
||||
if (ports.iconState() !== 'settled') blockers.push('icons');
|
||||
return { state: 'timeout', blockers };
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,16 @@ async function overAustin(h, r, height = 2000) {
|
||||
await h.frame(r);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until what is drawn has loaded (icon images, tiles, models), as a
|
||||
* query answers from what is loaded. A renderer that draws synchronously is
|
||||
* settled at once.
|
||||
*/
|
||||
async function settled(r) {
|
||||
const result = await r.whenSettled({ timeoutMs: 20000 });
|
||||
equal(result.state, 'settled', `settled (${result.blockers.join(', ')})`);
|
||||
}
|
||||
|
||||
async function drawn(h, r, frames = 2) {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
r.frame.requestRender('conformance');
|
||||
@@ -486,6 +496,7 @@ export function portChecks() {
|
||||
{ id: 'one', pos, image: DOT, size: [24, 24], meta: { n: 1 } },
|
||||
]);
|
||||
await drawn(h, r);
|
||||
await settled(r);
|
||||
const s = r.query.project(pos);
|
||||
ok(s, 'the icon projects');
|
||||
const hit = r.query.pick(s.x, s.y);
|
||||
@@ -1162,6 +1173,7 @@ export function portChecks() {
|
||||
'pickRay starts at the eye; pickEllipsoid lies on its ray; pickGlobe agrees',
|
||||
async (h, r) => {
|
||||
await overAustin(h, r, 5000);
|
||||
await settled(r);
|
||||
const eye = r.camera.pose().position;
|
||||
const ray = r.query.pickRay(640, 360);
|
||||
nearVec(ray.origin, eye, 1e-3, 'ray origin');
|
||||
@@ -1267,6 +1279,7 @@ export function portChecks() {
|
||||
async (h, r) => {
|
||||
await overAustin(h, r);
|
||||
await drawn(h, r);
|
||||
await settled(r);
|
||||
const point = { ...AUSTIN };
|
||||
const before = await r.query.sampleHeight([point]);
|
||||
const shapes = r.draw.shapes({ id: 'conf:not-ground' });
|
||||
@@ -1291,6 +1304,7 @@ export function portChecks() {
|
||||
},
|
||||
]);
|
||||
await drawn(h, r);
|
||||
await settled(r);
|
||||
const after = await r.query.sampleHeight([point]);
|
||||
equal(after[0] === null, before[0] === null, 'answered as before');
|
||||
if (after[0] && before[0])
|
||||
|
||||
Reference in new issue
Block a user