diff --git a/src/queries/heights.js b/src/queries/heights.js index b0dd6fcdd..fad95c524 100644 --- a/src/queries/heights.js +++ b/src/queries/heights.js @@ -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') diff --git a/src/queries/heights.test.mjs b/src/queries/heights.test.mjs index 0cd2620be..21afb3fc6 100644 --- a/src/queries/heights.test.mjs +++ b/src/queries/heights.test.mjs @@ -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( diff --git a/src/render/cesium/appRenderer.js b/src/render/cesium/appRenderer.js index 84da62a5d..fc80c8743 100644 --- a/src/render/cesium/appRenderer.js +++ b/src/render/cesium/appRenderer.js @@ -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, diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index fdda47b35..9ebda81ce 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -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', diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index 0036ca0e8..9feb8fa18 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -14,13 +14,20 @@ import { createCesiumSceneRenderer } from './sceneRenderer.js'; /** @type {ReadonlySet} */ 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', ]); diff --git a/src/render/cesium/internals.js b/src/render/cesium/internals.js index c7585803a..ab6386cfc 100644 --- a/src/render/cesium/internals.js +++ b/src/render/cesium/internals.js @@ -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; +} diff --git a/src/render/cesium/internals.test.mjs b/src/render/cesium/internals.test.mjs index fb596d152..dc78d1671 100644 --- a/src/render/cesium/internals.test.mjs +++ b/src/render/cesium/internals.test.mjs @@ -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); diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index d28410613..dc4494f1c 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -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} [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}} 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() { diff --git a/src/render/cesium/ports.test.mjs b/src/render/cesium/ports.test.mjs new file mode 100644 index 000000000..235bffdb6 --- /dev/null +++ b/src/render/cesium/ports.test.mjs @@ -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(); +}); diff --git a/src/render/cesium/sceneRenderer.js b/src/render/cesium/sceneRenderer.js index 02d7774df..a96aa5dbc 100644 --- a/src/render/cesium/sceneRenderer.js +++ b/src/render/cesium/sceneRenderer.js @@ -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}} 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 }; } diff --git a/src/render/conformance/checks/ports.js b/src/render/conformance/checks/ports.js index ba4338975..005ba916f 100644 --- a/src/render/conformance/checks/ports.js +++ b/src/render/conformance/checks/ports.js @@ -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])