diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index 69fe6933b..2c096f571 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -12,7 +12,12 @@ not through Cesium or Navara. The contract has five drawing primitives: Each engine implements all five. Cesium's are in `src/globe/cesium/`, Navara's in `src/globe/navara/`, and the fake engine's in `src/globe/testing/fakeEngine.js`. The checks every engine must pass are in -`src/globe/testing/contractSuite.mjs`. +`src/globe/testing/contractSuite/index.mjs`. + +A new contract member lands in one change with its implementation on Cesium, +Navara and the fake engine, a behavioral check in the contract suite, and a +mutant that the check kills. Keep check names prefixed with the member they +exercise; optional behavior belongs in `engineCapabilities`. ## Movers: things at a point @@ -602,7 +607,7 @@ Run `createContractSuite(engine, { waitFrames, ... })` from pixel reads for browser checks; keep the fake-engine tests as fast contract checks. Test ownership and repeated destruction as well as visible output. The browser harness is `scripts/qa-contract-suite.mjs`; use -`--only=ground:,imagery:` for ground and imagery pixel checks. +`--only=ground.,imagery.` for ground and imagery pixel checks. Three reports keep migration debt from growing: `scripts/report-engine-coupling.mjs` counts application coupling, `scripts/report-qa-harness.mjs` counts browser QA diff --git a/scripts/contract-mutants.test.mjs b/scripts/contract-mutants.test.mjs new file mode 100644 index 000000000..96252b91a --- /dev/null +++ b/scripts/contract-mutants.test.mjs @@ -0,0 +1,367 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createFakeEngine } from '../src/globe/testing/fakeEngine.js'; +import { fakeContractOptions } from '../src/globe/testing/contractFixtures.js'; +import { runContractSuite } from '../src/globe/testing/contractSuite/index.mjs'; + +const replace = (object, member, wrap) => { + object[member] = wrap(object[member].bind(object)); +}; +const handle = (engine, area, factory, mutate) => + replace(engine[area], factory, (create) => (...args) => { + const value = create(...args); + mutate(value); + return value; + }); + +const mutants = [ + [ + 'retarget omits followChanged', + 'camera.following: set, retarget', + (e) => { + let previous; + replace(e.camera.followChanged, 'raiseEvent', (raise) => (value) => { + if (value !== previous) raise(value); + previous = value; + }); + }, + ], + [ + 'pointer cancels an active flight', + 'camera.flyTo: a pointer gesture', + (e) => { + replace(e.fake, 'input', (input) => (type, event) => { + if (type === 'down') e.camera.cancelFlight(); + input(type, event); + }); + }, + ], + [ + 'terrain errors are labelled as tiles', + 'maps.errors: terrain failures', + (e) => { + replace( + e.maps.errors, + 'raiseEvent', + (raise) => (error) => raise({ ...error, kind: 'tile' }), + ); + }, + ], + [ + 'interrupted flight omits cancel', + 'camera.flyTo: a newer flight', + (e) => { + replace( + e.camera, + 'flyTo', + (fly) => (options) => fly({ ...options, cancel: undefined }), + ); + }, + ], + [ + 'pure rotation omits changed', + 'camera.changed: a pure heading', + (e) => { + let previous = e.camera.pose().position; + replace(e.camera.changed, 'raiseEvent', (raise) => (...args) => { + const position = e.camera.pose().position; + const moved = + Math.hypot( + position.x - previous.x, + position.y - previous.y, + position.z - previous.z, + ) > 0.01; + previous = position; + if (moved) raise(...args); + }); + }, + ], + [ + 'request during render is lost', + 'render.request: a request inside', + (e) => { + let inFrame = false; + e.frames.preRender.addEventListener(() => { + inFrame = true; + }); + e.frames.postRender.addEventListener(() => { + inFrame = false; + }); + replace(e.render, 'request', (request) => (...args) => { + if (!inFrame) request(...args); + }); + }, + ], + [ + 'terrain revision stays fixed', + 'maps.terrainRevision: changes when setTerrain', + (e) => { + e.maps.terrainRevision = () => 0; + }, + ], + [ + 'removed mover remains listed', + 'movers.list: shown follows', + (e) => { + const removed = []; + const list = e.movers.list; + handle(e, 'movers', 'createSet', (set) => { + replace(set, 'destroy', (destroy) => () => { + const row = list().find(({ id }) => id === set.id); + if (row) removed.push(row); + destroy(); + }); + }); + e.movers.list = () => [...list(), ...removed]; + }, + ], + [ + 'stop omits followChanged', + 'camera.following: set, retarget', + (e) => { + replace(e.camera.followChanged, 'raiseEvent', (raise) => (value) => { + if (value !== null) raise(value); + }); + }, + ], + [ + 'following forgets the handle', + 'camera.following: set, retarget', + (e) => { + e.camera.following = () => null; + }, + ], + [ + 'retarget keeps the old target', + 'camera.following: set, retarget', + (e) => { + handle(e, 'camera', 'follow', (follow) => { + follow.retarget = () => {}; + }); + }, + ], + [ + 'texture limit is not a power of two', + 'render.maxTextureSize:', + (e) => { + e.render.maxTextureSize = () => 3; + }, + ], + [ + 'continuous mode never changes', + 'render.setContinuous: continuous', + (e) => { + e.render.setContinuous = () => {}; + }, + ], + [ + 'clock is never latched', + 'clock.frameTime: advances before', + (e) => { + e.clock.frameTime = () => 0; + }, + ], + [ + 'terrain samples are reversed', + 'surface.sampleTerrainMostDetailed: preserves', + (e) => { + replace( + e.surface, + 'sampleTerrainMostDetailed', + (sample) => + async (...args) => + (await sample(...args)).reverse(), + ); + }, + ], + [ + 'unknown terrain becomes zero', + 'surface.sampleTerrainMostDetailed: preserves', + (e) => { + replace( + e.surface, + 'sampleTerrainMostDetailed', + (sample) => + async (...args) => + (await sample(...args)).map((height) => height ?? 0), + ); + }, + ], + [ + 'terrain rejection is swallowed', + 'surface.sampleTerrainMostDetailed: passes', + (e) => { + replace( + e.surface, + 'sampleTerrainMostDetailed', + (sample) => + async (...args) => { + try { + return await sample(...args); + } catch { + return []; + } + }, + ); + }, + ], + [ + 'failed model reports no failure', + 'MoverSet.failed:', + (e) => { + handle(e, 'movers', 'createSet', (set) => { + set.failed = () => false; + }); + }, + ], + [ + 'failed model reports ready', + 'MoverSet.failed:', + (e) => { + handle(e, 'movers', 'createSet', (set) => { + set.ready = () => true; + }); + }, + ], + [ + 'hidden mover reports shown', + 'movers.list: shown follows', + (e) => { + replace( + e.movers, + 'list', + (list) => () => list().map((row) => ({ ...row, shown: true })), + ); + }, + ], + [ + 'map error loses HTTP status', + 'maps.errors: forwards source', + (e) => { + replace( + e.maps.errors, + 'raiseEvent', + (raise) => (error) => raise({ ...error, status: undefined }), + ); + }, + ], + [ + 'map errors are swallowed', + 'maps.errors: forwards source', + (e) => { + e.maps.errors.raiseEvent = () => {}; + }, + ], + [ + 'old map providers keep reporting', + 'maps.errors: forwards source', + (e) => { + replace(e.maps, 'setBaseImagery', (set) => async (source) => { + await set(source); + source.provider?.errorEvent?.addEventListener((error) => { + e.maps.errors.raiseEvent({ + source, + kind: 'tile', + status: error?.statusCode, + }); + }); + }); + }, + ], + [ + 'flight remains active after completion', + 'camera.flyTo: duration 0', + (e) => { + e.camera.flightActive = () => true; + }, + ], + [ + 'setView leaves the flight running', + 'camera.setView: cancels an active', + (e) => { + e.camera.setView = ({ destination, orientation }) => + e.fake.setPose({ position: destination, ...orientation }); + }, + ], + [ + 'instant flight omits complete', + 'camera.flyTo: duration 0', + (e) => { + replace( + e.camera, + 'flyTo', + (fly) => (options) => fly({ ...options, complete: undefined }), + ); + }, + ], + [ + 'hidden meshes remain shown', + 'meshes.createMesh: picks use', + (e) => { + handle(e, 'meshes', 'createMesh', (mesh) => { + replace( + mesh, + 'show', + (show) => (value) => show(value === false ? true : value), + ); + }); + }, + ], + [ + 'imagery ignores alpha changes', + 'imagery.createLayer: layers are', + (e) => { + handle(e, 'imagery', 'createLayer', (layer) => { + layer.setAlpha = () => {}; + }); + }, + ], + [ + 'clearing tiles forgets base imagery', + 'maps.setBaseImagery: setPhotorealTiles', + (e) => { + replace(e.maps, 'setPhotorealTiles', (set) => async (source) => { + await set(source); + if (source === null) e.fake.maps.imagery = null; + }); + }, + ], + [ + 'tiles-only ground also draws on terrain', + 'ground.createSet: tiles drape', + (e) => { + replace( + e.ground, + 'createSet', + (create) => (options) => + create({ + ...options, + drape: options.drape === 'tiles' ? 'both' : options.drape, + }), + ); + }, + ], +]; + +for (const [name, killedBy, mutate] of mutants) { + test(`${name} — killed by ${killedBy}`, async () => { + const engine = createFakeEngine(); + try { + mutate(engine); + const results = await runContractSuite(engine, { + ...fakeContractOptions(engine), + maxWaitFrames: 160, + }); + const check = results.find(({ name }) => name.startsWith(killedBy)); + assert.ok(check, `No check named ${killedBy}`); + assert.equal(check.ok, false, `Surviving mutant: ${name}`); + assert.ok( + check.error.name === 'ContractError' || + check.error.message === 'No frame requested', + `Unexpected failure: ${check.error.stack}`, + ); + } finally { + engine.destroy(); + } + }); +} diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 053c8c2d6..c446dc929 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2285,7 +2285,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/globe/contract.js" ], "external": ["cesium"] }, diff --git a/scripts/qa-contract-suite.mjs b/scripts/qa-contract-suite.mjs index 04ba768a3..fb0410eaa 100644 --- a/scripts/qa-contract-suite.mjs +++ b/scripts/qa-contract-suite.mjs @@ -26,13 +26,22 @@ const option = (name) => args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); if (!origin) { console.error( - 'Usage: node scripts/qa-contract-suite.mjs [--engine=cesium,navara] [--only=prefix,...] [--json=FILE]', + 'Usage: node scripts/qa-contract-suite.mjs [--engine=cesium,navara] [--only=prefix,...] [--dpr=1|2] [--json=FILE]', ); process.exit(2); } const engines = (option('engine') ?? 'cesium,navara').split(','); // --only=prefix,prefix runs just the checks whose names start with one. -const only = option('only')?.split(',') ?? null; +const only = + option('only') + ?.split(',') + .map((prefix) => + /^(ground|imagery|movers|lines|meshes|maps):$/.test(prefix) + ? prefix.replace(':', '.') + : prefix, + ) ?? null; +const dpr = Number(option('dpr') ?? 1); +if (![1, 2].includes(dpr)) throw new Error('--dpr must be 1 or 2'); // Checks of state the running app drives itself, by name prefix, with the // owner. The app sets contact sonar on every frame (src/cyberSonarEngine.js), @@ -43,13 +52,15 @@ const APP_OWNED = Object.freeze([ async function runOn(browser, kind) { const page = await browser.newPage(); - await page.setViewport({ width: 1280, height: 800 }); + await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: dpr }); const pageErrors = []; page.on('pageerror', (error) => pageErrors.push(error.message)); await page.setRequestInterception(true); page.on('request', (request) => { const { hostname } = new URL(request.url()); - if (BLOCKED_HOSTS.test(hostname)) request.abort(); + if (new URL(request.url()).pathname === '/__contract_missing_model.glb') + request.respond({ status: 404, body: 'Model unavailable' }); + else if (BLOCKED_HOSTS.test(hostname)) request.abort(); else request.continue(); }); // A view in the link: the app starts there instead of flying to a city. @@ -139,25 +150,53 @@ async function runOn(browser, kind) { await page.mouse.move(...at); } }); + await page.exposeFunction('__contractDrag', async (x, y, endX, endY) => { + const box = await page.evaluate(() => { + const rect = + window.__godsEyeView.engine.host.canvas.getBoundingClientRect(); + return { left: rect.left, top: rect.top }; + }); + await page.mouse.move(box.left + x, box.top + y); + await page.mouse.down(); + await page.mouse.move(box.left + endX, box.top + endY, { steps: 4 }); + await page.mouse.up(); + }); + await page.exposeFunction('__contractResize', async (width, height) => { + await page.setViewport({ + width: Math.round(width), + height: Math.round(height), + deviceScaleFactor: dpr, + }); + }); const results = await page.evaluate( async (appOwned, only) => { const { engine } = window.__godsEyeView; const waitFrames = (count) => - new Promise((resolve) => { + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + remove(); + reject(new Error('Timed out waiting for a rendered frame')); + }, 10_000); let left = count; const remove = engine.frames.postRender.addEventListener(() => { if (--left > 0) return; remove(); + clearTimeout(timer); resolve(); }); - engine.render.request('contract-suite'); }); const pointer = { click: (x, y) => window.__contractPointer('click', x, y), move: (x, y) => window.__contractPointer('move', x, y), + drag: (x, y, endX, endY) => window.__contractDrag(x, y, endX, endY), }; const { createContractSuite } = await import('/src/globe/testing/contractSuite.mjs'); + const fixtureModule = + engine.kind === 'cesium' + ? await import('/src/globe/cesium/testing/contractFixtures.js') + : await import('/src/globe/navara/testing/contractFixtures.js'); + const fixtures = await fixtureModule.createContractFixtures(); const outcome = []; // A real 2D canvas: a sprite atlas the engines can draw and pick. const makeCanvas = (width, height) => @@ -189,6 +228,8 @@ async function runOn(browser, kind) { pointer, readRegion, makeCanvas, + fixtures, + resize: (width, height) => window.__contractResize(width, height), })) { if (only && !only.some((prefix) => name.startsWith(prefix))) continue; const owner = appOwned.find(([prefix]) => name.startsWith(prefix)); @@ -196,6 +237,12 @@ async function runOn(browser, kind) { outcome.push({ name, skipped: owner[1] }); continue; } + const knownFailure = + engine.kind === 'navara' && + name === + 'meshes.createMesh: a single colour draws as asked within 10 per channel' + ? 'Meshes are drawn before tone mapping' + : null; try { await run(); outcome.push({ name, ok: true }); @@ -203,24 +250,33 @@ async function runOn(browser, kind) { outcome.push({ name, ok: false, + ...(knownFailure && + String(error?.message).startsWith( + 'mesh colour within 10 per channel', + ) && { knownFailure }), error: String(error?.message ?? error), }); } } + fixtures.destroy?.(); return outcome; }, APP_OWNED, only, ); await page.close(); - return { engine: kind, results, pageErrors }; + return { engine: kind, dpr, results, pageErrors }; } const browser = await puppeteer.launch({ headless: true, // A Navara page can hold its main thread for minutes while it starts. protocolTimeout: 600_000, - args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'], + args: [ + `--use-angle=${process.env.QA_ANGLE ?? (process.platform === 'darwin' ? 'metal' : 'swiftshader')}`, + '--enable-gpu', + '--no-sandbox', + ], }); const report = []; try { @@ -229,13 +285,16 @@ try { report.push(run); const ran = run.results.filter(({ skipped }) => !skipped); const failed = ran.filter(({ ok }) => !ok); + const known = failed.filter(({ knownFailure }) => knownFailure); console.log( - `${kind}: ${ran.length - failed.length}/${ran.length} checks pass`, + `${kind} DPR ${dpr}: ${ran.length - failed.length}/${ran.length} checks pass; ${known.length} known failures; ${run.results.length - ran.length} skipped`, ); for (const { name, skipped } of run.results) if (skipped) console.log(` skipped ${name}: ${skipped}`); - for (const { name, error } of failed) - console.log(` FAIL ${name}: ${error}`); + for (const { name, error, knownFailure } of failed) + console.log( + ` ${knownFailure ? 'KNOWN FAIL' : 'FAIL'} ${name}: ${error}`, + ); for (const message of run.pageErrors) console.log(` page error: ${message}`); } @@ -246,7 +305,9 @@ const json = option('json'); if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`); process.exit( report.every(({ results }) => - results.every(({ ok, skipped }) => ok || skipped), + results.every( + ({ ok, skipped, knownFailure }) => ok || skipped || knownFailure, + ), ) ? 0 : 1, diff --git a/src/app/qaProbe.test.mjs b/src/app/qaProbe.test.mjs index 90ff4cdbe..a146abf3d 100644 --- a/src/app/qaProbe.test.mjs +++ b/src/app/qaProbe.test.mjs @@ -84,7 +84,7 @@ test('the probe lists, toggles and reads layers through the engine', async () => point: { sizePx: 4 }, }); assert.deepEqual(probe.movers(), [ - { id: 'fleet', kind: 'point', count: 0, capacity: 4 }, + { id: 'fleet', kind: 'point', count: 0, capacity: 4, shown: false }, ]); const before = probe.framesDrawn(); engine.fake.frame(); diff --git a/src/globe/cesium/createCesiumEngine.test.mjs b/src/globe/cesium/createCesiumEngine.test.mjs index bb723f4f0..2b3e999a5 100644 --- a/src/globe/cesium/createCesiumEngine.test.mjs +++ b/src/globe/cesium/createCesiumEngine.test.mjs @@ -53,6 +53,7 @@ test('the engine draws the visual styles, bloom and contact sonar, not the cloud const viewer = createFakeViewer(); const engine = createCesiumEngine(viewer); assert.deepEqual(engineCapabilities(engine), { + tileErrors: true, visualStyles: true, bloom: true, cloudCover: false, @@ -199,11 +200,11 @@ test('the contract suite movers and lines checks pass on the Cesium engine (no p for (let index = 0; index < count; index++) frame(); }, }).filter(({ name }) => - /^(shape|movers|lines|colours|input: picks|surface\.sampleHeightDetailed)\b/.test( + /^(kind|movers|lines|input\.on: picks|surface\.sampleHeightDetailed)\b/.test( name, ), ); - assert.equal(checks.length, 9); + assert.equal(checks.length, 10); try { for (const { name, run } of checks) await t.test(name, run); } finally { @@ -211,3 +212,41 @@ test('the contract suite movers and lines checks pass on the Cesium engine (no p } assert.equal(scene.primitives.length, 0, 'no collection left behind'); }); + +test('the camera, clock and failed model suite checks pass on the Cesium adapter', async (t) => { + const { createAppClock } = await import('../../time/clock.js'); + let wall = 1_000_000; + const { viewer, camera, engine, frame } = createCameraViewer({ + engineOptions: { + createEventHandler: createFakeEventHandlers().create, + appClock: createAppClock({ now: () => wall }), + moverFactories: { + loadModel: async () => { + throw new Error('Model unavailable'); + }, + }, + }, + }); + viewer.entities = new Cesium.EntityCollection(); + const checks = createContractSuite(engine, { + fixtures: { failModel() {} }, + waitFrames: async (count) => { + for (let i = 0; i < count; i++) { + wall += 16; + camera.update(Cesium.SceneMode.SCENE3D); + camera._updateCameraChanged(); + frame(); + } + }, + }).filter(({ name }) => + /^(camera.changed: a pure|camera.following:|clock.frameTime:|MoverSet.failed:)/.test( + name, + ), + ); + assert.equal(checks.length, 6); + try { + for (const { name, run } of checks) await t.test(name, run); + } finally { + engine.destroy(); + } +}); diff --git a/src/globe/cesium/maps.js b/src/globe/cesium/maps.js index 7184aaaea..f6156aaba 100644 --- a/src/globe/cesium/maps.js +++ b/src/globe/cesium/maps.js @@ -4,6 +4,7 @@ // shared factories in src/maps, as MapSourceController builds them. import * as Cesium from 'cesium'; +import { createEngineEvent } from '../contract.js'; import { loadPhotorealisticTileset } from '../../maps/google3d.js'; import { createMapScene } from './mapScene.js'; import { ESRI_ATTRIBUTION_HTML, imageryFactoryFor } from './mapImagery.js'; @@ -99,10 +100,29 @@ export function createCesiumMaps( let destroyed = false; // Every terrain the globe takes counts, a fallback to the ellipsoid too. let terrainRevision = 0; + let terrainSource = null; + const errors = createEngineEvent(); + const errorRemovers = { tile: null, terrain: null }; + function watchErrors(provider, source, kind) { + errorRemovers[kind]?.(); + errorRemovers[kind] = provider?.errorEvent?.addEventListener((error) => { + if (destroyed) return; + errors.raiseEvent({ + source, + kind, + status: error?.error?.statusCode ?? error?.statusCode ?? error?.status, + }); + }); + } const offTerrain = - viewer.scene.globe?.terrainProviderChanged?.addEventListener( - () => terrainRevision++, - ); + viewer.scene.globe?.terrainProviderChanged?.addEventListener((provider) => { + terrainRevision++; + watchErrors( + provider ?? viewer.scene.terrainProvider, + terrainSource, + 'terrain', + ); + }); const startupTileset = () => (typeof tilesetRef === 'function' ? tilesetRef() : tilesetRef?.current) ?? @@ -137,11 +157,13 @@ export function createCesiumMaps( function showBase(source, provider) { currentBase = source; + watchErrors(provider, source, 'tile'); mapScene.showBaseImagery(provider); showCredit('imagery', imageryCredit(source)); } const maps = { + errors, async setBaseImagery(source) { const generation = ++generations.display; const provider = await providerFor(source); @@ -154,7 +176,9 @@ export function createCesiumMaps( const generation = ++generations.terrain; const result = await terrainCreator(source)({ signal: abort.signal }); if (destroyed || generation !== generations.terrain) return; + terrainSource = source; applyTerrain(viewer, result); + if (result.provider) watchErrors(result.provider, source, 'terrain'); requestRender('map-terrain'); }, async setPhotorealTiles(source) { @@ -203,6 +227,7 @@ export function createCesiumMaps( // As MapSourceController activates a 3D stack: no base layer, the // source's credit, one tileset shown and the globe hidden. mapScene.removeBaseImagery(); + watchErrors(null, null, 'tile'); currentBase = null; currentTiles = source; showCredit('photoreal', source.credit); @@ -232,6 +257,7 @@ export function createCesiumMaps( destroyed = true; abort.abort(); offTerrain?.(); + for (const remove of Object.values(errorRemovers)) remove?.(); mapScene.destroy(); for (const promise of providers.values()) void promise.then(dispose, () => {}); diff --git a/src/globe/cesium/maps.test.mjs b/src/globe/cesium/maps.test.mjs index 90696be9d..e31c2acda 100644 --- a/src/globe/cesium/maps.test.mjs +++ b/src/globe/cesium/maps.test.mjs @@ -411,3 +411,27 @@ test('current names the sources on screen as passed', async () => { maps.releasePhotorealTiles(tiles); assert.equal(maps.current().photoreal, null, 'a released source is gone'); }); + +test('maps.errors bridges terrain failures, follows fallback providers and detaches on destroy', async () => { + const { viewer, maps, destroy } = setup(); + const failures = []; + maps.errors.addEventListener((error) => failures.push(error)); + const first = { errorEvent: new Cesium.Event() }; + const source = { kind: 'keyless', create: () => ({ provider: first }) }; + await maps.setTerrain(source); + const revision = maps.terrainRevision(); + first.errorEvent.raiseEvent({ error: { statusCode: 503 } }); + assert.deepEqual(failures, [{ source, kind: 'terrain', status: 503 }]); + const fallback = { errorEvent: new Cesium.Event() }; + viewer.terrainProvider = fallback; + assert.ok(maps.terrainRevision() > revision); + first.errorEvent.raiseEvent({ statusCode: 401 }); + fallback.errorEvent.raiseEvent({ statusCode: 429 }); + assert.deepEqual(failures, [ + { source, kind: 'terrain', status: 503 }, + { source, kind: 'terrain', status: 429 }, + ]); + destroy(); + assert.equal(first.errorEvent.numberOfListeners, 0); + assert.equal(fallback.errorEvent.numberOfListeners, 0); +}); diff --git a/src/globe/cesium/testing/contractFixtures.js b/src/globe/cesium/testing/contractFixtures.js new file mode 100644 index 000000000..86d5eb3a0 --- /dev/null +++ b/src/globe/cesium/testing/contractFixtures.js @@ -0,0 +1,57 @@ +import * as Cesium from 'cesium'; +import { createCesiumMaps } from '../maps.js'; +import { createCesiumSurface } from '../surface.js'; + +// A provider with deterministic samples, using Cesium's real sampling path. +export function createContractFixtures() { + let read = () => 0; + const provider = { + tilingScheme: new Cesium.GeographicTilingScheme(), + availability: { + computeMaximumLevelAtPosition: (place) => { + read(place); + return 0; + }, + }, + loadTileDataAvailability() {}, + requestTileGeometry: async () => ({ + interpolateHeight: (_rectangle, longitude, latitude) => + read({ longitude, latitude }) ?? NaN, + }), + }; + const { surface } = createCesiumSurface({ + scene: { terrainProvider: provider }, + }); + const globe = { show: true, terrainProviderChanged: new Cesium.Event() }; + const scene = { + globe, + primitives: { contains: () => false }, + requestRender() {}, + }; + const viewer = { + scene, + imageryLayers: { add() {}, remove() {} }, + set terrainProvider(value) { + scene.terrainProvider = value; + globe.terrainProviderChanged.raiseEvent(value); + }, + }; + const owner = createCesiumMaps(viewer, { + createImageryLayer: (provider) => ({ provider }), + credits: { addStatic: () => () => {} }, + }); + return { + maps: owner.maps, + terrainSource: { kind: 'keyless', create: () => ({ provider }) }, + fallbackTerrain: () => { + viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider(); + }, + destroy: () => owner.destroy(), + terrain: { + surface, + sample: (next) => { + read = next ?? (() => 0); + }, + }, + }; +} diff --git a/src/globe/contract.js b/src/globe/contract.js index 9197c50f2..08dfc95c8 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -166,7 +166,7 @@ * @property {any} [native] * @property {object} [label] FollowOptions.label. * @property {string} [name] Cesium only: the followed Entity's name, else its - * id (a follow without an Entity has none). + * id (a follow without an Entity has none). @deprecated Compatibility field. * * @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType * @typedef {'ctrl'|'shift'|'alt'} InputModifier @@ -346,7 +346,7 @@ * pickId); undefined for map tiles. * @property {string} [owner] The mover set or line the pick belongs to. * @property {number} [instance] The mover instance index. - * @property {any} [primitive] Cesium only, for compatibility. + * @property {any} [primitive] Cesium only, for compatibility. @deprecated Compatibility field. * @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. @@ -368,6 +368,11 @@ * cannot be built (the current source then stays). Base imagery and * photoreal tiles share one switch order: the call made last decides what * shows, and a superseded call settles without showing anything. + * @property {EngineEvent} [errors] With the tileErrors capability, reports + * provider failures as { source, kind: 'tile' | 'terrain', status }, where + * source is the description passed to the setter and status is the HTTP + * status code when known (undefined otherwise). Replaced sources stop + * reporting; engines without provider error events omit this capability. * @property {(source: object) => Promise} setBaseImagery * @property {(source: object) => Promise} setTerrain * @property {(source: object | null) => Promise} setPhotorealTiles @@ -481,7 +486,7 @@ * * @typedef {object} GlobeMovers * @property {(options: MoverSetOptions) => MoverSet} createSet - * @property {() => { id: string, kind: string, count: number, capacity: number }[]} list + * @property {() => { id: string, kind: string, count: number, capacity: number, shown: boolean }[]} list * * @typedef {object} LineOptions * A dynamic polyline (src/globe/lines.js checks and defaults these). @@ -1093,6 +1098,7 @@ const isNameList = (value) => // group whole or not at all (assertGlobeEngine); engineCapabilities says // which it has. const OPTIONAL_SHAPE = { + tileErrors: { 'maps.errors': isEngineEvent }, visualStyles: { 'render.visualStyles': isNameList, 'render.setStyle': isFunction, diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs index 6905eb1d0..5ad990842 100644 --- a/src/globe/contract.test.mjs +++ b/src/globe/contract.test.mjs @@ -1,3 +1,4 @@ +import { fakeContractOptions } from './testing/contractFixtures.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { @@ -198,9 +199,10 @@ test('assertGlobeEngine accepts a complete engine and names every missing capabi test('the fake engine passes the engine contract suite', async (t) => { const engine = createFakeEngine(); const checks = createContractSuite(engine, { - waitFrames: async (count) => engine.fake.frames(count), + ...fakeContractOptions(engine), // The fake's input, as a real pointer would drive it. pointer: { + ...fakeContractOptions(engine).pointer, click: async (x, y) => engine.fake.input('click', { position: { x, y } }), move: async (x, y) => engine.fake.input('move', { @@ -214,8 +216,9 @@ test('the fake engine passes the engine contract suite', async (t) => { }, }); assert.ok(checks.length >= 15); - assert.ok(checks.some(({ name }) => name.startsWith('maps:'))); + assert.ok(checks.some(({ name }) => name.startsWith('maps.'))); assert.equal(new Set(checks.map((check) => check.name)).size, checks.length); + for (const { name } of checks) assert.match(name, /^\w+(?:\.\w+)?: /); for (const check of checks) await t.test(check.name, () => check.run()); }); @@ -305,7 +308,7 @@ test('movers and lines are required', () => { const names = createContractSuite(engine, { waitFrames: async () => {}, }).map(({ name }) => name); - for (const prefix of ['movers:', 'lines:', 'colours:', 'ground:']) + for (const prefix of ['movers.', 'lines.', 'ground.']) assert.ok( names.some((name) => name.startsWith(prefix)), prefix, @@ -334,7 +337,7 @@ test('imagery is required', () => { const names = createContractSuite(engine, { waitFrames: async () => {}, }).map(({ name }) => name); - assert.ok(names.some((name) => name.startsWith('imagery:'))); + assert.ok(names.some((name) => name.startsWith('imagery.'))); }); test('meshes are required', () => { diff --git a/src/globe/follow.js b/src/globe/follow.js index c16d71cab..f7d8b08d0 100644 --- a/src/globe/follow.js +++ b/src/globe/follow.js @@ -388,6 +388,7 @@ export function createFollowController({ handle.label = updated.label; handle.offset = null; handle.previous = null; + followChanged.raiseEvent(handle); requestRender(); }, }; diff --git a/src/globe/movers.js b/src/globe/movers.js index e4ece8af8..88d325615 100644 --- a/src/globe/movers.js +++ b/src/globe/movers.js @@ -422,6 +422,7 @@ export function createMoverRegistry(build) { kind: options.kind, count: store.count, capacity: store.capacity, + shown: store.shows.subarray(0, store.count).some(Boolean), })), }; return { diff --git a/src/globe/navara/maps.js b/src/globe/navara/maps.js index b99cfc5c7..8c7a19867 100644 --- a/src/globe/navara/maps.js +++ b/src/globe/navara/maps.js @@ -223,6 +223,7 @@ export function createNavaraMaps( let tilesSource = null; const knownTiles = new Set(); + // The package exposes no provider error event, so tileErrors is absent. const maps = { get baseKind() { return kindOf(currentBase); diff --git a/src/globe/navara/testing/contractFixtures.js b/src/globe/navara/testing/contractFixtures.js new file mode 100644 index 000000000..9416eede0 --- /dev/null +++ b/src/globe/navara/testing/contractFixtures.js @@ -0,0 +1,36 @@ +import { createNavaraMaps } from '../maps.js'; +import { createNavaraSurface } from '../surface.js'; + +// A source with deterministic samples, using the maps and surface adapters. +export async function createContractFixtures() { + let read = () => 0; + const view = { + addSource: () => ({ delete() {} }), + addLayer: () => ({ delete() {}, update() {} }), + forceUpdate() {}, + sampleTerrainMostDetailed: async (_source, places) => + places.map(({ lng, lat }) => ({ + height: read({ + longitude: (lng * Math.PI) / 180, + latitude: (lat * Math.PI) / 180, + }), + })), + }; + const maps = createNavaraMaps(view, { setInterval: () => null }); + await maps.maps.setTerrain({ kind: 'keyless' }); + const { surface } = createNavaraSurface( + view, + { retainTileGeometry() {} }, + maps.sampleTerrain, + ); + return { + maps: maps.maps, + terrain: { + surface, + sample: (next) => { + read = next ?? (() => 0); + }, + }, + destroy: () => maps.destroy(), + }; +} diff --git a/src/globe/testing/callLog.js b/src/globe/testing/callLog.js index 906dc8dd5..2411bbaed 100644 --- a/src/globe/testing/callLog.js +++ b/src/globe/testing/callLog.js @@ -82,6 +82,7 @@ const HANDLE_TYPES = { export function contractCallMembers() { return [ ...engineContractMembers(), + 'maps.errors', ...Object.entries(HANDLE_TYPES).flatMap(([type, { members }]) => members.map((member) => `${type}.${member}`), ), diff --git a/src/globe/testing/contract-uncalled-baseline.json b/src/globe/testing/contract-uncalled-baseline.json index e69c9e219..36841fd82 100644 --- a/src/globe/testing/contract-uncalled-baseline.json +++ b/src/globe/testing/contract-uncalled-baseline.json @@ -1,14 +1,7 @@ [ - "FollowHandle.label", "FollowHandle.name", - "GlobeMesh.id", "GroundPulseField.id", "Line.setColor", "Line.setModelMatrix", - "Line.show", - "MoverSet.failed", - "maps.terrainRevision", - "render.maxTextureSize", - "surface.raycast", - "surface.sampleTerrainMostDetailed" + "Line.show" ] diff --git a/src/globe/testing/contractCoverage.test.mjs b/src/globe/testing/contractCoverage.test.mjs index 88fe0197a..e72439860 100644 --- a/src/globe/testing/contractCoverage.test.mjs +++ b/src/globe/testing/contractCoverage.test.mjs @@ -1,3 +1,4 @@ +import { fakeContractOptions } from './contractFixtures.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; @@ -10,8 +11,9 @@ test('the contract suite does not leave new members uncalled', async (t) => { const fake = createFakeEngine(); const { engine, called } = withCallLog(fake); const checks = createContractSuite(engine, { - waitFrames: async (count) => fake.fake.frames(count), + ...fakeContractOptions(engine), pointer: { + ...fakeContractOptions(engine).pointer, click: async (x, y) => fake.fake.input('click', { position: { x, y } }), move: async (x, y) => fake.fake.input('move', { diff --git a/src/globe/testing/contractFixtures.js b/src/globe/testing/contractFixtures.js new file mode 100644 index 000000000..25d116dc7 --- /dev/null +++ b/src/globe/testing/contractFixtures.js @@ -0,0 +1,59 @@ +// Deterministic inputs for the same checks used by the browser runner. +export function fakeContractOptions(engine) { + const { fake } = engine; + let consumed = -1; + return { + waitFrames: async (count) => { + for (let i = 0; i < count; i++) { + if (!engine.render.isContinuous() && consumed === fake.renderRequests) + throw new Error('No frame requested'); + consumed = fake.renderRequests; + fake.frame(); + } + }, + pointer: { + click: async (x, y) => fake.input('click', { position: { x, y } }), + move: async (x, y) => + fake.input('move', { + startPosition: { x: x - 1, y }, + endPosition: { x, y }, + }), + drag: async (x, y, endX, endY) => { + fake.input('down', { position: { x, y } }); + fake.input('move', { + startPosition: { x, y }, + endPosition: { x: endX, y: endY }, + }); + fake.input('up', { position: { x: endX, y: endY } }); + }, + }, + resize: async (width, height) => fake.setSize(width, height), + mapSources: { + imagery: [{ kind: 'osm' }, { kind: 'esri-imagery' }], + photoreal: { kind: 'photoreal' }, + }, + fixtures: { + terrain: { + surface: engine.surface, + sample: (read) => + read + ? fake.script('sampleTerrainMostDetailed', async (places) => + places.map(read), + ) + : fake.unscript('sampleTerrainMostDetailed'), + }, + failModel: (id, enabled) => + enabled + ? fake.script( + 'moverFailed', + (setId, index) => + setId === id && + (engine.kind === 'navara' || + index === undefined || + index === 0), + ) + : fake.unscript('moverFailed'), + tiles: true, + }, + }; +} diff --git a/src/globe/testing/contractFixtures.test.mjs b/src/globe/testing/contractFixtures.test.mjs new file mode 100644 index 000000000..60e29eb15 --- /dev/null +++ b/src/globe/testing/contractFixtures.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createFakeEngine } from './fakeEngine.js'; +import { fakeContractOptions } from './contractFixtures.js'; +import { createContractSuite } from './contractSuite/index.mjs'; +import { createContractFixtures as cesiumFixtures } from '../cesium/testing/contractFixtures.js'; +import { createContractFixtures as navaraFixtures } from '../navara/testing/contractFixtures.js'; + +for (const [kind, create] of [ + ['cesium', cesiumFixtures], + ['navara', navaraFixtures], +]) { + test(`${kind} adapters pass deterministic sampling and map error suite checks`, async (t) => { + const fixtures = await create(); + const engine = createFakeEngine({ kind }); + engine.maps = fixtures.maps; + try { + const checks = createContractSuite(engine, { + ...fakeContractOptions(engine), + fixtures, + }); + const selected = checks.filter( + ({ name }) => + name.startsWith('maps.errors:') || + name.startsWith('maps.terrainRevision: changes when falling') || + name.startsWith('surface.sampleTerrainMostDetailed: preserves') || + name.startsWith('surface.sampleTerrainMostDetailed: passes'), + ); + assert.equal(selected.length, kind === 'cesium' ? 8 : 5); + for (const { name, run } of selected) await t.test(name, run); + } finally { + fixtures.destroy?.(); + engine.destroy(); + } + }); +} diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs index 73cdfe9e0..dcdd7b3a6 100644 --- a/src/globe/testing/contractSuite.mjs +++ b/src/globe/testing/contractSuite.mjs @@ -1,5143 +1 @@ -// Behavioural checks every GlobeEngine must pass. The suite is engine-agnostic -// and imports nothing from Node, so it runs in Node against the fake engine and -// in a headless browser against the Cesium and Navara engines: -// -// const checks = createContractSuite(engine, { waitFrames }); -// for (const { name, run } of checks) await run(); -// -// `waitFrames(count)` resolves after the engine has rendered `count` frames. -// Checks request renders themselves, so engines in request-render mode work. -// Each check sets the view it needs; checks may leave the camera anywhere. - -import { enuOffset } from '../follow.js'; -import * as geo from '../../geo/index.js'; -import { - INPUT_TYPES, - MODIFIERS, - assertGlobeEngine, - engineCapabilities, -} from '../contract.js'; - -const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo; -const throwsType = (run, label) => { - let error = null; - try { - run(); - } catch (caught) { - error = caught; - } - ensure(error instanceof TypeError, `${label} did not throw a TypeError`); -}; -const like = (colour, data, i) => - colour.every( - (channel, k) => k > 2 || Math.abs(data[i + k] - channel * 255) <= 40, - ); -const MAGENTA = [255, 0, 255]; -const GREEN = [0, 255, 0]; -const nearColour = (actual, expected, tolerance = 40) => - actual.every((value, k) => Math.abs(value - expected[k]) <= tolerance); -const show = (colour) => colour.map((value) => value.toFixed(0)).join(','); -const screenOf = (engine, lon, lat) => - engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat)); -const meanAt = (engine, readRegion, lon, lat, half) => { - const { x, y } = screenOf(engine, lon, lat); - const data = readRegion( - Math.round(x) - half, - Math.round(y) - half, - 2 * half, - 2 * half, - ); - const mean = [0, 0, 0]; - for (let i = 0; i < data.length; i += 4) - for (let k = 0; k < 3; k++) mean[k] += data[i + k]; - return mean.map((sum) => sum / (data.length / 4)); -}; - -const WGS84 = ellipsoid.WGS84; - -class ContractError extends Error { - constructor(message) { - super(message); - this.name = 'ContractError'; - } -} - -// The radial ground look's disc in the contract checks, a launch pad's radius. -const RADIAL_RADIUS_M = 500; -// The camera height that shows that disc and its rim a few pixels wide. -const RADIAL_VIEW_M = 4000; - -function ensure(condition, message) { - if (!condition) throw new ContractError(message); -} - -function near(actual, expected, tolerance, label) { - ensure( - typeof actual === 'number' && Math.abs(actual - expected) <= tolerance, - `${label}: ${actual} is not within ${tolerance} of ${expected}`, - ); -} - -// Smallest difference between two angles, in radians. -const angleBetween = (a, b) => Math.abs(math.negativePiToPi(a - b)); - -const isCartesian3 = (value) => - value != null && - Number.isFinite(value.x) && - Number.isFinite(value.y) && - Number.isFinite(value.z); - -// The local east/north/up axes at a position. -function localAxes(position) { - const frame = geo.transforms.eastNorthUpToFixedFrame(position, WGS84); - return { - frame, - east: cartesian3.create(frame[0], frame[1], frame[2]), - north: cartesian3.create(frame[4], frame[5], frame[6]), - up: cartesian3.create(frame[8], frame[9], frame[10]), - }; -} - -const inRange = (value, low, high, label) => - ensure( - typeof value === 'number' && value >= low && value < high, - `${label} ${value} is outside [${low}, ${high})`, - ); - -const unit = (vector) => cartesian3.normalize(vector, cartesian3.create()); -const along = (origin, direction, distance) => - cartesian3.add( - origin, - cartesian3.multiplyByScalar(direction, distance, cartesian3.create()), - cartesian3.create(), - ); - -/** - * @param {import('../contract.js').GlobeEngine} engine - * @param {{ waitFrames: (count: number) => Promise, maxWaitFrames?: number, - * mapSources?: { imagery: object[], photoreal: object }, - * readRegion?: (x: number, y: number, width: number, height: number) => Uint8ClampedArray, - * makeCanvas?: (width: number, height: number) => object, - * pointer?: { click: (x: number, y: number) => Promise, move: (x: number, y: number) => Promise } }} options - * `mapSources` (two imagery sources and a photoreal one the engine can - * show) adds the maps checks; they leave photoreal tiles hidden. - * `readRegion` (RGBA bytes of the last drawn frame in a CSS-pixel box) and - * `makeCanvas` (a 2D canvas) add the pixel checks of drawn objects. - * `pointer` (a real left click and pointer move at a canvas position) - * adds the pick checks. - * @returns {{ name: string, run: () => Promise }[]} - */ -export function createContractSuite( - engine, - { - waitFrames, - maxWaitFrames = 240, - mapSources = null, - readRegion = null, - makeCanvas = null, - pointer = null, - }, -) { - if (typeof waitFrames !== 'function') - throw new TypeError('createContractSuite needs a waitFrames(count) helper'); - const { camera } = engine; - - const settle = async (count = 2) => { - for (let index = 0; index < count; index++) { - engine.render.request(); - await waitFrames(1); - } - }; - const until = async (predicate, label) => { - for (let frame = 0; frame < maxWaitFrames; frame++) { - if (predicate()) return; - await settle(1); - } - ensure( - predicate(), - `${label} did not happen within ${maxWaitFrames} frames`, - ); - }; - - // A view 1000 km above (20°E, 10°N) looking straight down, heading north. - const nadirView = async (height = 1e6) => { - camera.setView({ - destination: cartesian3.fromDegrees(20, 10, height), - orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, - }); - await settle(); - }; - const centre = () => { - const { width, height } = engine.host.size(); - return cartesian2.create(width / 2, height / 2); - }; - - const capabilities = engineCapabilities(engine); - const checks = [ - [ - 'shape: the engine has every contract capability', - async () => { - assertGlobeEngine(engine); - }, - ], - [ - 'host.size: a positive CSS size', - async () => { - const { width, height } = engine.host.size(); - ensure( - width > 0 && height > 0, - `size ${width}x${height} is not positive`, - ); - }, - ], - [ - 'camera.setView: heading, pitch and roll round-trip through pose', - async () => { - const destination = cartesian3.fromDegrees(-70, 45, 25000); - const orientation = { heading: 1.2, pitch: -0.6, roll: 0.05 }; - camera.setView({ destination, orientation }); - await settle(); - const pose = camera.pose(); - near( - cartesian3.distance(pose.position, destination), - 0, - 0.01, - 'position error (m)', - ); - near( - angleBetween(pose.heading, orientation.heading), - 0, - 1e-6, - 'heading error', - ); - near(pose.pitch, orientation.pitch, 1e-6, 'pitch'); - near(angleBetween(pose.roll, orientation.roll), 0, 1e-6, 'roll error'); - }, - ], - [ - 'camera.setView: direction and up orientation', - async () => { - const destination = cartesian3.fromDegrees(5, 50, 3e5); - const direction = unit( - cartesian3.negate(destination, cartesian3.create()), - ); - const north = cartesian3.cross( - cartesian3.cross(direction, cartesian3.UNIT_Z, cartesian3.create()), - direction, - cartesian3.create(), - ); - const up = unit(north); - camera.setView({ destination, orientation: { direction, up } }); - await settle(); - const pose = camera.pose(); - near( - cartesian3.dot(pose.direction, direction), - 1, - 1e-9, - 'direction·expected', - ); - near(cartesian3.dot(pose.up, up), 1, 1e-9, 'up·expected'); - }, - ], - [ - 'camera.livePosition and camera.height: the pose position and height, without a pose', - async () => { - camera.setView({ - destination: cartesian3.fromDegrees(-3.7, 40.4, 12000), - orientation: { heading: 0.4, pitch: -0.5, roll: 0 }, - }); - await settle(); - const live = camera.livePosition; - ensure(isCartesian3(live), 'livePosition is a Cartesian3'); - near( - cartesian3.distance(live, camera.pose().position), - 0, - 1e-6, - 'livePosition vs pose().position', - ); - near( - camera.height(), - camera.pose().cartographic.height, - 0.01, - 'height()', - ); - const destination = cartesian3.fromDegrees(2.35, 48.85, 30000); - camera.setView({ destination }); - await settle(); - ensure( - camera.livePosition === live, - 'livePosition is the same engine-owned object on every read', - ); - near( - cartesian3.distance(camera.livePosition, destination), - 0, - 1e-3, - 'livePosition follows the camera', - ); - }, - ], - [ - 'camera.pose: cartographic, frame vectors and fovy are consistent', - async () => { - camera.setView({ - destination: cartesian3.fromDegrees(139.7, 35.6, 8000), - orientation: { heading: 4, pitch: -0.3, roll: 0 }, - }); - await settle(); - const pose = camera.pose(); - const expected = cartographic.fromCartesian(pose.position, WGS84); - near( - pose.cartographic.longitude, - expected.longitude, - 1e-9, - 'longitude', - ); - near(pose.cartographic.latitude, expected.latitude, 1e-9, 'latitude'); - near(pose.cartographic.height, expected.height, 0.01, 'height'); - for (const [name, vector] of [ - ['direction', pose.direction], - ['up', pose.up], - ['right', pose.right], - ]) - near(cartesian3.magnitude(vector), 1, 1e-6, `|${name}|`); - near(cartesian3.dot(pose.direction, pose.up), 0, 1e-6, 'direction·up'); - const right = cartesian3.cross( - pose.direction, - pose.up, - cartesian3.create(), - ); - near( - cartesian3.dot(right, pose.right), - 1, - 1e-6, - 'right = direction × up', - ); - ensure( - pose.fovy > 0 && pose.fovy < Math.PI, - `fovy ${pose.fovy} out of range`, - ); - }, - ], - [ - 'camera.percentageChanged: readable and writable', - async () => { - const previous = camera.percentageChanged; - try { - camera.percentageChanged = 0.125; - near(camera.percentageChanged, 0.125, 0, 'percentageChanged'); - } finally { - camera.percentageChanged = previous; - } - }, - ], - [ - 'camera events: changed, moveStart and moveEnd follow a move', - async () => { - // moveStart marks a move from rest: let the nadir move end first - // (Cesium raises moveEnd only after cameraEventWaitTime, 500 ms). - let rested = 0; - const removeRest = camera.moveEnd.addEventListener(() => rested++); - try { - // 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(); - } - const previous = camera.percentageChanged; - const seen = { changed: 0, moveStart: 0, moveEnd: 0 }; - const removers = Object.keys(seen).map((name) => - camera[name].addEventListener(() => seen[name]++), - ); - try { - camera.percentageChanged = 0.01; - camera.setView({ - destination: cartesian3.fromDegrees(60, -30, 2e6), - orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, - }); - await until(() => seen.moveEnd > 0, 'moveEnd'); - ensure(seen.moveStart > 0, 'moveStart did not fire'); - ensure(seen.changed > 0, 'changed did not fire'); - } finally { - for (const remove of removers) remove(); - camera.percentageChanged = previous; - } - }, - ], - [ - 'events: removed listeners are not called', - async () => { - await nadirView(); - let viaHandle = 0; - let viaRemove = 0; - const remove = camera.moveStart.addEventListener(() => viaHandle++); - const listener = () => viaRemove++; - engine.frames.postRender.addEventListener(listener); - remove(); - ensure( - engine.frames.postRender.removeEventListener(listener) === true, - 'removeEventListener did not report removal', - ); - await nadirView(3e6); - await settle(3); - ensure( - viaHandle === 0 && viaRemove === 0, - 'a removed listener was called', - ); - }, - ], - [ - 'frames: preUpdate, preRender and postRender fire in order with timestamps', - async () => { - const order = []; - const removers = ['preUpdate', 'preRender', 'postRender'].map((name) => - engine.frames[name].addEventListener((time) => - order.push([name, time]), - ), - ); - try { - await until( - () => order.some(([name]) => name === 'postRender'), - 'postRender', - ); - } finally { - for (const remove of removers) remove(); - } - const firstPost = order.findIndex(([name]) => name === 'postRender'); - const frame = order.slice(0, firstPost + 1); - const index = (name) => frame.map(([entry]) => entry).lastIndexOf(name); - ensure(index('preRender') >= 0, 'preRender did not precede postRender'); - ensure(index('preUpdate') >= 0, 'preUpdate did not precede postRender'); - ensure( - index('preUpdate') < index('preRender'), - 'preUpdate ran after preRender', - ); - for (const [name, time] of order) - ensure( - Number.isFinite(time), - `${name} timestamp ${time} is not a number`, - ); - }, - ], - [ - 'clock: one frame time per frame, latched before preUpdate, never decreasing', - async () => { - const records = []; - const read = (name) => - records.push({ - name, - frame: engine.clock.frameTime(), - now: engine.clock.now(), - }); - const removers = ['preUpdate', 'preRender', 'postRender'].map((name) => - engine.frames[name].addEventListener(() => read(name)), - ); - try { - await until( - () => - records.filter(({ name }) => name === 'postRender').length >= 3, - 'three drawn frames', - ); - } finally { - for (const remove of removers) remove(); - } - ensure( - Number.isFinite(engine.clock.now()) && - Number.isFinite(engine.clock.frameTime()), - 'clock.now or clock.frameTime is not a number', - ); - // From the first preUpdate on: every frame event reads the frame time - // its frame's preUpdate read, and that time never goes back. - const start = records.findIndex(({ name }) => name === 'preUpdate'); - ensure(start >= 0, 'frames.preUpdate never fired'); - let frame = null; - for (const record of records.slice(start)) { - ensure( - record.frame <= record.now, - `${record.name}: frameTime ${record.frame} is after now ${record.now}`, - ); - if (record.name === 'preUpdate') { - ensure( - frame === null || record.frame >= frame.frame, - `frame time went back from ${frame?.frame} to ${record.frame}`, - ); - frame = record; - continue; - } - ensure( - record.frame === frame.frame, - `${record.name} read frame time ${record.frame}, its frame's is ${frame.frame}`, - ); - } - }, - ], - [ - 'movers: three sprites draw at their projections, hide, recolour and go', - async () => { - await nadirView(1e6); - const atlasCanvas = makeCanvas - ? makeCanvas(16, 16) - : { width: 16, height: 16 }; - if (makeCanvas) { - const context = atlasCanvas.getContext('2d'); - context.fillStyle = '#ffffff'; - context.fillRect(0, 0, 16, 16); - } - const places = [ - [20, 10], - [21.5, 10], - [20, 11.5], - ].map(([lon, lat]) => cartesian3.fromDegrees(lon, lat, 1000)); - const set = engine.movers.createSet({ - id: 'contract-sprites', - kind: 'sprite', - capacity: 3, - order: 100, - depthTest: 'always', - sprite: { - atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } }, - sizePx: 24, - }, - }); - const MAGENTA = [1, 0, 1, 1]; - // Frames drawn in a window, with nothing asked of the engine. - const framesIn = async (ms) => { - let frames = 0; - const remove = engine.frames.postRender.addEventListener( - () => frames++, - ); - await new Promise((resolve) => setTimeout(resolve, ms)); - remove(); - return frames; - }; - const idleFrames = readRegion ? await framesIn(2000) : 0; - try { - places.forEach((position, index) => - set.set(index, { position, color: MAGENTA, cell: 'box' }), - ); - set.setCount(3); - set.commit(); - ensure(set.count() === 3, `count ${set.count()}`); - ensure( - typeof set.ready() === 'boolean' && - typeof set.ready(0) === 'boolean', - 'ready() and ready(index) answer booleans', - ); - ensure( - engine.movers - .list() - .some( - ({ id, count }) => id === 'contract-sprites' && count === 3, - ), - 'movers.list() does not show the set', - ); - if (!readRegion) return; - const screens = places.map((position) => - engine.projector.worldToWindow(position), - ); - // Pixels close to a colour in a 64 px box around a point: their - // count and centroid. - // A colour as drawn: within 40 of the asked one on every channel - // (both engines draw overlays after tone mapping, unlit). - - const find = (screen, colour) => { - const half = 32; - const x0 = Math.round(screen.x) - half; - const y0 = Math.round(screen.y) - half; - const data = readRegion(x0, y0, 2 * half, 2 * half); - let count = 0; - let sx = 0; - let sy = 0; - for (let y = 0; y < 2 * half; y++) - for (let x = 0; x < 2 * half; x++) { - const i = (y * 2 * half + x) * 4; - if (like(colour, data, i)) { - count++; - sx += x0 + x + 0.5; - sy += y0 + y + 0.5; - } - } - return count - ? { count, x: sx / count, y: sy / count } - : { count, x: NaN, y: NaN }; - }; - // The atlas and the upload may take a few frames. - await until( - () => find(screens[0], MAGENTA).count > 100, - 'the first sprite drawn', - ); - screens.forEach((screen, index) => { - const seen = find(screen, MAGENTA); - ensure( - seen.count > 300, - `sprite ${index}: ${seen.count} magenta pixels, a 24 px square has 576`, - ); - ensure( - Math.hypot(seen.x - screen.x, seen.y - screen.y) <= 2, - `sprite ${index} centred at ${seen.x.toFixed(1)}, ${seen.y.toFixed(1)}, projected ${screen.x.toFixed(1)}, ${screen.y.toFixed(1)}`, - ); - }); - set.set(1, { show: false }); - set.set(0, { color: [0, 1, 0, 1] }); - set.commit(); - await until( - () => find(screens[1], MAGENTA).count < 10, - 'the hidden sprite gone', - ); - ensure( - find(screens[0], [0, 1, 0, 1]).count > 300, - 'the recoloured sprite is not green', - ); - ensure( - find(screens[2], MAGENTA).count > 300, - 'an untouched sprite changed', - ); - // Idle: once a commit has drawn, the set adds no frames (the - // page may draw for its own reasons: the app's other layers). - await settle(2); - const framesAfter = await framesIn(2000); - ensure( - framesAfter <= idleFrames + 1, - `${framesAfter} frames in 2 s after a commit, ${idleFrames} before the set`, - ); - set.destroy(); - await until( - () => find(screens[2], MAGENTA).count < 10, - 'the destroyed set gone', - ); - } finally { - set.destroy(); - } - ensure( - !engine.movers.list().some(({ id }) => id === 'contract-sprites'), - 'a destroyed set is still listed', - ); - }, - ], - [ - 'colours: mid-tone sprite, point and line colours draw as asked (within 3 per channel)', - async () => { - if (!readRegion || !makeCanvas) return; - await nadirView(1e6); - const at = (lon, lat, height = 20_000) => - cartesian3.fromDegrees(lon, lat, height); - const GREY = [0.5, 0.5, 0.5, 1]; - const ORANGE = [1, 0.6, 0.2, 1]; - const atlasCanvas = makeCanvas(16, 16); - const context = atlasCanvas.getContext('2d'); - context.fillStyle = '#ffffff'; - context.fillRect(0, 0, 16, 16); - const sprites = engine.movers.createSet({ - id: 'contract-colour-sprites', - kind: 'sprite', - capacity: 2, - order: 100, - depthTest: 'always', - sprite: { - atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } }, - sizePx: 32, - }, - }); - const points = engine.movers.createSet({ - id: 'contract-colour-points', - kind: 'point', - capacity: 2, - order: 100, - depthTest: 'always', - point: { sizePx: 32 }, - }); - const lines = [GREY, ORANGE].map((color, index) => - engine.lines.createLine({ - id: `contract-colour-line-${index}`, - widthPx: 16, - color, - }), - ); - // The centre 4×4 pixels' mean, as 0..255 per channel. - const centre = (position) => { - const screen = engine.projector.worldToWindow(position); - const data = readRegion( - Math.round(screen.x) - 2, - Math.round(screen.y) - 2, - 4, - 4, - ); - const sum = [0, 0, 0]; - for (let i = 0; i < data.length; i += 4) - for (let k = 0; k < 3; k++) sum[k] += data[i + k]; - return sum.map((value) => value / (data.length / 4)); - }; - const cases = [ - ['sprite', GREY, at(19.5, 10.5)], - ['sprite', ORANGE, at(20.5, 10.5)], - ['point', GREY, at(19.5, 9.5)], - ['point', ORANGE, at(20.5, 9.5)], - ['line', GREY, at(19.5, 10)], - ['line', ORANGE, at(20.5, 10)], - ]; - try { - sprites.set(0, { position: cases[0][2], color: GREY, cell: 'box' }); - sprites.set(1, { position: cases[1][2], color: ORANGE, cell: 'box' }); - sprites.setCount(2); - sprites.commit(); - points.set(0, { position: cases[2][2], color: GREY }); - points.set(1, { position: cases[3][2], color: ORANGE }); - points.setCount(2); - points.commit(); - // Short vertical lines through their sample points. - lines[0].setPositions([at(19.5, 9.9), at(19.5, 10.1)]); - lines[1].setPositions([at(20.5, 9.9), at(20.5, 10.1)]); - const off = (seen, colour) => - Math.max( - ...seen.map((value, k) => Math.abs(value - colour[k] * 255)), - ); - await until( - () => cases.every(([, colour, p]) => off(centre(p), colour) <= 3), - 'mid-tone colours within 3', - ); - } catch (error) { - const seen = cases - .map( - ([kind, colour, p]) => - `${kind} ${colour.slice(0, 3)} → ${centre(p) - .map((v) => v.toFixed(0)) - .join('/')}`, - ) - .join('; '); - throw new ContractError(`${error.message}: ${seen}`); - } finally { - sprites.destroy(); - points.destroy(); - for (const line of lines) line.destroy(); - } - }, - ], - [ - 'lines: a line draws between its projections, turns with its model matrix and dims where hidden', - async () => { - await nadirView(1e6); - // 20 km up: above any ground the scene draws. - const at = (lon, lat, height = 20_000) => - cartesian3.fromDegrees(lon, lat, height); - const line = engine.lines.createLine({ - id: 'contract-line', - widthPx: 8, - color: [1, 0, 1, 1], - }); - const hidden = engine.lines.createLine({ - id: 'contract-line-hidden', - widthPx: 10, - color: [1, 0, 1, 1], - depthFailAlpha: 0.6, - }); - try { - line.setPositions([at(19, 9), at(20, 9), at(21, 9)]); - ensure( - engine.lines - .list() - .some(({ id, points }) => id === 'contract-line' && points === 3), - 'lines.list() does not show the line', - ); - if (!readRegion) return; - // Magenta-leaning pixels (red and blue over green; a dimmed copy - // over a light map stays above 30) near a point. - // The strongest magenta lean near a point (for dimmer-than). - const strength = (position) => { - const screen = engine.projector.worldToWindow(position); - const data = readRegion( - Math.round(screen.x) - 6, - Math.round(screen.y) - 6, - 12, - 12, - ); - let best = -255; - for (let i = 0; i < data.length; i += 4) - best = Math.max( - best, - Math.min(data[i], data[i + 2]) - data[i + 1], - ); - return best; - }; - const magentaAt = (position) => { - const screen = engine.projector.worldToWindow(position); - if (!screen) return 0; - const data = readRegion( - Math.round(screen.x) - 6, - Math.round(screen.y) - 6, - 12, - 12, - ); - let count = 0; - for (let i = 0; i < data.length; i += 4) - if ( - Math.min(data[i], data[i + 2]) - data[i + 1] > 30 && - data[i] > 120 - ) - count++; - return count; - }; - await until( - () => magentaAt(at(19.5, 9)) > 10 && magentaAt(at(20.5, 9)) > 10, - 'the line drawn along both segments', - ); - ensure(magentaAt(at(19.5, 10.5)) === 0, 'line pixels off the line'); - // Turned 1° about the Earth's axis: the line moves 1° east. - const turn = (radians) => { - const c = Math.cos(radians); - const s = Math.sin(radians); - return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; - }; - line.setModelMatrix(turn(math.toRadians(1))); - await until( - () => magentaAt(at(21.5, 9)) > 10 && magentaAt(at(19.25, 9)) === 0, - 'the turned line drawn 1° east', - ); - const shownStrength = strength(at(20.5, 9)); - line.show(false); - await until(() => magentaAt(at(20.5, 9)) === 0, 'the line hidden'); - // Round the back of the Earth along the great circle west of the - // view centre, out to a visible end past the western horizon. The - // far side, behind the Earth's centre, shows only by its dimmed - // depth-fail copy. Cesium culls a line the globe hides whole, and - // draws in full what lies under the horizon but before the centre. - const west = (degrees) => { - const t = (degrees * Math.PI) / 180; - const [lon0, lat0] = [(20 * Math.PI) / 180, (10 * Math.PI) / 180]; - const [x, y, z] = [ - Math.cos(lat0) * Math.cos(lon0) * Math.cos(t) + - Math.sin(lon0) * Math.sin(t), - Math.cos(lat0) * Math.sin(lon0) * Math.cos(t) - - Math.cos(lon0) * Math.sin(t), - Math.sin(lat0) * Math.cos(t), - ]; - return at( - (Math.atan2(y, x) * 180) / Math.PI, - (Math.asin(z) * 180) / Math.PI, - ); - }; - const deep = () => west(150); - hidden.setPositions([200, 150, 100, 50, 20].map(west)); - await until( - () => magentaAt(deep()) > 10, - 'the hidden line drawn by its depth-fail copy', - ); - const hiddenStrength = strength(deep()); - ensure( - hiddenStrength < shownStrength, - `the depth-fail copy (${hiddenStrength}) is not dimmer than the line (${shownStrength})`, - ); - hidden.destroy(); - await until(() => magentaAt(deep()) === 0, 'the destroyed line gone'); - } finally { - line.destroy(); - hidden.destroy(); - } - ensure( - !engine.lines.list().some(({ id }) => id.startsWith('contract-line')), - 'a destroyed line is still listed', - ); - }, - ], - [ - 'lines: batched segments stay disconnected and retain individual picks', - async () => { - await nadirView(1e6); - const at = (lon, lat) => cartesian3.fromDegrees(lon, lat, 20_000); - const id = 'contract-segments'; - const batch = engine.lines.createSegments({ - id, - widthPx: 10, - color: [1, 0, 1, 1], - }); - const screen = (lon, lat) => - engine.projector.worldToWindow(at(lon, lat)); - const pick = (lon, lat) => engine.picker.pick(screen(lon, lat)); - const magenta = (lon, lat) => { - const p = screen(lon, lat); - const pixels = readRegion( - Math.round(p.x) - 5, - Math.round(p.y) - 5, - 10, - 10, - ); - let count = 0; - for (let i = 0; i < pixels.length; i += 4) - if (Math.min(pixels[i], pixels[i + 2]) - pixels[i + 1] > 30) - count++; - return count; - }; - try { - batch.replace([ - { positions: [at(19, 9.5), at(19, 10.5)], pickId: 'west' }, - { - positions: [at(21, 9.5), at(21, 10.5)], - widthPx: 12, - pickId: 'east', - }, - { positions: [at(20, 10.8), at(20, 11.2)] }, - ]); - batch.commit(); - ensure( - engine.lines.list().find((entry) => entry.id === id)?.points === 6, - 'batch endpoints', - ); - if (!pointer && !readRegion) return; - await until( - () => pick(19, 10)?.id === 'west' && pick(21, 10)?.id === 'east', - 'both segment picks', - ); - ensure(pick(19, 10)?.owner === id, 'batch pick owner'); - ensure( - pick(20, 11)?.owner !== id, - 'segment without a pick id is ignored', - ); - ensure(pick(20, 10)?.owner !== id, 'no connecting segment pick'); - if (readRegion) { - await until( - () => magenta(19, 10) > 10 && magenta(21, 10) > 10, - 'both segments drawn', - ); - ensure(magenta(20, 10) === 0, 'no connecting segment pixels'); - } - batch.show(false); - await settle(3); - ensure(pick(19, 10)?.owner !== id, 'hidden batch is not picked'); - batch.replace([ - { positions: [at(21, 9.5), at(21, 10.5)], pickId: 'replacement' }, - ]); - batch.commit(); - await settle(3); - ensure(pick(21, 10)?.owner !== id, 'replacement stays hidden'); - batch.show(true); - await until( - () => pick(21, 10)?.id === 'replacement', - 'replacement pick', - ); - ensure(pick(19, 10)?.owner !== id, 'removed segment is not picked'); - batch.replace([]); - batch.commit(); - await settle(3); - ensure(pick(21, 10)?.owner !== id, 'empty batch is not picked'); - } finally { - batch.destroy(); - batch.destroy(); - } - ensure( - !engine.lines.list().some((entry) => entry.id === id), - 'batch released', - ); - }, - ], - [ - 'input: picks on click and move payloads, drill, lines', - async () => { - await nadirView(1e6); - const at = (lon, lat, height = 20_000) => - cartesian3.fromDegrees(lon, lat, height); - const atlasCanvas = makeCanvas - ? makeCanvas(16, 16) - : { width: 16, height: 16 }; - if (makeCanvas) { - const context = atlasCanvas.getContext('2d'); - context.fillStyle = '#ffffff'; - context.fillRect(0, 0, 16, 16); - } - const set = engine.movers.createSet({ - id: 'contract-picks', - kind: 'sprite', - capacity: 3, - order: 100, - depthTest: 'always', - pickable: true, - sprite: { - atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } }, - sizePx: 24, - }, - }); - const quiet = engine.movers.createSet({ - id: 'contract-quiet', - kind: 'sprite', - capacity: 1, - order: 100, - depthTest: 'always', - sprite: { - atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } }, - sizePx: 24, - }, - }); - const line = engine.lines.createLine({ - id: 'contract-pick-line', - widthPx: 8, - color: [1, 1, 0, 1], - pickId: 'line-1', - }); - const clicks = []; - const moves = []; - const removers = [ - engine.input.on('click', (event) => clicks.push(event), { - pick: true, - drill: 8, - pickWindow: { width: 9, height: 9 }, - }), - engine.input.on('move', (event) => moves.push(event), { - pick: true, - }), - ]; - try { - const a = at(20, 10); - const b = at(21, 10.5); - set.set(0, { position: a, pickId: 'a', color: [1, 0, 1, 1] }); - set.set(1, { position: b, pickId: 'b', color: [1, 0, 1, 1] }); - set.set(2, { position: b, pickId: 'c', color: [1, 0, 1, 1] }); - set.setCount(3); - set.commit(); - line.setPositions([at(19, 9), at(21, 9)]); - const unpickable = at(19.5, 11); - quiet.set(0, { position: unpickable, pickId: 'quiet' }); - quiet.setCount(1); - quiet.commit(); - await settle(4); - if (!pointer) return; - const screen = (position) => engine.projector.worldToWindow(position); - const clickAt = async (position) => { - const before = clicks.length; - const { x, y } = screen(position); - await pointer.click(x, y); - await until(() => clicks.length > before, 'a click payload'); - return clicks[clicks.length - 1]; - }; - const onA = await clickAt(a); - ensure( - onA.picked?.id === 'a' && onA.picked?.owner === 'contract-picks', - `a click on sprite a picked ${JSON.stringify(onA.picked?.id)} of ${onA.picked?.owner}`, - ); - ensure(onA.picked.instance === 0, `instance ${onA.picked.instance}`); - const empty = await clickAt(at(18, 12)); - ensure( - empty.picked === undefined || empty.picked.id === undefined, - `a click on the map picked ${JSON.stringify(empty.picked?.id)}`, - ); - const onB = await clickAt(b); - const drilled = (onB.drilled ?? []).map(({ id }) => id); - ensure( - drilled.includes('b') && drilled.includes('c'), - `two sprites in one place drilled ${JSON.stringify(drilled)}`, - ); - const onQuiet = await clickAt(unpickable); - ensure( - onQuiet.picked?.owner !== 'contract-quiet' && - !(onQuiet.drilled ?? []).some( - ({ owner }) => owner === 'contract-quiet', - ), - `a pickable: false sprite was picked (${JSON.stringify(onQuiet.picked?.id)})`, - ); - const linePosition = at(20, 9); - const lineCameraBefore = cartesian3.clone( - engine.camera.pose().position, - ); - const onLine = await clickAt(linePosition); - ensure( - onLine.picked?.id === 'line-1', - `a click on the line picked ${JSON.stringify(onLine.picked?.id)}; ${ - onLine.picked?.id === 'line-1' - ? '' - : JSON.stringify({ - event: onLine.position, - projectedNow: screen(linePosition), - directNow: engine.picker.pick(onLine.position)?.id, - drilled: (onLine.drilled ?? []).map(({ id }) => id), - cameraMovedM: cartesian3.distance( - lineCameraBefore, - engine.camera.pose().position, - ), - }) - }`, - ); - const target = screen(a); - const movesBefore = moves.length; - await pointer.move(target.x + 1, target.y); - await until( - () => moves.length > movesBefore && moves.at(-1).picked?.id === 'a', - 'a hover payload over sprite a', - ); - } finally { - for (const remove of removers) remove(); - set.destroy(); - quiet.destroy(); - line.destroy(); - } - }, - ], - [ - 'lines: pixel dashes are regular, in CSS pixels', - async () => { - if (!readRegion) return; - await nadirView(1e6); - const line = engine.lines.createLine({ - id: 'contract-dashes', - widthPx: 6, - color: [1, 0, 1, 1], - dash: { lengthPx: 16, gapPx: 16 }, - }); - try { - const a = cartesian3.fromDegrees(19, 9, 20_000); - const b = cartesian3.fromDegrees(21, 9, 20_000); - line.setPositions([a, b]); - const sa = engine.projector.worldToWindow(a); - const sb = engine.projector.worldToWindow(b); - // On/off runs (CSS px) along the middle of the line. - const runs = () => { - const left = Math.round(Math.min(sa.x, sb.x)) + 4; - const width = Math.round(Math.abs(sb.x - sa.x)) - 8; - const y = Math.round((sa.y + sb.y) / 2); - const data = readRegion(left, y, width, 1); - const on = []; - const off = []; - let current = null; - let length = 0; - for (let x = 0; x < width; x++) { - const i = x * 4; - const lit = - Math.min(data[i], data[i + 2]) - data[i + 1] > 60 && - data[i] > 150; - if (lit === current) length++; - else { - if (current !== null) (current ? on : off).push(length); - current = lit; - length = 1; - } - } - // The first and last runs are cut by the window. - return { on: on.slice(1), off: off.slice(1) }; - }; - const median = (values) => - [...values].sort((x, y) => x - y)[Math.floor(values.length / 2)]; - await until(() => runs().on.length >= 3, 'dashes drawn'); - const { on, off } = runs(); - const spread = (values) => Math.max(...values) - Math.min(...values); - // What each engine draws for 16/16, for the harness to compare - // across device pixel ratios (Cesium's PolylineDash is the - // reference; Navara's must not change with the ratio). - globalThis.__qaDashes = { - dash: median(on), - gap: median(off), - period: median(on) + median(off), - }; - ensure( - on.length >= 3 && spread(on) <= 2 && spread(off) <= 2, - `irregular dashes: runs ${JSON.stringify(on)} / ${JSON.stringify(off)}`, - ); - if (engine.kind === 'navara') - ensure( - Math.abs(median(on) - 16) <= 2 && Math.abs(median(off) - 16) <= 2, - `dash ${median(on)} px, gap ${median(off)} px (16/16 asked)`, - ); - } finally { - line.destroy(); - } - }, - ], - [ - 'movers: depth tests, rotation, cells, scale, count, points and models', - async () => { - await nadirView(1e6); - const at = (lon, lat, height = 20_000) => - cartesian3.fromDegrees(lon, lat, height); - // Atlas: 'red' a red square; 'up' white in its top half only. - const atlas = makeCanvas - ? makeCanvas(32, 16) - : { width: 32, height: 16 }; - if (makeCanvas) { - const context = atlas.getContext('2d'); - context.fillStyle = '#ff0000'; - context.fillRect(0, 0, 16, 16); - context.fillStyle = '#ffffff'; - context.fillRect(16, 0, 16, 8); - } - const cells = { red: [0, 0, 0.5, 1], up: [0.5, 0, 1, 1] }; - const sprites = (id, extra = {}) => - engine.movers.createSet({ - id, - kind: 'sprite', - capacity: 3, - order: 100, - depthTest: 'always', - sprite: { atlas: { canvas: atlas, cells }, sizePx: 24 }, - ...extra, - }); - const sets = []; - const make = (set) => (sets.push(set), set); - const RED = [1, 0, 0]; - const MAGENTA = [1, 0, 1]; - // Pixels near a colour (within 60 per channel) in a box, and how - // many lie above / below the box's centre row. - const look = (position, colour, half = 40) => { - const screen = engine.projector.worldToWindow(position); - const x0 = Math.round(screen.x) - half; - const y0 = Math.round(screen.y) - half; - const data = readRegion(x0, y0, 2 * half, 2 * half); - let total = 0; - let above = 0; - for (let y = 0; y < 2 * half; y++) - for (let x = 0; x < 2 * half; x++) { - const i = (y * 2 * half + x) * 4; - if ( - colour.every( - (channel, k) => Math.abs(data[i + k] - channel * 255) <= 60, - ) - ) { - total++; - if (y < half) above++; - } - } - return { total, above, below: total - above }; - }; - try { - const a = at(19.5, 10); - const base = make(sprites('contract-cells')); - base.set(0, { position: a, cell: 'red' }); - base.set(1, { position: at(20.5, 10), cell: 'red' }); - base.set(2, { position: at(20.5, 11), cell: 'red' }); - base.setCount(3); - base.commit(); - const far = make( - sprites('contract-far', { - sprite: { - atlas: { canvas: atlas, cells }, - sizePx: 24, - // The camera is 1000 km away: past `far`, half size. - scaleByDistance: [1e3, 1, 1e5, 0.5], - }, - }), - ); - const b = at(19.5, 11); - far.set(0, { position: b, cell: 'red' }); - far.setCount(1); - far.commit(); - const deep = (lon) => at(lon, 9, -30_000); - const depth = { - normal: make(sprites('contract-normal', { depthTest: 'normal' })), - always: make(sprites('contract-always', { depthTest: 'always' })), - within: make( - sprites('contract-within', { - depthTest: { disableWithinM: 2e7 }, - }), - ), - near: make( - sprites('contract-near', { depthTest: { disableWithinM: 10 } }), - ), - }; - Object.values(depth).forEach((set, index) => { - set.set(0, { position: deep(19 + index * 0.7), cell: 'red' }); - set.setCount(1); - set.commit(); - }); - const points = make( - engine.movers.createSet({ - id: 'contract-points', - kind: 'point', - capacity: 1, - depthTest: 'always', - point: { sizePx: 20, outlinePx: 3 }, - }), - ); - const p = at(20.5, 9.5); - points.set(0, { position: p, color: [1, 0, 1, 1] }); - points.setCount(1); - points.commit(); - if (!readRegion) { - base.setCount(1); - base.commit(); - ensure(base.count() === 1, 'setCount did not shrink the set'); - return; - } - await until(() => look(a, RED).total > 300, 'the red cell drawn'); - const unit = look(a, RED).total; - ensure( - look(at(20.5, 11), RED).total > 300, - 'the third sprite is not drawn', - ); - // Scale by distance: a quarter of the area. - const farArea = look(b, RED).total; - ensure( - farArea > unit * 0.12 && farArea < unit * 0.4, - `scaleByDistance drew ${farArea} px against ${unit} at full size`, - ); - // Scale 2: four times the area. - base.set(0, { scale: 2 }); - base.commit(); - await until( - () => look(a, RED).total > unit * 3, - 'the sprite drawn at scale 2', - ); - ensure( - look(a, RED).total < unit * 5, - `scale 2 drew ${look(a, RED).total} px against ${unit}`, - ); - // Cell switch: the 'up' cell is white in its top half (magenta - // times the instance colour), then turned upside down. - base.set(0, { scale: 1, cell: 'up', color: [1, 0, 1, 1] }); - base.commit(); - await until(() => look(a, MAGENTA).total > 100, 'the up cell drawn'); - const upright = look(a, MAGENTA); - ensure( - upright.above > 10 * Math.max(1, upright.below) && - look(a, RED).total < 10, - `the up cell: ${JSON.stringify(upright)}, red ${look(a, RED).total}`, - ); - base.set(0, { rotation: Math.PI }); - base.commit(); - await until( - () => look(a, MAGENTA).below > 100, - 'the rotated cell drawn', - ); - const turned = look(a, MAGENTA); - ensure( - turned.below > 10 * Math.max(1, turned.above), - `rotation π: ${JSON.stringify(turned)}`, - ); - // setCount shrinks: the others go. - base.setCount(1); - base.commit(); - await until( - () => look(at(20.5, 11), RED).total < 10, - 'the shrunk set drawn', - ); - ensure(look(a, MAGENTA).total > 100, 'instance 0 went with them'); - // Depth: under the ground only 'always' and a disableWithinM - // reaching past the camera draw. - const drawn = (index) => - look(deep(19 + index * 0.7), RED).total > 100; - await until(() => drawn(1), "the 'always' sprite drawn"); - ensure(drawn(2), 'disableWithinM past the camera is hidden'); - // Cesium's billboards under the app's photoreal tiles (globe - // hidden) are not occluded by them, measured; Navara's overlay - // tests the scene depth. Occlusion is checked on Navara only. - if (engine.kind === 'navara') { - ensure(!drawn(0), "a 'normal' sprite under the ground is drawn"); - ensure(!drawn(3), 'disableWithinM 10 m under the ground is drawn'); - } - // Points: a magenta disc with a dark outline. - await until(() => look(p, MAGENTA, 16).total > 80, 'the point drawn'); - const ring = look(p, [0, 0, 0], 16).total; - ensure(ring > 20, `no dark outline around the point (${ring} px)`); - // Models: an aircraft drawn in magenta (blended by replace), at - // least 80 px; lit and graded on Navara, so magenta-leaning. - const models = make( - engine.movers.createSet({ - id: 'contract-models', - kind: 'model', - capacity: 1, - model: { - url: '/models/airplane.glb', - minimumPixelSize: 80, - colorBlend: { mode: 'replace', amount: 1 }, - }, - }), - ); - const m = at(19.2, 9.6, 50_000); - models.set(0, { position: m, color: [1, 0, 1, 1] }); - models.setCount(1); - models.commit(); - const modelPixels = () => { - const screen = engine.projector.worldToWindow(m); - const data = readRegion( - Math.round(screen.x) - 50, - Math.round(screen.y) - 50, - 100, - 100, - ); - let count = 0; - for (let i = 0; i < data.length; i += 4) - if ( - Math.min(data[i], data[i + 2]) - data[i + 1] > 40 && - data[i] > 60 - ) - count++; - return count; - }; - // ready() turns true once the glTF has loaded (the magenta point - // and sprites above can already fill the region before then). - await until(() => models.ready(), 'the model set ready()'); - ensure( - models.ready(0) === true, - 'the drawn instance is not ready(0)', - ); - await until(() => modelPixels() > 150, 'the model drawn'); - } finally { - for (const set of sets) set.destroy(); - } - }, - ], - [ - 'render: continuous mode toggles', - async () => { - const previous = engine.render.isContinuous(); - try { - engine.render.setContinuous(true); - ensure( - engine.render.isContinuous() === true, - 'continuous not enabled', - ); - engine.render.setContinuous(false); - ensure( - engine.render.isContinuous() === false, - 'continuous not disabled', - ); - } finally { - engine.render.setContinuous(previous); - } - }, - ], - [ - 'render.setSharpen: merges partial updates, rejects bad values, draws', - async () => { - const previous = engine.render.sharpen(); - const same = (expected, label) => { - const actual = engine.render.sharpen(); - ensure( - actual.enabled === expected.enabled && - actual.amount === expected.amount, - `${label}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`, - ); - }; - ensure( - typeof previous.enabled === 'boolean' && - Number.isFinite(previous.amount), - `sharpen() ${JSON.stringify(previous)} is not { enabled, amount }`, - ); - try { - engine.render.setSharpen({ enabled: true, amount: 1.7 }); - same({ enabled: true, amount: 1.7 }, 'after enabling'); - engine.render.sharpen().amount = 0; - same({ enabled: true, amount: 1.7 }, 'sharpen() is a copy'); - // A frame with the sharpening on draws without errors. - await settle(2); - engine.render.setSharpen({ amount: 0.5 }); - same({ enabled: true, amount: 0.5 }, 'after an amount alone'); - engine.render.setSharpen({ enabled: false }); - same({ enabled: false, amount: 0.5 }, 'after disabling'); - for (const bad of [ - { enabled: 'yes' }, - { amount: -0.1 }, - { amount: Number.NaN }, - { amount: '1' }, - ]) { - let error = null; - try { - engine.render.setSharpen(bad); - } catch (caught) { - error = caught; - } - ensure( - error instanceof TypeError, - `setSharpen(${JSON.stringify(bad)}) did not throw a TypeError`, - ); - } - same({ enabled: false, amount: 0.5 }, 'after rejected values'); - await settle(1); - } finally { - engine.render.setSharpen(previous); - await settle(1); - } - }, - ], - [ - 'camera.flyTo: duration 0 lands and completes before flyTo returns', - async () => { - await nadirView(); - const destination = cartesian3.fromDegrees(-3.7, 40.4, 50000); - const outcome = []; - camera.flyTo({ - destination, - orientation: { heading: 0.5, pitch: -0.8, roll: 0 }, - duration: 0, - complete: () => outcome.push('complete'), - cancel: () => outcome.push('cancel'), - }); - ensure( - outcome.join() === 'complete', - `flyTo returned with [${outcome}] instead of [complete]`, - ); - ensure(!camera.flightActive(), 'a duration-0 flight is still active'); - const pose = camera.pose(); - near( - cartesian3.distance(pose.position, destination), - 0, - 0.01, - 'position error (m)', - ); - near(angleBetween(pose.heading, 0.5), 0, 1e-6, 'heading error'); - near(pose.pitch, -0.8, 1e-6, 'pitch'); - }, - ], - [ - 'camera.flyTo: cancelFlight calls cancel and never complete', - async () => { - await nadirView(); - const outcome = []; - camera.flyTo({ - destination: cartesian3.fromDegrees(100, 0, 5e6), - duration: 5, - easing: 'linear', - complete: () => outcome.push('complete'), - cancel: () => outcome.push('cancel'), - }); - await settle(2); - camera.cancelFlight(); - await until(() => outcome.length > 0, 'flight cancellation'); - await settle(3); - ensure(outcome.join() === 'cancel', `flight ended with ${outcome}`); - }, - ], - [ - 'camera.completeFlight: lands a running flight at once and calls complete', - async () => { - await nadirView(); - camera.completeFlight(); - const outcome = []; - const destination = cartesian3.fromDegrees(100, 0, 5e6); - camera.flyTo({ - destination, - orientation: { heading: 0.5, pitch: -0.8, roll: 0 }, - duration: 5, - complete: () => outcome.push('complete'), - cancel: () => outcome.push('cancel'), - }); - await settle(2); - camera.completeFlight(); - ensure( - outcome.join() === 'complete', - `completeFlight returned with [${outcome}] instead of [complete]`, - ); - ensure(!camera.flightActive(), 'the flight is still active'); - const pose = camera.pose(); - near( - cartesian3.distance(pose.position, destination), - 0, - 0.01, - 'position error (m)', - ); - near(angleBetween(pose.heading, 0.5), 0, 1e-6, 'heading error'); - near(pose.pitch, -0.8, 1e-6, 'pitch'); - await settle(3); - ensure(outcome.join() === 'complete', `flight ended with ${outcome}`); - }, - ], - [ - 'camera.setView: cancels an active flight and retains its destination', - async () => { - await nadirView(); - const outcome = []; - camera.flyTo({ - destination: cartesian3.fromDegrees(100, 0, 5e6), - duration: 5, - complete: () => outcome.push('complete'), - cancel: () => outcome.push('cancel'), - }); - await settle(2); - const destination = cartesian3.fromDegrees(20, 10, 200000); - camera.setView({ destination }); - await settle(3); - ensure(outcome.join() === 'cancel', `flight ended with ${outcome}`); - ensure(!camera.flightActive(), 'setView left the flight active'); - near( - cartesian3.distance(camera.pose().position, destination), - 0, - 0.01, - 'setView position error (m)', - ); - }, - ], - [ - 'camera.flyTo: a newer flight cancels the previous one', - async () => { - await nadirView(); - const outcome = []; - camera.flyTo({ - destination: cartesian3.fromDegrees(100, 0, 5e6), - duration: 5, - complete: () => outcome.push('first complete'), - cancel: () => outcome.push('first cancel'), - }); - await settle(1); - camera.flyTo({ - destination: cartesian3.fromDegrees(20, 10, 2e6), - duration: 0, - complete: () => outcome.push('second complete'), - }); - await until(() => outcome.includes('second complete'), 'second flight'); - ensure( - outcome.join() === 'first cancel,second complete', - `flights ended with ${outcome}`, - ); - }, - ], - [ - 'camera.flyToSphere: ends at the range, looking at the centre', - async () => { - await nadirView(); - const center = cartesian3.fromDegrees(2.35, 48.85, 100); - let done = false; - camera.flyToSphere( - { center, radius: 500 }, - { - heading: 0.3, - pitch: -0.5, - range: 5000, - duration: 0, - complete: () => (done = true), - }, - ); - await until(() => done, 'sphere flight completion'); - const pose = camera.pose(); - near(cartesian3.distance(pose.position, center), 5000, 5, 'range (m)'); - const toCenter = unit( - cartesian3.subtract(center, pose.position, cartesian3.create()), - ); - near( - cartesian3.dot(toCenter, pose.direction), - 1, - 1e-6, - 'direction·toCentre', - ); - }, - ], - [ - 'camera.lookAt: holds the range toward the target until release', - async () => { - await nadirView(); - const target = cartesian3.fromDegrees(-0.12, 51.5, 0); - camera.lookAt(target, { heading: 0.4, pitch: -0.7, range: 2000 }); - await settle(); - const pose = camera.pose(); - near(cartesian3.distance(pose.position, target), 2000, 1, 'range (m)'); - const toTarget = unit( - cartesian3.subtract(target, pose.position, cartesian3.create()), - ); - near( - cartesian3.dot(toTarget, pose.direction), - 1, - 1e-6, - 'direction·toTarget', - ); - camera.release(); - await settle(); - near( - cartesian3.distance(camera.pose().position, pose.position), - 0, - 0.01, - 'release moved the camera (m)', - ); - }, - ], - [ - 'projector.worldToWindow: a point ahead projects to the canvas centre', - async () => { - await nadirView(); - const pose = camera.pose(); - const window = engine.projector.worldToWindow( - along(pose.position, pose.direction, 1000), - ); - ensure(window, 'a point ahead did not project'); - const expected = centre(); - near(window.x, expected.x, 1, 'x (px)'); - near(window.y, expected.y, 1, 'y (px)'); - }, - ], - [ - 'projector.worldToWindow: a point behind the camera is undefined', - async () => { - await nadirView(); - const pose = camera.pose(); - const behind = along(pose.position, pose.direction, -1000); - ensure( - engine.projector.worldToWindow(behind) === undefined, - 'a point behind the camera projected', - ); - }, - ], - [ - 'projector.worldToWindow: up on screen is camera up, and result is filled', - async () => { - await nadirView(); - const pose = camera.pose(); - const ahead = along(pose.position, pose.direction, 1000); - const result = cartesian2.create(); - const upper = engine.projector.worldToWindow( - along(ahead, pose.up, 100), - result, - ); - ensure( - upper === result, - 'worldToWindow did not return its result argument', - ); - const lefter = engine.projector.worldToWindow( - along(ahead, pose.right, -100), - ); - const expected = centre(); - ensure( - upper.y < expected.y, - `camera up projected below the centre (${upper.y})`, - ); - ensure( - lefter.x < expected.x, - `camera left projected right of the centre (${lefter.x})`, - ); - }, - ], - [ - 'camera.pickRay: the centre ray starts at the camera along its direction', - async () => { - await nadirView(); - const pose = camera.pose(); - const pickRay = camera.pickRay(centre()); - ensure(isCartesian3(pickRay?.origin), 'pickRay has no origin'); - near( - cartesian3.distance(pickRay.origin, pose.position), - 0, - 1, - 'origin error (m)', - ); - near( - cartesian3.dot(unit(pickRay.direction), pose.direction), - 1, - 1e-6, - 'direction·pose', - ); - }, - ], - [ - 'camera.pickEllipsoid: the nadir hits the surface and the sky misses', - async () => { - await nadirView(); - const hit = camera.pickEllipsoid(centre()); - ensure(isCartesian3(hit), 'the nadir missed the ellipsoid'); - const point = cartographic.fromCartesian(hit, WGS84); - near(point.height, 0, 1, 'surface height (m)'); - // Off the surface, Cesium's local up is the scaled-surface normal, - // which misses the geodetic nadir by about 0.0015° at 1000 km. - near(math.toDegrees(point.longitude), 20, 0.01, 'longitude (deg)'); - near(math.toDegrees(point.latitude), 10, 0.01, 'latitude (deg)'); - camera.setView({ - destination: cartesian3.fromDegrees(20, 10, 1e6), - orientation: { heading: 0, pitch: 0.6, roll: 0 }, - }); - await settle(); - ensure( - camera.pickEllipsoid(centre()) === undefined, - 'the sky hit the ellipsoid', - ); - }, - ], - [ - 'projector.worldToWindow: round-trips camera.pickEllipsoid', - async () => { - await nadirView(); - const { width, height } = engine.host.size(); - const screen = cartesian2.create(width / 4, height / 3); - const hit = camera.pickEllipsoid(screen); - ensure(isCartesian3(hit), 'the screen point missed the ellipsoid'); - const back = engine.projector.worldToWindow(hit); - ensure(back, 'the picked point did not project'); - near(back.x, screen.x, 1, 'x (px)'); - near(back.y, screen.y, 1, 'y (px)'); - }, - ], - [ - 'camera.viewRectangle: contains the nadir point', - async () => { - await nadirView(); - const rectangle = camera.viewRectangle(); - ensure(rectangle, 'no view rectangle looking straight down'); - const lon = math.toRadians(20); - const lat = math.toRadians(10); - ensure( - rectangle.west < lon && lon < rectangle.east, - `longitude outside ${rectangle.west}..${rectangle.east}`, - ); - ensure( - rectangle.south < lat && lat < rectangle.north, - `latitude outside ${rectangle.south}..${rectangle.north}`, - ); - }, - ], - [ - 'picker: pick, drillPick and pickPosition return contract shapes', - async () => { - await nadirView(); - const screen = cartesian2.create(1, 1); - const picked = engine.picker.pick(screen); - ensure( - picked === undefined || 'id' in picked, - 'pick returned no id field', - ); - const drilled = engine.picker.drillPick(screen, 3); - ensure(Array.isArray(drilled), 'drillPick did not return an array'); - ensure(drilled.length <= 3, 'drillPick ignored its limit'); - ensure( - typeof engine.picker.pickPositionSupported === 'boolean', - 'pickPositionSupported is not boolean', - ); - const position = engine.picker.pickPosition(centre()); - ensure( - position === undefined || isCartesian3(position), - 'pickPosition is not a Cartesian3', - ); - }, - ], - [ - 'surface.sampleHeightDetailed: source, tile size and mover-set exclusion', - async () => { - const place = cartographic.fromDegrees(20, 10, 0); - const shape = (detail, label) => { - if (detail === undefined) return; - ensure(Number.isFinite(detail.height), `${label}: height`); - ensure( - detail.source === 'tiles' || detail.source === 'terrain', - `${label}: source ${detail.source}`, - ); - ensure( - detail.tileSizeM === undefined || - (detail.source === 'tiles' && detail.tileSizeM > 0), - `${label}: tileSizeM ${detail.tileSizeM}`, - ); - }; - const visible = engine.surface.sampleHeightDetailed(place); - shape(visible, 'visible'); - if (visible?.source === 'tiles') - near( - engine.surface.sampleHeight(place), - visible.height, - 1e-6, - 'sampleHeight and the detailed tiles answer', - ); - // A mover set in exclude: the sample works and the set stays. - const set = engine.movers.createSet({ - id: 'contract-surface-exclude', - kind: 'point', - capacity: 1, - point: { sizePx: 8 }, - }); - try { - set.set(0, { position: cartesian3.fromDegrees(20, 10, 50) }); - set.setCount(1); - set.commit(); - shape( - engine.surface.sampleHeightDetailed(place, { exclude: [set] }), - 'excluding a set', - ); - ensure(set.id === 'contract-surface-exclude', 'the set has no id'); - ensure( - engine.movers - .list() - .some(({ id }) => id === 'contract-surface-exclude'), - 'the excluded set is gone', - ); - } finally { - set.destroy(); - } - }, - ], - [ - 'surface: flags, tilesLoaded, sampleHeight, terrainHeight and clampToHeight shapes', - async () => { - for (const flag of ['terrainHeightSupported', 'clampToHeightSupported']) - ensure( - typeof engine.surface[flag] === 'boolean', - `${flag} is not boolean`, - ); - const place = cartographic.fromDegrees(20, 10, 0); - if (engine.surface.terrainHeightSupported) { - const terrain = engine.surface.terrainHeight(place); - ensure( - terrain === undefined || Number.isFinite(terrain), - `terrainHeight returned ${terrain}`, - ); - } - if (engine.surface.clampToHeightSupported) { - const clamped = engine.surface.clampToHeight( - cartesian3.fromDegrees(20, 10, 5000), - ); - ensure( - clamped === undefined || isCartesian3(clamped), - 'clampToHeight is not a Cartesian3', - ); - } - ensure( - typeof engine.surface.supported === 'boolean', - 'supported is not boolean', - ); - ensure( - typeof engine.surface.tilesLoaded() === 'boolean', - 'tilesLoaded is not boolean', - ); - if (!engine.surface.supported) return; - const value = engine.surface.sampleHeight( - cartographic.fromDegrees(20, 10, 0), - ); - ensure( - value === undefined || Number.isFinite(value), - `sampleHeight returned ${value}`, - ); - }, - ], - [ - 'input.acquirePointer: exclusive subscriptions, release and camera gestures', - async () => { - const input = engine.input; - const before = input.cameraInputsEnabled(); - const claim = input.acquirePointer(); - ensure(claim, 'no pointer claim'); - const removers = []; - let ambient = 0, - owned = 0; - try { - ensure(input.acquirePointer() === null, 'a second claim succeeded'); - ensure( - input.cameraInputsEnabled() === before, - 'claim disabled camera gestures', - ); - removers.push(input.on('click', () => ambient++)); - removers.push(input.on('click', () => owned++, { pointer: claim })); - if (pointer) { - await pointer.click(100, 100); - await settle(); - ensure(owned === 1 && ambient === 0, 'exclusive click leaked'); - } - claim.release(); - claim.release(); - if (pointer) { - await pointer.click(100, 100); - await settle(); - ensure( - owned === 1 && ambient === 1, - 'released click stayed exclusive', - ); - } - const next = input.acquirePointer(); - ensure(next, 'release did not free the pointer'); - claim.release(); - ensure( - input.acquirePointer() === null, - 'stale release freed its successor', - ); - next.release(); - } finally { - claim.release(); - for (const remove of removers) remove(); - } - }, - ], - [ - 'camera.follow: swings to an ENU offset and cancels on handoff', - async () => { - const target = cartesian3.fromDegrees(10, 30, 5000); - const goal = { x: 0, y: 4800, z: 3300 }; - const handle = camera.follow({ - position: () => target, - viewFrom: { x: 0, y: -4800, z: 3300 }, - swingTo: goal, - }); - try { - await until( - () => - cartesian3.distance( - enuOffset(target, camera.pose().position), - goal, - ) < 5, - 'the shared follow offset', - ); - ensure(camera.following() === handle, 'swing replaced the follow'); - handle.retarget({ swingTo: { x: 0, y: -4800, z: 3300 } }); - handle.retarget({ swingTo: null }); - await settle(3); - near( - cartesian3.distance( - enuOffset(target, camera.pose().position), - goal, - ), - 0, - 5, - 'cancelled swing moved the camera', - ); - camera.setView({ - destination: cartesian3.fromDegrees(10, 30, 50000), - }); - await settle(3); - ensure( - camera.following() === null, - 'camera handoff retained the swing', - ); - } finally { - handle.stop(); - } - }, - ], - [ - 'input: every type subscribes and unsubscribes; camera inputs toggle', - async () => { - for (const type of INPUT_TYPES) { - for (const modifier of [undefined, ...MODIFIERS]) { - const off = engine.input.on(type, () => {}, { modifier }); - ensure( - typeof off === 'function', - `input.on('${type}', ${modifier}) returned no remover`, - ); - off(); - off(); - } - } - }, - ], - [ - 'input.suspendCameraInputs: counted, the last resume restores the state before the first', - async () => { - const { input } = engine; - const before = input.cameraInputsEnabled(); - ensure( - typeof before === 'boolean', - `cameraInputsEnabled() returned ${before}`, - ); - const outer = input.suspendCameraInputs(); - const inner = input.suspendCameraInputs(); - try { - ensure( - typeof outer === 'function' && typeof inner === 'function', - 'suspendCameraInputs returned no resume', - ); - ensure(!input.cameraInputsEnabled(), 'on under a suspension'); - inner(); - inner(); - ensure( - !input.cameraInputsEnabled(), - 'an inner resume lifted the outer suspension', - ); - outer(); - ensure( - input.cameraInputsEnabled() === before, - 'last resume did not restore inputs', - ); - } finally { - inner(); - outer(); - } - }, - ], - [ - 'credits.addStatic: returns an idempotent remover', - async () => { - const remove = engine.credits.addStatic('Contract check', { - onScreen: false, - }); - ensure(typeof remove === 'function', 'addStatic returned no remover'); - remove(); - remove(); - }, - ], - [ - 'maps.setPhotorealTiles(null): photoreal is inactive', - async () => { - engine.maps.setPhotorealTiles(null); - ensure( - engine.maps.photorealActive() === false, - 'photoreal still active', - ); - }, - ], - [ - 'events: adding a listener twice registers it once', - async () => { - let once = 0; - let twice = 0; - const reference = () => once++; - const listener = () => twice++; - const removers = [ - engine.frames.postRender.addEventListener(reference), - engine.frames.postRender.addEventListener(listener), - engine.frames.postRender.addEventListener(listener), - ]; - try { - await until(() => once >= 2, 'postRender'); - } finally { - for (const remove of removers) remove(); - } - ensure( - twice === once, - `a listener added twice fired ${twice} times in ${once} frames`, - ); - removers[1](); - removers[2](); - await settle(2); - ensure(twice === once, 'a removed listener still fired'); - }, - ], - [ - 'events: removal during dispatch skips the listener; re-adding keeps it', - async () => { - const event = engine.frames.postRender; - const seen = []; - const victim = () => seen.push('victim'); - const survivor = () => seen.push('survivor'); - let dispatches = 0; - const remover = () => { - dispatches++; - if (dispatches !== 1) return; - event.removeEventListener(victim); - event.removeEventListener(survivor); - event.addEventListener(survivor); - }; - event.addEventListener(remover); - event.addEventListener(victim); - event.addEventListener(survivor); - try { - await until(() => dispatches >= 3, 'three postRender dispatches'); - } finally { - for (const listener of [remover, victim, survivor]) - event.removeEventListener(listener); - } - ensure( - !seen.includes('victim'), - 'a listener removed during dispatch ran', - ); - ensure( - seen.includes('survivor'), - 'a listener removed and re-added during dispatch was lost', - ); - }, - ], - [ - 'camera.pose: angle ranges follow Cesium', - async () => { - const destination = cartesian3.fromDegrees(30, 20, 20000); - camera.setView({ - destination, - orientation: { heading: -0.5, pitch: -0.4, roll: -0.2 }, - }); - await settle(); - let pose = camera.pose(); - inRange(pose.heading, 0, math.TWO_PI, 'heading'); - inRange(pose.roll, 0, math.TWO_PI, 'roll'); - near(pose.heading, math.TWO_PI - 0.5, 1e-6, 'heading of −0.5'); - near(pose.roll, math.TWO_PI - 0.2, 1e-6, 'roll of −0.2'); - ensure( - pose.pitch >= -math.PI_OVER_TWO && pose.pitch <= math.PI_OVER_TWO, - `pitch ${pose.pitch} outside [−π/2, π/2]`, - ); - camera.setView({ - destination, - orientation: { heading: 0.7, pitch: -math.PI_OVER_TWO, roll: 0.3 }, - }); - await settle(); - pose = camera.pose(); - near(pose.roll, 0, 0, 'roll looking straight down'); - inRange(pose.heading, 0, math.TWO_PI, 'heading looking straight down'); - }, - ], - [ - 'camera.setView: heading π/2 at pitch 0 looks east', - async () => { - const destination = cartesian3.fromDegrees(-120, 35, 3000); - camera.setView({ - destination, - orientation: { heading: math.PI_OVER_TWO, pitch: 0, roll: 0 }, - }); - await settle(); - const pose = camera.pose(); - const axes = localAxes(pose.position); - near( - cartesian3.dot(pose.direction, axes.east), - 1, - 1e-6, - 'direction·east', - ); - near(cartesian3.dot(pose.up, axes.up), 1, 1e-6, 'up·localUp'); - }, - ], - [ - 'camera.setView: pitch −π/2 looks down the local up', - async () => { - const destination = cartesian3.fromDegrees(-120, 35, 3000); - camera.setView({ - destination, - orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, - }); - await settle(); - const pose = camera.pose(); - const axes = localAxes(pose.position); - near(cartesian3.dot(pose.direction, axes.up), -1, 1e-6, 'direction·up'); - near( - cartesian3.dot(pose.up, axes.north), - 1, - 1e-6, - 'up·north at heading 0', - ); - }, - ], - [ - 'camera.setView: positive roll tilts up toward the camera right', - async () => { - const destination = cartesian3.fromDegrees(10, 45, 3000); - camera.setView({ - destination, - orientation: { heading: 0.4, pitch: -0.2, roll: 0 }, - }); - await settle(); - const level = camera.pose(); - camera.setView({ - destination, - orientation: { heading: 0.4, pitch: -0.2, roll: 0.3 }, - }); - await settle(); - const rolled = camera.pose(); - near( - cartesian3.dot(rolled.up, level.right), - Math.sin(0.3), - 1e-6, - 'up·levelRight', - ); - near( - cartesian3.dot(rolled.direction, level.direction), - 1, - 1e-9, - 'roll changed the direction', - ); - }, - ], - [ - 'camera.flightActive: true during a flight, false after it ends', - async () => { - await nadirView(); - ensure(!camera.flightActive(), 'a flight is active before flyTo'); - camera.flyTo({ - destination: cartesian3.fromDegrees(100, 0, 5e6), - duration: 5, - }); - ensure(camera.flightActive(), 'no flight is active after flyTo'); - camera.cancelFlight(); - ensure( - !camera.flightActive(), - 'the flight is active after cancelFlight', - ); - }, - ], - [ - 'camera.flyToSphere: Cesium default heading, pitch and range', - async () => { - await nadirView(); - const center = cartesian3.fromDegrees(2.35, 48.85, 100); - const radius = 1000; - let done = false; - camera.flyToSphere( - { center, radius }, - { duration: 0, complete: () => (done = true) }, - ); - ensure( - done, - 'a duration-0 sphere flight did not complete synchronously', - ); - const pose = camera.pose(); - const { width, height } = engine.host.size(); - const tanPhi = Math.tan(pose.fovy / 2); - const tanTheta = (width / height) * tanPhi; - const range = Math.max(radius / tanTheta, radius / tanPhi); - near( - cartesian3.distance(pose.position, center), - range, - range * 1e-6, - 'range (m)', - ); - const axes = localAxes(center); - const toCamera = unit( - cartesian3.subtract(pose.position, center, cartesian3.create()), - ); - near( - cartesian3.dot(toCamera, axes.up), - Math.sin(Math.PI / 4), - 1e-6, - 'pitch −π/4', - ); - near( - cartesian3.dot(toCamera, axes.east), - 0, - 1e-6, - 'heading 0 (east offset)', - ); - ensure( - cartesian3.dot(toCamera, axes.north) < 0, - 'camera is not south of the centre', - ); - camera.flyToSphere({ center, radius: 0 }, { duration: 0 }); - near( - cartesian3.distance(camera.pose().position, center), - 100, - 1e-3, - 'minimum range (m)', - ); - const identity = camera.transform(); - for (let index = 0; index < 16; index++) - near( - identity[index], - matrix4.IDENTITY[index], - 0, - `transform[${index}]`, - ); - }, - ], - [ - 'camera.lookAtTransform: heading/pitch/range and Cartesian offsets, transform and release', - async () => { - await nadirView(); - const target = cartesian3.fromDegrees(-0.12, 51.5, 0); - const { frame, up } = localAxes(target); - camera.lookAtTransform(frame, { - heading: 0.4, - pitch: -0.7, - range: 2000, - }); - await settle(); - let pose = camera.pose(); - near( - cartesian3.distance(pose.position, target), - 2000, - 1e-3, - 'range (m)', - ); - const aim = () => - cartesian3.dot( - unit( - cartesian3.subtract( - target, - camera.pose().position, - cartesian3.create(), - ), - ), - camera.pose().direction, - ); - near(aim(), 1, 1e-9, 'direction·toTarget'); - const held = camera.transform(); - for (let index = 0; index < 16; index++) - near(held[index], frame[index], 1e-6, `transform[${index}]`); - camera.lookAtTransform(frame, cartesian3.create(0, -1000, 1000)); - await settle(); - pose = camera.pose(); - near( - cartesian3.distance(pose.position, target), - Math.SQRT2 * 1000, - 1e-3, - 'Cartesian offset range (m)', - ); - near(aim(), 1, 1e-9, 'Cartesian offset direction·toTarget'); - ensure( - cartesian3.dot(pose.up, up) > 0, - 'up does not follow the frame +Z', - ); - camera.release(); - await settle(); - near( - cartesian3.distance(camera.pose().position, pose.position), - 0, - 0.01, - 'release moved the camera (m)', - ); - const released = camera.transform(); - for (let index = 0; index < 16; index++) - near( - released[index], - matrix4.IDENTITY[index], - 0, - `released transform[${index}]`, - ); - }, - ], - [ - 'camera.zoomIn and zoomOut move along the view direction', - async () => { - camera.setView({ - destination: cartesian3.fromDegrees(20, 10, 50000), - orientation: { heading: 0.3, pitch: -0.9, roll: 0 }, - }); - await settle(); - const start = camera.pose(); - camera.zoomIn(2000); - await settle(); - const zoomed = camera.pose().position; - const moved = cartesian3.subtract( - zoomed, - start.position, - cartesian3.create(), - ); - near( - cartesian3.dot(moved, start.direction), - 2000, - 1e-3, - 'zoomIn distance (m)', - ); - near( - cartesian3.magnitude(moved), - 2000, - 1e-3, - 'zoomIn stays on the view axis', - ); - camera.zoomOut(500); - await settle(); - near( - cartesian3.distance(camera.pose().position, start.position), - 1500, - 1e-3, - 'zoomOut distance (m)', - ); - }, - ], - [ - 'camera.viewRectangle: wraps west > east across the antimeridian', - async () => { - camera.setView({ - destination: cartesian3.fromDegrees(180, 0, 1e6), - orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, - }); - await settle(); - const rectangle = camera.viewRectangle(); - ensure(rectangle, 'no view rectangle over the antimeridian'); - ensure( - rectangle.west > rectangle.east, - `west ${rectangle.west} is not east of east ${rectangle.east}`, - ); - ensure( - rectangle.west > 0 && rectangle.east < 0, - 'the rectangle does not cross ±180°', - ); - ensure( - rectangle.south < 0 && rectangle.north > 0, - 'the rectangle misses the equator', - ); - }, - ], - [ - 'camera.setView and flyTo: missing angles default to heading 0, pitch −π/2, roll 0', - async () => { - const destination = cartesian3.fromDegrees(40, 30, 20000); - const expectDefaults = (label) => { - const pose = camera.pose(); - near(pose.pitch, -math.PI_OVER_TWO, 1e-6, `${label} pitch`); - near(angleBetween(pose.heading, 0), 0, 1e-6, `${label} heading`); - near(pose.roll, 0, 1e-6, `${label} roll`); - }; - const tilted = { heading: 1, pitch: -0.3, roll: 0.2 }; - camera.setView({ destination, orientation: tilted }); - camera.setView({ destination }); - await settle(); - expectDefaults('setView without orientation'); - camera.setView({ destination, orientation: tilted }); - camera.setView({ destination, orientation: {} }); - expectDefaults('setView with empty orientation'); - camera.setView({ destination, orientation: tilted }); - camera.flyTo({ destination, duration: 0 }); - expectDefaults('flyTo without orientation'); - camera.setView({ destination, orientation: tilted }); - camera.setView({ destination, orientation: { heading: 1 } }); - const partial = camera.pose(); - near(angleBetween(partial.heading, 1), 0, 1e-6, 'partial heading'); - near(partial.pitch, -math.PI_OVER_TWO, 1e-6, 'partial pitch default'); - }, - ], - [ - 'camera.viewRectangle: a horizontal view is bounded by the horizon', - async () => { - camera.setView({ - destination: cartesian3.fromDegrees(20, 10, 100000), - orientation: { heading: 0, pitch: 0, roll: 0 }, - }); - await settle(); - const rectangle = camera.viewRectangle(); - ensure(rectangle, 'no view rectangle looking at the horizon'); - for (const side of ['west', 'south', 'east', 'north']) - ensure(Number.isFinite(rectangle[side]), `${side} is not finite`); - // Looking north, Cesium's rectangle is built from the canvas corners - // that hit the globe (the bottom ones) and horizon points for the - // rest; it need not contain the camera's own position. - const { width, height } = engine.host.size(); - for (const x of [0, width]) { - const ground = camera.pickEllipsoid(cartesian2.create(x, height)); - ensure( - isCartesian3(ground), - `the bottom corner at x=${x} missed the globe`, - ); - const seen = cartographic.fromCartesian(ground, WGS84); - ensure( - rectangle.west <= seen.longitude + 1e-9 && - seen.longitude - 1e-9 <= rectangle.east && - rectangle.south <= seen.latitude + 1e-9 && - seen.latitude - 1e-9 <= rectangle.north, - `the visible corner at x=${x} lies outside the rectangle`, - ); - } - const lon = math.toRadians(20); - ensure( - rectangle.west < lon && lon < rectangle.east, - `camera longitude outside ${rectangle.west}..${rectangle.east}`, - ); - ensure( - rectangle.east - rectangle.west < math.toRadians(30) && - rectangle.north - rectangle.south < math.toRadians(30), - 'the rectangle is not bounded by the horizon', - ); - }, - ], - [ - 'camera.viewRectangle: undefined when looking away from the Earth', - async () => { - camera.setView({ - destination: cartesian3.fromDegrees(20, 10, 1e6), - orientation: { heading: 0, pitch: math.PI_OVER_TWO, roll: 0 }, - }); - await settle(); - const rectangle = camera.viewRectangle(); - ensure( - rectangle === undefined, - `looking away from the Earth gave ${JSON.stringify(rectangle)}`, - ); - }, - ], - [ - 'camera.viewRectangle: spans all longitudes over a pole', - async () => { - camera.setView({ - destination: cartesian3.fromDegrees(0, 89, 1e6), - orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, - }); - await settle(); - const rectangle = camera.viewRectangle(); - ensure(rectangle, 'no view rectangle over the pole'); - near(rectangle.west, -Math.PI, 1e-9, 'west'); - near(rectangle.east, Math.PI, 1e-9, 'east'); - near(rectangle.north, math.PI_OVER_TWO, 1e-9, 'north'); - ensure( - rectangle.south > 0, - `south ${rectangle.south} crosses the equator`, - ); - }, - ], - [ - 'projector.viewProjection: agrees with worldToWindow', - async () => { - camera.setView({ - destination: cartesian3.fromDegrees(20, 10, 30000), - orientation: { heading: 0.6, pitch: -0.5, roll: 0.1 }, - }); - await settle(); - const { width, height } = engine.host.size(); - const result = new Array(16).fill(0); - const matrix = engine.projector.viewProjection(result); - ensure( - matrix === result, - 'viewProjection did not fill its result argument', - ); - ensure( - matrix.length === 16 && matrix.every(Number.isFinite), - 'not 16 finite numbers', - ); - let compared = 0; - for (const [x, y] of [ - [width / 2, height / 2], - [width / 5, height / 4], - [width * 0.9, height * 0.8], - ]) { - const point = camera.pickEllipsoid(cartesian2.create(x, y)); - if (!point) continue; - const window = engine.projector.worldToWindow(point); - ensure(window, 'a picked point did not project'); - const clip = [0, 1, 2, 3].map( - (row) => - matrix[row] * point.x + - matrix[4 + row] * point.y + - matrix[8 + row] * point.z + - matrix[12 + row], - ); - ensure(clip[3] > 0, 'a visible point has clip.w <= 0'); - near(((clip[0] / clip[3] + 1) / 2) * width, window.x, 0.5, 'x (px)'); - near(((1 - clip[1] / clip[3]) / 2) * height, window.y, 0.5, 'y (px)'); - compared++; - } - 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 () => { - await nadirView(); - ensure( - engine.projector.horizonVisible(cartesian3.fromDegrees(20, 10, 0)), - 'the nadir is hidden', - ); - ensure( - !engine.projector.horizonVisible( - cartesian3.fromDegrees(-160, -10, 0), - ), - 'the antipode is visible', - ); - ensure( - engine.projector.horizonVisible(cartesian3.fromDegrees(-70, 10, 2e7)), - 'a high point beside the limb is hidden', - ); - }, - ], - [ - 'isDestroyed: false while the engine lives', - async () => { - ensure(engine.isDestroyed() === false, 'a live engine is destroyed'); - }, - ], - [ - 'camera.pose: aspectRatio is the view width over its height', - async () => { - await nadirView(); - const { width, height } = engine.host.size(); - // Cesium's frustum uses the drawing buffer, rounded to device pixels. - near(camera.pose().aspectRatio, width / height, 0.01, 'aspectRatio'); - }, - ], - [ - 'picker.pickGlobe: the nadir hits the globe and the sky misses', - async () => { - await nadirView(); - const hit = engine.picker.pickGlobe(centre()); - ensure(isCartesian3(hit), 'the nadir missed the globe'); - const point = cartographic.fromCartesian(hit, WGS84); - // Terrain, when shown, stays within Earth's relief of the ellipsoid. - near(point.height, 0, 9000, 'globe height (m)'); - near(math.toDegrees(point.longitude), 20, 0.05, 'longitude (deg)'); - near(math.toDegrees(point.latitude), 10, 0.05, 'latitude (deg)'); - camera.setView({ - destination: cartesian3.fromDegrees(20, 10, 1e6), - orientation: { heading: 0, pitch: 0.6, roll: 0 }, - }); - await settle(); - ensure( - engine.picker.pickGlobe(centre()) === undefined, - 'the sky hit the globe', - ); - }, - ], - [ - 'picker.pickPosition and projector.worldToWindow round-trip within 1.5 px', - async () => { - if (!engine.picker.pickPositionSupported) return; - // Straight down and tilted; screen points with no surface are skipped. - // Over New York, low enough for map detail. - const views = [ - { height: 20000, heading: 0, pitch: -math.PI_OVER_TWO }, - { height: 5000, heading: 0.7, pitch: -1.0 }, - ]; - let hits = 0; - const misses = []; - for (const { height: viewHeight, heading, pitch } of views) { - camera.setView({ - destination: cartesian3.fromDegrees(-73.78, 40.64, viewHeight), - orientation: { heading, pitch, roll: 0 }, - }); - // Picks read the depth of the last drawn frame: draw this view. - await settle(3); - const { width, height } = engine.host.size(); - const screens = [ - [0.5, 0.5], - [0.3, 0.4], - [0.7, 0.6], - [0.5, 0.75], - ].map(([x, y]) => - cartesian2.create(Math.round(width * x), Math.round(height * y)), - ); - // The depth a pick reads needs drawn frames. - await until( - () => engine.picker.pickPosition(screens[0]) !== undefined, - 'a surface under the centre for pickPosition', - ); - for (const screen of screens) { - const position = engine.picker.pickPosition(screen); - if (position === undefined) continue; - const back = engine.projector.worldToWindow(position); - const error = back - ? Math.hypot(back.x - screen.x, back.y - screen.y) - : Infinity; - hits += 1; - if (!(error <= 1.5)) - misses.push( - `(${screen.x}, ${screen.y}) → ${back ? `(${back.x.toFixed(2)}, ${back.y.toFixed(2)})` : 'undefined'}`, - ); - } - } - ensure(hits > 0, 'pickPosition hit no surface'); - ensure( - misses.length === 0, - `round-trip off by more than 1.5 px: ${misses.join('; ')}`, - ); - }, - ], - [ - 'maps.setTerrain: keyless terrain resolves; an unknown kind rejects', - async () => { - await engine.maps.setTerrain({ id: 'keyless', kind: 'keyless' }); - let rejected = null; - try { - await engine.maps.setTerrain({ - id: 'no-such-terrain', - kind: 'no-such-terrain', - }); - } catch (error) { - rejected = error; - } - ensure(rejected !== null, 'an unknown terrain kind resolved'); - // The keyless terrain still applies after the rejection. - await engine.maps.setTerrain({ id: 'keyless', kind: 'keyless' }); - }, - ], - [ - 'camera.commanded: fires for each camera command, never for reads', - async () => { - await nadirView(); - let raised = 0; - const remove = camera.commanded.addEventListener(() => raised++); - const target = cartesian3.fromDegrees(20, 10, 0); - const commands = [ - [ - 'setView', - () => - camera.setView({ - destination: cartesian3.fromDegrees(20, 10, 2e6), - }), - ], - [ - 'flyTo', - () => - camera.flyTo({ - destination: cartesian3.fromDegrees(21, 10, 2e6), - duration: 0, - }), - ], - [ - 'flyToSphere', - () => - camera.flyToSphere( - { center: target, radius: 100 }, - { duration: 0 }, - ), - ], - [ - 'lookAt', - () => camera.lookAt(target, { heading: 0, pitch: -1, range: 5e3 }), - ], - ['cancelFlight', () => camera.cancelFlight()], - ]; - try { - camera.pose(); - camera.height(); - camera.viewRectangle(); - camera.pickEllipsoid(centre()); - ensure(raised === 0, `reads raised camera.commanded ${raised}x`); - for (const [name, command] of commands) { - const before = raised; - command(); - ensure(raised > before, `${name} did not raise camera.commanded`); - } - camera.release(); - } finally { - remove(); - } - const count = raised; - camera.setView({ destination: cartesian3.fromDegrees(20, 10, 1e6) }); - ensure(raised === count, 'a removed listener was called'); - }, - ], - [ - 'camera.follow: holds a moving target centred, keeps the minimum range, ends on stop or a command', - async () => { - // An aircraft at 3 km over JFK flying east at 250 m/s, one step per - // drawn frame (the step after postRender: every stage of the next - // frame sees the same position, and the check reads the drawn one). - const start = cartographic.fromDegrees(-73.78, 40.64, 3000); - const stepRad = 250 / 60 / (6378137 * Math.cos(start.latitude)); - let step = 0; - let drawn = 0; - const at = (index) => - cartesian3.fromRadians( - start.longitude + index * stepRad, - start.latitude, - start.height, - ); - const removeStep = engine.frames.postRender.addEventListener(() => { - drawn = step; - step++; - }); - const changes = []; - const removeChanged = camera.followChanged.addEventListener((handle) => - changes.push(handle), - ); - const offCentre = () => { - const screen = engine.projector.worldToWindow(at(drawn)); - if (!screen) return Infinity; - const middle = centre(); - return Math.hypot(screen.x - middle.x, screen.y - middle.y); - }; - const range = () => - cartesian3.distance(camera.pose().position, at(drawn)); - try { - camera.setView({ - destination: cartesian3.fromDegrees(-73.8, 40.6, 8000), - orientation: { heading: 0, pitch: -0.6, roll: 0 }, - }); - await settle(); - // A layer's own handle as `native`: echoed, whatever the engine. - const followed = { id: 'contract-followed' }; - const handle = camera.follow({ - native: followed, - position: () => at(step), - viewFrom: { x: 0, y: -1000, z: 400 }, - }); - ensure( - camera.following() === handle, - 'following() is not the handle', - ); - ensure(handle.native === followed, 'the handle lost its native'); - ensure( - changes.at(-1) === handle, - 'followChanged did not report the handle', - ); - await until(() => offCentre() <= 3, 'the follow centring its target'); - near(range(), Math.hypot(1000, 400), 5, 'follow range at viewFrom'); - // target(): where the followed target is now. - ensure( - cartesian3.distance(handle.target(), at(step)) < 1, - 'following().target() is not the target', - ); - // 2 s of motion (at least 120 frames), centred within 3 px. - const began = performance.now(); - const firstStep = step; - let worst = 0; - for ( - let frames = 0; - frames < 600 && (frames < 120 || performance.now() - began < 2000); - frames++ - ) { - await settle(1); - worst = Math.max(worst, offCentre()); - } - ensure( - worst <= 3, - `the target drifted ${worst.toFixed(2)} px off centre`, - ); - ensure(step - firstStep >= 120, 'the target did not move'); - if (pointer?.drag && pointer?.wheel) { - // A left drag orbits the target: same range (±5 %), centred. - const { width, height } = engine.host.size(); - const before = range(); - await pointer.drag( - width / 2, - height / 2, - width / 2 + 200, - height / 2 + 60, - ); - await settle(4); - ensure(camera.following() === handle, 'a drag ended the follow'); - ensure( - Math.abs(range() - before) <= 0.05 * before, - `a drag changed the range ${before.toFixed(0)} → ${range().toFixed(0)} m`, - ); - ensure(offCentre() <= 3, 'the target left the centre after a drag'); - // A 240 px drag right turns the heading clockwise as Cesium's - // tracked camera: 1.77 × 240 / width × 360° (±15 %). - // Cesium's gesture inertia carries a turn on for a while: the - // heading is read once it has been still for 5 frames. - const headingAtRest = async () => { - let last = camera.pose().heading; - let still = 0; - await until(() => { - const now = camera.pose().heading; - still = - Math.abs(math.negativePiToPi(now - last)) < 1e-4 - ? still + 1 - : 0; - last = now; - return still >= 5; - }, 'the heading at rest'); - return last; - }; - const headingBefore = await headingAtRest(); - await pointer.drag( - width / 2 - 120, - height / 2, - width / 2 + 120, - height / 2, - ); - const headingAfter = await headingAtRest(); - const turned = - (math.negativePiToPi(headingAfter - headingBefore) * 180) / - Math.PI; - const expected = (1.77 * 240 * 360) / width; - // Clockwise, at Cesium's rate (the harness holds the pointer - // still before releasing it: no inertia). - ensure( - Math.abs(turned - expected) <= 0.15 * expected, - `a 240 px drag right turned the heading ${turned.toFixed(1)}°, Cesium turns ${expected.toFixed(1)}°`, - ); - ensure(offCentre() <= 3, 'the target left the centre after a turn'); - // A right drag down closes in (Cesium zoom3D), above the floor. - if (pointer.dragRight) { - const far = range(); - await pointer.dragRight( - width / 2, - height / 2 - 100, - width / 2, - height / 2 + 100, - ); - await headingAtRest(); - ensure( - range() < 0.8 * far && range() >= 149, - `a right drag down moved the range ${far.toFixed(0)} → ${range().toFixed(0)} m`, - ); - ensure( - offCentre() <= 3, - 'the target left the centre after a dolly', - ); - } - // The wheel zooms toward the target down to the floor, never below. - let lowest = Infinity; - for (let notch = 0; notch < 40; notch++) { - await pointer.wheel(width / 2, height / 2, -100); - await settle(2); - lowest = Math.min(lowest, range()); - } - await settle(4); - lowest = Math.min(lowest, range()); - ensure(lowest >= 149, `the wheel reached ${lowest.toFixed(1)} m`); - ensure( - range() <= 150 * 1.05, - `40 wheel notches stopped at ${range().toFixed(1)} m, not the 150 m floor`, - ); - ensure( - offCentre() <= 3, - 'the target left the centre after the wheel', - ); - ensure(camera.following() === handle, 'the wheel ended the follow'); - } - // A zoom through the target stops at the minimum range. - camera.zoomIn(5000); - await settle(4); - ensure(camera.following() === handle, 'zoomIn ended the follow'); - ensure(range() >= 149, `zoomIn reached ${range().toFixed(1)} m`); - ensure(offCentre() <= 3, 'the target left the centre after zoomIn'); - // stop() frees the camera: the target moves on without it. - handle.stop(); - ensure(camera.following() === null, 'following() after stop'); - ensure(changes.at(-1) === null, 'followChanged did not report null'); - camera.release(); - // Gesture inertia (Cesium) may carry the camera a few frames: - // wait for it to rest, then the target moves on without it. - let last = camera.pose().position; - let stillFrames = 0; - await until(() => { - const now = camera.pose().position; - stillFrames = - cartesian3.distance(now, last) < 1e-3 ? stillFrames + 1 : 0; - last = now; - return stillFrames >= 5; - }, 'the camera at rest after stop'); - const free = camera.pose().position; - await settle(10); - ensure( - cartesian3.distance(camera.pose().position, free) < 1e-3, - `the camera still moves after stop (${cartesian3 - .distance(camera.pose().position, free) - .toFixed(2)} m in 10 frames; following ${Boolean( - camera.following(), - )})`, - ); - // A command that takes the camera ends the follow. - camera.follow({ - position: () => at(step), - viewFrom: { x: 0, y: -1000, z: 400 }, - }); - await settle(4); - camera.setView({ - destination: cartesian3.fromDegrees(20, 10, 1e6), - orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, - }); - ensure(camera.following() === null, 'setView did not end the follow'); - ensure(changes.at(-1) === null, 'followChanged missed the command'); - await settle(); - near(camera.height(), 1e6, 1, 'height after the command'); - } finally { - camera.following()?.stop(); - camera.release(); - removeChanged(); - removeStep(); - } - }, - ], - ]; - checks.push( - ...meshChecks(engine, { settle, until, nadirView, readRegion, makeCanvas }), - ...groundChecks(engine, { settle, until, nadirView, readRegion, pointer }), - ...imageryChecks(engine, { settle, until, nadirView, readRegion }), - ); - if (mapSources?.photoreal && mapSources.imagery?.length >= 2) - checks.push(...mapChecks(engine.maps, mapSources)); - if (capabilities.visualStyles) - checks.push(...styleChecks(engine.render, settle)); - if (capabilities.bloom) checks.push(...bloomChecks(engine.render, settle)); - if (capabilities.cloudCover) - checks.push(...cloudCoverChecks(engine.render, settle)); - if (capabilities.contactSonar) - checks.push(...contactSonarChecks(engine.render, settle)); - return checks.map(([name, run]) => ({ name, run })); -} - -// engine.ground: sets of fills on the ground (every engine). -function groundChecks( - engine, - { settle, until, nadirView, readRegion, pointer }, -) { - const { ground } = engine; - const cell = (west, south, east, north, extra = {}) => ({ - west, - south, - east, - north, - ...extra, - }); - const square = (lon, lat, half) => [ - [lon - half, lat - half], - [lon + half, lat - half], - [lon + half, lat + half], - [lon - half, lat + half], - ]; - - return [ - [ - 'projector: a lowered horizon ellipsoid keeps below-sea-level ground visible', - async () => { - const eye = cartesian3.fromDegrees(-74.006, 40.7128, -19.6); - engine.camera.setView({ destination: eye }); - await settle(); - const point = cartesian3.fromDegrees(-74.006, 40.71289, -22); - const options = { - ellipsoid: { - radii: { x: 6377137, y: 6377137, z: 6355752.314245179 }, - }, - }; - ensure( - engine.projector.horizonVisible(point, options), - 'nearby ground below WGS84 stays visible', - ); - ensure( - !engine.projector.horizonVisible( - cartesian3.fromDegrees(105.994, -40.7128), - options, - ), - 'far side stays hidden', - ); - throwsType( - () => - engine.projector.horizonVisible(point, { - ellipsoid: { radii: { x: 0, y: 1, z: 1 } }, - }), - 'zero horizon radius', - ); - await nadirView(1e6); - }, - ], - [ - 'ground: corridor reveals advance without replacing their path', - async () => { - const set = ground.createSet({ - id: 'contract-reveal', - kind: 'corridor', - reveal: true, - }); - try { - set.replace([ - { - positions: [ - [19.7, 10], - [20.3, 10], - ], - widthPx: 12, - color: [1, 0, 1, 1], - }, - ]); - set.commit(); - set.setReveal(0.25); - throwsType(() => set.setReveal(-1), 'negative reveal'); - throwsType(() => set.setReveal(2), 'large reveal'); - if (!readRegion) return; - await nadirView(350000); - await until(() => set.ready(), 'reveal ground ready'); - await settle(); - const magenta = (lon) => { - const { x, y } = screenOf(engine, lon, 10); - const p = readRegion(Math.round(x), Math.round(y), 1, 1); - return Math.min(p[0], p[2]) - p[1]; - }; - ensure(magenta(19.8) > 60, 'revealed start draws'); - ensure(magenta(20.2) < 60, 'unrevealed end is hidden'); - set.setReveal(0.9); - await settle(); - ensure(magenta(20.2) > 60, 'reveal advances over the existing path'); - } finally { - set.destroy(); - } - }, - ], - [ - 'ground: pulse fields update fronts, move, hide and release without a clock', - async () => { - const options = { - id: 'contract-ground-pulses', - center: cartesian3.fromDegrees(20, 10), - radiusM: 250000, - heightM: 2500, - color: [0, 1, 1, 0.25], - lineColor: [0, 1, 1, 1], - haloColor: [0, 0, 0, 0.8], - depth: 0.35, - lineWidth: 3, - haloWidth: 1, - }; - throwsType( - () => ground.createPulseField({ ...options, radiusM: -1 }), - 'negative radius', - ); - const field = ground.createPulseField(options); - const listed = () => ground.list().find(({ id }) => id === options.id); - try { - ensure(!listed().shown, 'new field is visible'); - field.draw({ radii: [0.2, 0.6], alphas: [0.4, 0.2], level: 1 }); - ensure( - listed().shown && !listed().animated, - 'caller-driven field starts no clock', - ); - throwsType( - () => field.draw({ radii: [2], alphas: [1], level: 1 }), - 'invalid front', - ); - ensure(listed().shown, 'invalid update changed visibility'); - field.move(cartesian3.fromDegrees(21, 10)); - await settle(); - field.draw({ radii: [], alphas: [], level: 0 }); - ensure(!listed().shown, 'resting field is visible'); - } finally { - field.destroy(); - } - field.destroy(); - field.draw({ radii: [0.5], alphas: [1], level: 1 }); - ensure(field.isDestroyed() && !listed(), 'destroyed field retained'); - }, - ], - [ - 'ground: pulse bands change pixels and rest clears them', - async () => { - if (!readRegion) return; - await nadirView(1e6); - // The base frame must be the settled surface: a check before this one - // can leave tiles of another view still loading. - await until( - () => engine.surface.tilesLoaded?.() !== false, - 'surface settles', - ); - await settle(5); - const at = engine.projector.worldToWindow( - cartesian3.fromDegrees(20.9, 10, 2500), - ); - const sample = () => - readRegion(Math.round(at.x) - 3, Math.round(at.y) - 3, 6, 6); - const base = sample(); - const difference = (a, b) => { - let sum = 0; - for (let i = 0; i < a.length; i++) - if (i % 4 !== 3) sum += Math.abs(a[i] - b[i]); - return sum / (a.length * 0.75); - }; - const field = ground.createPulseField({ - id: 'contract-pulse-pixels', - center: cartesian3.fromDegrees(20, 10), - radiusM: 200000, - heightM: 2500, - color: [1, 0, 1, 1], - lineColor: [1, 0, 1, 1], - haloColor: [0, 0, 0, 1], - depth: 0.5, - lineWidth: 3, - haloWidth: 1, - }); - try { - field.draw({ radii: [0.6], alphas: [1], level: 1 }); - await until( - () => difference(sample(), base) > 25, - 'pulse band draws', - ); - field.draw({ radii: [0.2], alphas: [1], level: 1 }); - await until( - () => difference(sample(), base) < 10, - 'front moves past the sampled band', - ); - field.draw({ radii: [0.6], alphas: [1], level: 1 }); - await until(() => difference(sample(), base) > 25, 'front returns'); - field.draw({ radii: [0.6], alphas: [1], level: 0 }); - await until( - () => difference(sample(), base) < 10, - 'rest clears the field', - ); - } finally { - field.destroy(); - } - }, - ], - [ - 'ground: sets are created, committed, listed, shown, hidden and destroyed', - async () => { - throwsType( - () => ground.createSet({ id: 'contract-bad', kind: 'blob' }), - 'an unknown kind', - ); - throwsType( - () => - ground.createSet({ id: 'contract-bad', kind: 'cell', drape: 'x' }), - 'an unknown drape', - ); - const set = ground.createSet({ - id: 'contract-ground', - kind: 'polygon', - order: 3, - }); - const others = []; - try { - let duplicate = null; - try { - ground.createSet({ id: 'contract-ground', kind: 'disc' }); - } catch (error) { - duplicate = error; - } - ensure(duplicate, 'a second live set with the same id'); - ensure(set.id === 'contract-ground', `id ${set.id}`); - ensure( - listed()?.kind === 'polygon' && - listed().count === 0 && - listed().shown === true && - listed().order === 3, - `list() before a commit: ${JSON.stringify(listed())}`, - ); - set.replace([ - { - rings: [square(20, 10, 0.5), square(20, 10, 0.2).reverse()], - fill: [1, 0, 0, 0.5], - outline: { color: [1, 1, 1, 1], widthPx: 2 }, - pickId: 'p1', - }, - { - rings: [[...square(21, 10, 0.3), [20.7, 9.7]]], - fill: [0, 1, 0, 1], - }, - ]); - ensure(set.count() === 0, 'replace() drew before commit()'); - set.commit(); - ensure(set.count() === 2, `count ${set.count()} after commit`); - ensure(listed()?.count === 2, 'list() missed the commit'); - throwsType( - () => - set.replace([ - { - rings: [ - [ - [0, 0], - [1, 1], - ], - ], - }, - ]), - 'a ring of two positions', - ); - throwsType( - () => - set.replace([{ rings: [square(0, 0, 1)], fill: [2, 0, 0, 1] }]), - 'a fill outside 0..1', - ); - set.commit(); - ensure(set.count() === 2, 'a refused replace() changed the set'); - await until(() => set.ready(), 'the polygons drawn'); - set.show(false); - ensure( - set.shown() === false && listed()?.shown === false, - 'show(false) not reported', - ); - set.show(true); - ensure(set.shown() === true, 'show(true) not reported'); - // Every kind takes its own items. - const disc = ground.createSet({ id: 'contract-discs', kind: 'disc' }); - others.push(disc); - disc.replace([ - { - center: [20, 10], - radiusM: 20_000, - fill: [0, 0, 1, 0.4], - outline: { color: [0, 0, 1, 1], widthPx: 3 }, - }, - ]); - disc.commit(); - const cells = ground.createSet({ - id: 'contract-cells', - kind: 'cell', - }); - others.push(cells); - cells.replace([ - cell(179.5, 0, -179.5, 1, { fill: [1, 1, 0, 0.5] }), - cell(20, 10, 20.5, 10.5, { fill: [1, 1, 0, 0.5] }), - ]); - cells.commit(); - const corridors = ground.createSet({ - id: 'contract-corridors', - kind: 'corridor', - }); - others.push(corridors); - corridors.replace([ - { - positions: [ - [19, 9], - [21, 9], - ], - color: [1, 0, 1, 1], - widthPx: 4, - }, - { - positions: [ - [19, 11], - [20, 11.2], - [21, 11], - ], - color: [0, 1, 1, 1], - widthM: 5000, - }, - ]); - corridors.commit(); - throwsType( - () => - corridors.replace([ - { - positions: [ - [0, 0], - [1, 0], - ], - color: [1, 1, 1, 1], - }, - ]), - 'a corridor without a width', - ); - await until( - () => others.every((other) => other.ready()), - 'discs, cells and corridors drawn', - ); - ensure( - ['contract-discs', 'contract-cells', 'contract-corridors'].every( - (id) => ground.list().some((entry) => entry.id === id), - ), - 'list() misses a set', - ); - set.replace([]); - set.commit(); - ensure(set.count() === 0, 'an empty commit kept items'); - } finally { - set.destroy(); - for (const other of others) other.destroy(); - } - ensure(!listed(), 'a destroyed set is still listed'); - set.destroy(); - let refused = null; - try { - set.commit(); - } catch (error) { - refused = error; - } - ensure(refused, 'a destroyed set took a commit'); - function listed() { - return ground.list().find(({ id }) => id === 'contract-ground'); - } - }, - ], - [ - 'ground: picks resolve to the pickId of the item on top', - async () => { - await nadirView(1e6); - const cells = ground.createSet({ - id: 'contract-ground-cells', - kind: 'cell', - pickable: true, - }); - const polygons = ground.createSet({ - id: 'contract-ground-polygons', - kind: 'polygon', - pickable: true, - order: 1, - }); - const quiet = ground.createSet({ - id: 'contract-ground-quiet', - kind: 'disc', - }); - const clicks = []; - const remove = engine.input.on('click', (event) => clicks.push(event), { - pick: true, - }); - try { - cells.replace([ - cell(19, 9.5, 20, 10.5, { fill: [1, 0, 0, 0.6], pickId: 'cell-a' }), - cell(19.2, 9.7, 19.4, 9.9, { fill: [1, 0, 0, 0.6] }), - ]); - cells.commit(); - polygons.replace([ - { - rings: [square(20.6, 10, 0.4)], - fill: [0, 0, 1, 0.6], - pickId: 'poly-b', - }, - ]); - polygons.commit(); - quiet.replace([ - { - center: [20.6, 10], - radiusM: 15_000, - fill: [0, 1, 0, 0.6], - pickId: 'q', - }, - ]); - quiet.commit(); - await until( - () => cells.ready() && polygons.ready() && quiet.ready(), - 'the pickable fills drawn', - ); - await settle(3); - const pickAt = (lon, lat) => - engine.picker.pick(screenOf(engine, lon, lat)); - const onCell = pickAt(19.5, 10.2); - ensure( - onCell?.id === 'cell-a' && onCell.owner === 'contract-ground-cells', - `a pick on the cell found ${JSON.stringify(onCell?.id)} of ${onCell?.owner}`, - ); - ensure(onCell.instance === 0, `instance ${onCell.instance}`); - const noId = pickAt(19.3, 9.8); - ensure( - noId?.owner !== 'contract-ground-cells' || noId.id === 'cell-a', - `an item without a pickId was picked (${JSON.stringify(noId?.id)})`, - ); - // Higher order on top, an unpickable set under it never answers. - const onPolygon = pickAt(20.6, 10.25); - ensure( - onPolygon?.id === 'poly-b', - `a pick on the polygon found ${JSON.stringify(onPolygon?.id)}`, - ); - const onQuiet = pickAt(20.6, 10); - ensure( - onQuiet?.id === 'poly-b' && - onQuiet.owner !== 'contract-ground-quiet', - `the unpickable disc answered (${JSON.stringify(onQuiet?.id)} of ${onQuiet?.owner})`, - ); - const outside = pickAt(18, 12); - ensure( - outside === undefined || outside.id === undefined, - `a pick off every fill found ${JSON.stringify(outside?.id)}`, - ); - polygons.show(false); - await settle(3); - const hidden = pickAt(20.6, 10.25); - ensure(hidden?.id !== 'poly-b', 'a hidden set answered a pick'); - polygons.show(true); - await settle(3); - if (!pointer) return; - const before = clicks.length; - const at = screenOf(engine, 19.5, 10.2); - await pointer.click(at.x, at.y); - await until(() => clicks.length > before, 'a click payload'); - const clicked = clicks.at(-1); - ensure( - clicked.picked?.id === 'cell-a', - `a click on the cell picked ${JSON.stringify(clicked.picked?.id)}`, - ); - } finally { - remove(); - cells.destroy(); - polygons.destroy(); - quiet.destroy(); - } - }, - ], - [ - 'ground: fills, outlines and corridors draw on the globe (pixels)', - async () => { - if (!readRegion) return; - await nadirView(1e6); - const MAGENTA = [1, 0, 1, 1]; - const GREEN = [0, 1, 0, 1]; - - // Pixels of a colour in a box centred on a ground point. - const countAt = (lon, lat, colour, half = 12) => { - const { x, y } = screenOf(engine, lon, lat); - const data = readRegion( - Math.round(x) - half, - Math.round(y) - half, - 2 * half, - 2 * half, - ); - let count = 0; - for (let i = 0; i < data.length; i += 4) - if (like(colour, data, i)) count++; - return count / (4 * half * half); - }; - const cells = ground.createSet({ - id: 'contract-paint-cells', - kind: 'cell', - }); - const discs = ground.createSet({ - id: 'contract-paint-discs', - kind: 'disc', - order: 1, - }); - const corridors = ground.createSet({ - id: 'contract-paint-corridors', - kind: 'corridor', - order: 2, - }); - const extra = []; - try { - ensure( - countAt(19.5, 10, MAGENTA) < 0.05, - 'the globe is already magenta under the cell', - ); - cells.replace([cell(19, 9.5, 20, 10.5, { fill: MAGENTA })]); - cells.commit(); - discs.replace([ - { - center: [21, 10], - radiusM: 40_000, - fill: GREEN, - outline: { color: MAGENTA, widthPx: 4 }, - }, - ]); - discs.commit(); - corridors.replace([ - { - positions: [ - [19, 8.5], - [21, 8.5], - ], - color: GREEN, - widthPx: 6, - }, - ]); - corridors.commit(); - await until( - () => cells.ready() && discs.ready() && corridors.ready(), - 'the fills built', - ); - await until( - () => countAt(19.5, 10, MAGENTA) > 0.9, - 'the magenta cell drawn', - ); - ensure( - countAt(21, 10, GREEN, 8) > 0.9, - `the disc's centre is ${countAt(21, 10, GREEN, 8).toFixed(2)} green`, - ); - // The outline: magenta on the disc's rim, 40 km east of its centre. - const rimLon = 21 + 40_000 / (111_320 * Math.cos(math.toRadians(10))); - ensure( - countAt(rimLon, 10, MAGENTA, 3) > 0.3, - `the disc's outline is ${countAt(rimLon, 10, MAGENTA, 3).toFixed(2)} magenta at the rim`, - ); - // A 6 px corridor: a column of pixels across it. - const mid = screenOf(engine, 20, 8.5); - const column = readRegion( - Math.round(mid.x), - Math.round(mid.y) - 10, - 1, - 20, - ); - let lit = 0; - for (let i = 0; i < column.length; i += 4) - if (like(GREEN, column, i)) lit++; - ensure(lit >= 4 && lit <= 8, `the 6 px corridor is ${lit} px wide`); - cells.show(false); - await until( - () => countAt(19.5, 10, MAGENTA) < 0.05, - 'the hidden cell gone', - ); - cells.show(true); - await until( - () => countAt(19.5, 10, MAGENTA) > 0.9, - 'the cell shown again', - ); - cells.replace([]); - cells.commit(); - await until( - () => countAt(19.5, 10, MAGENTA) < 0.05, - 'the emptied cell gone', - ); - // A polygon of many vertices with a hole (engines may bin it). - const ring = (radius, count) => - Array.from({ length: count }, (_, index) => { - const angle = (index / count) * 2 * Math.PI; - return [ - 18 + radius * Math.cos(angle), - 11 + radius * Math.sin(angle), - ]; - }); - const many = ground.createSet({ - id: 'contract-paint-polygon', - kind: 'polygon', - }); - extra.push(many); - many.replace([ - { rings: [ring(0.7, 240), ring(0.25, 120)], fill: MAGENTA }, - ]); - many.commit(); - await until(() => many.ready(), 'the large polygon built'); - await until( - () => countAt(18.45, 11, MAGENTA, 6) > 0.9, - 'the large polygon drawn', - ); - ensure( - countAt(18, 11, MAGENTA, 6) < 0.05, - `the hole is ${countAt(18, 11, MAGENTA, 6).toFixed(2)} magenta`, - ); - ensure( - countAt(18, 12.2, MAGENTA, 6) < 0.05, - 'the large polygon spills outside its ring', - ); - // Round both rings, just inside and just outside each. - for (let step = 0; step < 12; step++) { - const angle = (step / 12) * 2 * Math.PI + 0.1; - const at = (radius) => [ - 18 + radius * Math.cos(angle), - 11 + radius * Math.sin(angle), - ]; - for (const [radius, filled] of [ - [0.62, true], - [0.78, false], - [0.33, true], - [0.17, false], - ]) { - const share = countAt(...at(radius), MAGENTA, 2); - ensure( - filled ? share > 0.9 : share < 0.1, - `at ${radius}° from the centre, ${(step * 30).toFixed(0)}° round: ${share.toFixed(2)} magenta`, - ); - } - } - } finally { - cells.destroy(); - discs.destroy(); - corridors.destroy(); - for (const set of extra) set.destroy(); - } - }, - ], - [ - 'ground: corridors glow, pulse and dash, and keep frames coming while they move', - async () => { - const line = (lat, extra) => ({ - positions: [ - [19, lat], - [21, lat], - ], - color: [1, 0, 1, 1], - widthPx: 12, - ...extra, - }); - const bad = ground.createSet({ - id: 'contract-looks-bad', - kind: 'corridor', - }); - try { - for (const [item, label] of [ - [ - { - ...line(9, { glow: { power: 0.25 } }), - widthPx: undefined, - widthM: 900, - }, - 'a glow on a metre corridor', - ], - [ - line(9, { pulse: { periodMs: 1000, min: 0.8, max: 0.2 } }), - 'a pulse min over max', - ], - [ - line(9, { pulse: { periodMs: 0, min: 0, max: 1 } }), - 'a pulse of no period', - ], - [ - line(9, { dash: { cells: 8, duty: 1 } }), - 'a dash lit all its cell', - ], - [line(9, { glow: { power: 0 } }), 'a glow of no power'], - ]) - throwsType(() => bad.replace([item]), label); - } finally { - bad.destroy(); - } - const glowing = ground.createSet({ - id: 'contract-glow', - kind: 'corridor', - order: 5, - }); - const pulsing = ground.createSet({ - id: 'contract-pulse', - kind: 'corridor', - order: 5, - }); - const dashed = ground.createSet({ - id: 'contract-dash', - kind: 'corridor', - order: 5, - }); - const listed = (id) => ground.list().find((entry) => entry.id === id); - // Frames drawn in a window, with nothing asked of the engine. - const framesIn = async (ms) => { - let frames = 0; - const remove = engine.frames.postRender.addEventListener( - () => frames++, - ); - await new Promise((resolve) => setTimeout(resolve, ms)); - remove(); - return frames; - }; - try { - if (readRegion) await nadirView(1e6); - const idle = readRegion ? await framesIn(1000) : 0; - glowing.replace([ - line(9, { - color: [0, 1, 0, 1], - widthPx: 24, - glow: { power: 0.25 }, - }), - ]); - glowing.commit(); - ensure( - listed('contract-glow')?.animated === false, - 'a glow alone does not animate', - ); - pulsing.replace([ - line(10, { pulse: { periodMs: 800, min: 0.1, max: 1 } }), - ]); - pulsing.commit(); - ensure( - listed('contract-pulse')?.animated === true, - 'a pulse animates', - ); - dashed.replace([ - line(11, { dash: { cells: 10, duty: 0.5, speed: 2 } }), - ]); - dashed.commit(); - ensure(listed('contract-dash')?.animated === true, 'a dash animates'); - await until( - () => glowing.ready() && pulsing.ready() && dashed.ready(), - 'the animated corridors built', - ); - if (!readRegion) return; - // (A page that already draws every frame shows nothing more.) - const moving = await framesIn(1000); - ensure( - moving >= Math.min(idle + 20, 50), - `${moving} frames in 1 s while corridors pulse and flow, ${idle} before`, - ); - // Pixels along a line of latitude between 19.2° and 20.8°E. - const along = (lat, samples = 120) => { - const out = []; - for (let k = 0; k < samples; k++) { - const at = screenOf( - engine, - 19.2 + (1.6 * k) / (samples - 1), - lat, - ); - out.push([ - ...readRegion(Math.round(at.x), Math.round(at.y), 1, 1), - ]); - } - return out; - }; - const magenta = ([r, g, b]) => r > 200 && b > 200 && g < 60; - // The glow: white at its centre line, green toward its edges. - { - const centre = screenOf(engine, 20, 9); - const column = readRegion( - Math.round(centre.x), - Math.round(centre.y) - 12, - 1, - 25, - ); - const mid = column.slice(12 * 4, 12 * 4 + 4); - ensure( - mid[0] > 200 && mid[1] > 200 && mid[2] > 200, - `the glow's centre is ${[...mid.slice(0, 3)]}, not white`, - ); - let greenish = 0; - for (let i = 0; i < column.length; i += 4) - if (column[i + 1] > 120 && column[i] < column[i + 1] - 60) - greenish++; - ensure( - greenish >= 2, - `the glow fades to green over ${greenish} px`, - ); - } - // The pulse: magenta at its peak, faint at its trough. - { - let peak = 0; - let trough = 0; - const started = performance.now(); - while (performance.now() - started < 1600) { - const centre = screenOf(engine, 20, 10); - const pixel = readRegion( - Math.round(centre.x), - Math.round(centre.y), - 1, - 1, - ); - if (magenta(pixel)) peak++; - else trough++; - await settle(1); - } - ensure( - peak > 0 && trough > 0, - `the pulse was magenta ${peak} times, not ${trough}`, - ); - } - // The dash: lit and dark cells along the line, moving. - { - const first = along(11).map(magenta); - let changes = 0; - for (let k = 1; k < first.length; k++) - if (first[k] !== first[k - 1]) changes++; - const lit = first.filter(Boolean).length / first.length; - ensure( - changes >= 8 && lit > 0.25 && lit < 0.75, - `the dash is ${lit.toFixed(2)} lit with ${changes} changes along it`, - ); - await new Promise((resolve) => setTimeout(resolve, 150)); - await settle(2); - const second = along(11).map(magenta); - const moved = second.filter( - (value, k) => value !== first[k], - ).length; - ensure( - moved >= 12, - `the dash moved at ${moved} of 120 pixels in 150 ms`, - ); - } - pulsing.show(false); - dashed.show(false); - await settle(2); - const still = await framesIn(1000); - ensure( - still <= idle + 3, - `${still} frames in 1 s once hidden, ${idle} before`, - ); - } finally { - glowing.destroy(); - pulsing.destroy(); - dashed.destroy(); - } - }, - ], - [ - 'ground: radial discs retain their look and draw a brighter rim than fill', - async () => { - const background = ground.createSet({ - id: 'contract-radial-background', - kind: 'cell', - }); - const disc = ground.createSet({ - id: 'contract-radial', - kind: 'disc', - order: 1, - }); - try { - background.replace([ - { west: 19, south: 9, east: 21, north: 11, fill: [0, 0, 0, 1] }, - ]); - background.commit(); - disc.replace([ - { - center: [20, 10], - // A launch pad's size: Cesium maps a large ground disc's - // texture coordinates differently, and only pads use this look. - radiusM: RADIAL_RADIUS_M, - fill: [0, 1, 1, 1], - radial: { - fillAlpha: 0.105, - rimAlpha: 0.72, - rimStart: 0.952, - rimEnd: 0.985, - emission: 0.35, - }, - }, - ]); - disc.commit(); - ensure( - ground.list().find((set) => set.id === 'contract-radial').count === - 1, - 'radial disc committed', - ); - if (!readRegion) return; - await nadirView(RADIAL_VIEW_M); - await until( - () => disc.ready() && background.ready(), - 'radial ground ready', - ); - await settle(); - const metresPerDegree = 111319.49 * Math.cos((10 * Math.PI) / 180); - const at = (radius, angle) => { - const { x, y } = screenOf( - engine, - 20 + (radius * Math.cos(angle)) / metresPerDegree, - 10 + (radius * Math.sin(angle)) / 110574, - ); - const bytes = readRegion(Math.round(x), Math.round(y), 1, 1); - return bytes[1] + bytes[2]; - }; - // Cesium leaves part of a small ground disc's fill undrawn (its - // old launch pad shows the same gap): the brightest inner sample. - const fillAt = () => - Math.max( - ...[0, 1, 2, 3, 4, 5].map((k) => - at(RADIAL_RADIUS_M * 0.5, (k * Math.PI) / 3), - ), - ); - // Cesium classifies onto the terrain loaded at this height, which - // can take longer than the usual frame budget after the descent. - // The app's own arrival flight can land after the view was set: - // the view is set again until the disc draws. - // Pixels come from the last drawn frame, screen positions from the - // camera now: both must be at the view, with no flight under way. - const away = () => - engine.camera.flightActive() || - engine.camera.height() > RADIAL_VIEW_M * 1.5; - for ( - let frame = 0; - frame < 1200 && (away() || fillAt() <= 5); - frame++ - ) { - if (away()) { - engine.camera.cancelFlight(); - await nadirView(RADIAL_VIEW_M); - } - await settle(1); - } - const fill = fillAt(); - // The rim is a few pixels wide: its brightest sample across it. - let rim = 0; - for (let step = 0; step <= 25; step++) - rim = Math.max(rim, at(RADIAL_RADIUS_M * (0.85 + step * 0.01), 0)); - ensure(fill > 5, `radial fill is visible: ${fill}`); - ensure( - rim > fill + 50, - `radial rim ${rim} is brighter than fill ${fill}`, - ); - disc.show(false); - await settle(); - ensure(fillAt() < fill, 'hidden radial fill clears'); - } finally { - disc.destroy(); - background.destroy(); - } - }, - ], - ]; -} - -// engine.imagery: raster layers on the ground (every engine). -function imageryChecks(engine, { settle, until, nadirView, readRegion }) { - const { imagery } = engine; - - // A flat colour as RGBA bytes (no canvas needed), size × size texels. - const flat = (rgba, size = 8) => { - const data = new Uint8Array(size * size * 4); - for (let i = 0; i < data.length; i += 4) data.set(rgba, i); - return { width: size, height: size, data }; - }; - const box = { west: 19, south: 9.5, east: 20, north: 10.5 }; - const listed = (id) => imagery.list().find((entry) => entry.id === id); - return [ - [ - 'imagery: layers are created, listed, restyled, ordered and destroyed', - async () => { - const source = { kind: 'image', image: flat([255, 0, 255, 255]) }; - throwsType( - () => - imagery.createLayer({ id: 'contract-bad', source: { kind: 'x' } }), - 'an unknown source kind', - ); - throwsType( - () => - imagery.createLayer({ - id: 'contract-bad', - source: { - kind: 'tiles', - url: 'https://example.test/{z}/{x}.png', - }, - }), - 'a template without {y}', - ); - throwsType( - () => - imagery.createLayer({ - id: 'contract-bad', - source: { - kind: 'image', - image: { width: 2, height: 2, data: new Uint8Array(3) }, - }, - }), - 'RGBA bytes of the wrong length', - ); - throwsType( - () => imagery.createLayer({ id: 'contract-bad', source, alpha: 2 }), - 'an alpha above 1', - ); - throwsType( - () => - imagery.createLayer({ id: 'contract-bad', source, split: 'up' }), - 'an unknown split', - ); - throwsType( - () => - imagery.createLayer({ - id: 'contract-bad', - source: { - ...source, - rectangle: { west: 0, south: 5, east: 1, north: 4 }, - }, - }), - 'a rectangle upside down', - ); - ensure(!listed('contract-bad'), 'a refused layer is listed'); - ensure( - ['globe', 'tiles', 'none'].includes(imagery.surface()), - `surface() ${imagery.surface()}`, - ); - const top = imagery.createLayer({ - id: 'contract-imagery-top', - source, - order: 2, - }); - const bottom = imagery.createLayer({ - id: 'contract-imagery-bottom', - source: { - kind: 'image', - image: flat([0, 255, 0, 255]), - rectangle: box, - }, - alpha: 0.5, - show: false, - split: 'left', - }); - const split = imagery.splitPosition(); - try { - let duplicate = null; - try { - imagery.createLayer({ id: 'contract-imagery-top', source }); - } catch (error) { - duplicate = error; - } - ensure(duplicate, 'a second live layer with the same id'); - ensure(top.id === 'contract-imagery-top', `id ${top.id}`); - const ids = imagery.list().map(({ id }) => id); - ensure( - ids.indexOf('contract-imagery-bottom') < - ids.indexOf('contract-imagery-top'), - `list() is not in draw order: ${ids.join(', ')}`, - ); - ensure( - listed('contract-imagery-bottom')?.kind === 'image' && - listed('contract-imagery-bottom').shown === false && - listed('contract-imagery-bottom').alpha === 0.5 && - listed('contract-imagery-bottom').split === 'left' && - listed('contract-imagery-top').order === 2, - `list(): ${JSON.stringify(imagery.list())}`, - ); - ensure( - bottom.shown() === false && - bottom.alpha() === 0.5 && - bottom.split() === 'left', - 'the options are not the layer state', - ); - bottom.show(true); - bottom.setAlpha(0.25); - bottom.setSplit('right'); - ensure( - bottom.shown() === true && - bottom.alpha() === 0.25 && - bottom.split() === 'right' && - listed('contract-imagery-bottom').alpha === 0.25, - 'a restyle is not reported', - ); - throwsType(() => bottom.setAlpha(-1), 'a negative alpha'); - throwsType(() => bottom.setSplit('down'), 'an unknown side'); - throwsType( - () => bottom.setSource({ kind: 'image' }), - 'an empty source', - ); - ensure( - bottom.source().kind === 'image', - 'a refused source replaced it', - ); - bottom.setSource({ - kind: 'tiles', - url: 'https://tiles.invalid/{z}/{x}/{y}.png', - maxLevel: 3, - rectangle: box, - }); - ensure( - bottom.source().kind === 'tiles' && - listed('contract-imagery-bottom').kind === 'tiles', - 'setSource() did not replace the source', - ); - imagery.setSplitPosition(0.25); - ensure(imagery.splitPosition() === 0.25, 'the split position'); - throwsType( - () => imagery.setSplitPosition(1.5), - 'a split outside 0..1', - ); - await until(() => top.ready(), 'the image layer ready'); - await settle(); - } finally { - imagery.setSplitPosition(split); - top.destroy(); - bottom.destroy(); - } - ensure(!listed('contract-imagery-top'), 'a destroyed layer is listed'); - ensure(!top.ready(), 'a destroyed layer is ready'); - top.destroy(); - let refused = null; - try { - top.setAlpha(1); - } catch (error) { - refused = error; - } - ensure(refused, 'a destroyed layer took a restyle'); - }, - ], - [ - 'imagery: images draw on the ground by alpha, show, order and split (pixels)', - async () => { - if (!readRegion) return; - await nadirView(1e6); - // The mean colour in a box centred on a ground point. - - // The map's own tiles loaded: what lies under the layers holds still. - await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded'); - await settle(4); - const base = meanAt(engine, readRegion, 19.5, 10, 6); - ensure( - !nearColour(base, MAGENTA), - 'the globe is already magenta there', - ); - const magenta = imagery.createLayer({ - id: 'contract-paint-magenta', - source: { - kind: 'image', - image: flat([...MAGENTA, 255]), - rectangle: box, - }, - }); - const layers = [magenta]; - try { - await until(() => magenta.ready(), 'the magenta layer ready'); - await until( - () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA), - 'the magenta layer drawn', - ); - ensure( - nearColour(meanAt(engine, readRegion, 21, 10, 6), base, 60) || - !nearColour(meanAt(engine, readRegion, 21, 10, 6), MAGENTA), - 'the layer spills outside its rectangle', - ); - // Alpha: at 0 the globe under it, at 0.5 half way to magenta. - magenta.setAlpha(0); - await until( - () => !nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA), - 'alpha 0 drawn', - ); - await settle(2); - const below = meanAt(engine, readRegion, 19.5, 10, 6); - magenta.setAlpha(0.5); - const half = below.map((value, k) => (value + MAGENTA[k]) / 2); - await until( - () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), half, 30), - `alpha 0.5 drawn (${show(half)} wanted)`, - ); - magenta.setAlpha(1); - magenta.show(false); - await until( - () => - nearColour(meanAt(engine, readRegion, 19.5, 10, 6), below, 30), - 'the hidden layer gone', - ); - magenta.show(true); - await until( - () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA), - 'the layer shown again', - ); - // Order: a higher order draws on top, a lower one under it. - const green = imagery.createLayer({ - id: 'contract-paint-green', - source: { - kind: 'image', - image: flat([...GREEN, 255]), - rectangle: box, - }, - order: 1, - }); - layers.push(green); - const lower = imagery.createLayer({ - id: 'contract-paint-under', - source: { - kind: 'image', - image: flat([0, 255, 255, 255]), - rectangle: box, - }, - order: -1, - }); - layers.push(lower); - await until( - () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), GREEN), - 'the higher order on top', - ); - green.destroy(); - await until( - () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA), - 'the destroyed layer gone', - ); - // A new source in place: the same layer, another colour. - magenta.setSource({ - kind: 'image', - image: flat([...GREEN, 255]), - rectangle: box, - }); - await until( - () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), GREEN), - 'the new source drawn', - ); - magenta.setSource({ - kind: 'image', - image: flat([...MAGENTA, 255]), - rectangle: box, - }); - lower.destroy(); - // Split: a left layer draws only left of the split position. - const width = engine.host.size().width; - const middle = screenOf(engine, 19.5, 10).x; - imagery.setSplitPosition(middle / width); - magenta.setSplit('left'); - const left = screenOf(engine, 19.25, 10); - const right = screenOf(engine, 19.75, 10); - const at = ({ x, y }) => { - const data = readRegion(Math.round(x) - 3, Math.round(y) - 3, 6, 6); - const mean = [0, 0, 0]; - for (let i = 0; i < data.length; i += 4) - for (let k = 0; k < 3; k++) mean[k] += data[i + k] / 36; - return mean; - }; - await until( - () => - nearColour(at(left), MAGENTA) && !nearColour(at(right), MAGENTA), - `the left split drawn (left ${show(at(left))}, right ${show(at(right))})`, - ); - magenta.setSplit('right'); - await until( - () => - !nearColour(at(left), MAGENTA) && nearColour(at(right), MAGENTA), - 'the right split drawn', - ); - magenta.setSplit('none'); - await until( - () => - nearColour(at(left), MAGENTA) && nearColour(at(right), MAGENTA), - 'the unsplit layer drawn', - ); - } finally { - imagery.setSplitPosition(0.5); - for (const layer of layers) layer.destroy(); - } - await until( - () => - [MAGENTA, GREEN, [0, 255, 255]].every( - (colour) => - !nearColour(meanAt(engine, readRegion, 19.5, 10, 6), colour), - ), - 'every layer gone', - ); - }, - ], - [ - 'imagery: tile schemes, transforms, staged sources and raised layers are checked and kept', - async () => { - const magenta = { kind: 'image', image: flat([255, 0, 255, 255]) }; - const green = { kind: 'image', image: flat([0, 255, 0, 255]) }; - const template = { - kind: 'tiles', - url: 'https://tiles.invalid/{z}/{x}/{y}.png', - maxLevel: 3, - rectangle: box, - }; - throwsType( - () => - imagery.createLayer({ - id: 'contract-bad', - source: { ...template, scheme: 'polar' }, - }), - 'an unknown tile scheme', - ); - throwsType( - () => - imagery.createLayer({ - id: 'contract-bad', - source: { ...template, transform: 'mask' }, - }), - 'a transform that is not a function', - ); - throwsType( - () => - imagery.createLayer({ - id: 'contract-bad', - source: magenta, - raise: -1, - }), - 'a raise below the ellipsoid', - ); - throwsType( - () => - imagery.createLayer({ - id: 'contract-bad', - source: template, - raise: 6000, - }), - 'a raised tiles layer', - ); - ensure(!listed('contract-bad'), 'a refused layer is listed'); - const geographic = imagery.createLayer({ - id: 'contract-imagery-geographic', - source: { ...template, scheme: 'geographic', transform: (i) => i }, - }); - const staged = imagery.createLayer({ - id: 'contract-imagery-staged', - source: { ...magenta, rectangle: box }, - }); - const raisedLayer = imagery.createLayer({ - id: 'contract-imagery-raised', - source: { ...magenta, rectangle: box }, - raise: 6000, - }); - // A promise's outcome, read frame by frame. - const outcome = (promise) => { - const result = { value: undefined }; - promise.then((value) => (result.value = value)); - return result; - }; - try { - ensure( - geographic.source().scheme === 'geographic' && - typeof geographic.source().transform === 'function', - 'the tile scheme or transform is not kept', - ); - ensure( - listed('contract-imagery-raised') && - raisedLayer.raise() === 6000 && - staged.raise() === null, - `raise(): ${raisedLayer.raise()}, ${staged.raise()}`, - ); - throwsType( - () => staged.setDetail({ image: flat([0, 0, 0, 255]) }), - 'a detail on a draped layer', - ); - throwsType( - () => raisedLayer.setSource(template), - 'tiles on a raised layer', - ); - throwsType( - () => - raisedLayer.setDetail({ - image: flat([0, 0, 0, 255]), - rectangle: { west: 170, south: 0, east: -170, north: 1 }, - }), - 'a detail across the antimeridian', - ); - // Staged: the drawn source stays until the new one is drawn. - await until(() => staged.ready(), 'the staged layer ready'); - const first = outcome( - staged.setSource({ ...green, rectangle: box }, { staged: true }), - ); - ensure( - staged.source().image === magenta.image || - first.value === true || - staged.source().image === green.image, - 'a staged source replaced nothing', - ); - await until(() => first.value !== undefined, 'the staged source'); - ensure(first.value === true, 'the staged source failed'); - ensure( - staged.source().image === green.image && !staged.staging(), - 'the staged source is not the layer source', - ); - // A newer source wins; an aborted one leaves the drawn source. - const superseded = outcome( - staged.setSource({ ...magenta, rectangle: box }, { staged: true }), - ); - const winner = outcome( - staged.setSource( - { kind: 'image', image: flat([0, 0, 255, 255]), rectangle: box }, - { staged: true }, - ), - ); - await until( - () => superseded.value !== undefined && winner.value !== undefined, - 'the superseded and the newer staged sources', - ); - ensure( - superseded.value === false && winner.value === true, - `superseded ${superseded.value}, newer ${winner.value}`, - ); - const drawnImage = staged.source().image; - const controller = new AbortController(); - const aborted = outcome( - staged.setSource( - { ...magenta, rectangle: box }, - { staged: true, signal: controller.signal }, - ), - ); - controller.abort(); - await until(() => aborted.value !== undefined, 'the aborted source'); - ensure( - aborted.value === false && staged.source().image === drawnImage, - 'an aborted staged source replaced the drawn one', - ); - // A new credit alone keeps the source's pixels. - geographic.setSource({ - ...template, - scheme: 'geographic', - transform: geographic.source().transform, - credit: 'Contract credit', - }); - ensure( - geographic.source().credit === 'Contract credit', - 'the credit did not change', - ); - // A hidden layer takes a staged source at once. - staged.show(false); - const hidden = outcome( - staged.setSource({ ...green, rectangle: box }, { staged: true }), - ); - await until(() => hidden.value !== undefined, 'the hidden staged'); - ensure(hidden.value === true, 'a hidden layer refused a stage'); - // A raised layer's detail. - const west = { ...box, east: 19.5 }; - await until(() => raisedLayer.ready(), 'the raised layer drawn'); - const detail = outcome( - raisedLayer.setDetail({ - image: flat([0, 255, 0, 255]), - rectangle: west, - }), - ); - await until(() => detail.value !== undefined, 'the detail drawn'); - ensure( - detail.value === true && - raisedLayer.detail()?.rectangle.east === 19.5, - 'the detail is not kept', - ); - await raisedLayer.setDetail(null); - ensure(raisedLayer.detail() === null, 'the detail is not cleared'); - } finally { - geographic.destroy(); - staged.destroy(); - raisedLayer.destroy(); - } - ensure(!listed('contract-imagery-raised'), 'a raised layer is listed'); - }, - ], - [ - 'imagery: geographic tiles through a transform, staged and raised layers draw (pixels)', - async () => { - if (!readRegion) return; - await nadirView(1e6); - - await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded'); - await settle(4); - // Every tile, whatever the app serves, painted magenta by the - // transform: geographic tiles over the box only. - const painted = (image) => { - const size = image.width; - const data = new Uint8Array(size * size * 4); - for (let i = 0; i < data.length; i += 4) - data.set([255, 0, 255, 255], i); - if (typeof document === 'undefined') return image; - const canvas = document.createElement('canvas'); - canvas.width = canvas.height = size; - const context = canvas.getContext('2d'); - const pixels = context.createImageData(size, size); - pixels.data.set(data); - context.putImageData(pixels, 0, 0); - return canvas; - }; - const layers = []; - try { - const tiles = imagery.createLayer({ - id: 'contract-paint-geographic', - source: { - kind: 'tiles', - url: '/icons/icon-32.png?z={z}&x={x}&y={y}', - tileSize: 32, - maxLevel: 4, - scheme: 'geographic', - transform: painted, - rectangle: box, - }, - }); - layers.push(tiles); - await until( - () => nearColour(meanAt(engine, readRegion, 19.5, 10, 5), MAGENTA), - `the transformed tiles drawn (${show(meanAt(engine, readRegion, 19.5, 10, 5))})`, - ); - ensure( - !nearColour(meanAt(engine, readRegion, 21, 10, 5), MAGENTA), - 'the tiles spill outside their rectangle', - ); - // Staged: green replaces the magenta tiles once it is drawn. - const staged = tiles.setSource( - { kind: 'image', image: flat([...GREEN, 255]), rectangle: box }, - { staged: true }, - ); - let swapped; - staged.then((value) => (swapped = value)); - await until(() => swapped !== undefined, 'the staged image'); - ensure(swapped === true, 'the staged image failed'); - await until( - () => nearColour(meanAt(engine, readRegion, 19.5, 10, 5), GREEN), - `the staged image drawn (${show(meanAt(engine, readRegion, 19.5, 10, 5))})`, - ); - tiles.destroy(); - // Raised 6 km: drawn over the box (a little parallax at 1000 km), - // a detail in place of its west half. - const raisedLayer = imagery.createLayer({ - id: 'contract-paint-raised', - source: { - kind: 'image', - image: flat([...MAGENTA, 255]), - rectangle: box, - }, - raise: 6000, - }); - layers.push(raisedLayer); - await until( - () => nearColour(meanAt(engine, readRegion, 19.6, 10, 5), MAGENTA), - `the raised layer drawn (${show(meanAt(engine, readRegion, 19.6, 10, 5))})`, - ); - raisedLayer.setDetail({ - image: flat([...GREEN, 255]), - rectangle: { ...box, east: 19.5 }, - }); - await until( - () => - nearColour(meanAt(engine, readRegion, 19.25, 10, 5), GREEN) && - nearColour(meanAt(engine, readRegion, 19.75, 10, 5), MAGENTA), - `the detail drawn (west ${show(meanAt(engine, readRegion, 19.25, 10, 5))}, east ${show(meanAt(engine, readRegion, 19.75, 10, 5))})`, - ); - raisedLayer.setDetail(null); - await until( - () => nearColour(meanAt(engine, readRegion, 19.25, 10, 5), MAGENTA), - 'the detail cleared', - ); - } finally { - for (const layer of layers) layer.destroy(); - } - await until( - () => - !nearColour(meanAt(engine, readRegion, 19.5, 10, 5), MAGENTA) && - !nearColour(meanAt(engine, readRegion, 19.5, 10, 5), GREEN), - 'every layer gone', - ); - }, - ], - ]; -} - -// The optional contactSonar capability (render.setContactSonar). -function contactSonarChecks(render, settle) { - return [ - [ - 'render.setContactSonar (contactSonar): merges, copies, rejects bad values, draws', - async () => { - const previous = render.contactSonar(); - ensure( - previous.enabled === false, - `the sonar starts ${JSON.stringify(previous)}`, - ); - try { - render.setContactSonar({ - enabled: true, - active: true, - angleDeg: 90, - range: 100, - opacity: 40, - sector: 30, - }); - await settle(2); - render.setContactSonar({ angleDeg: 180 }); - const state = render.contactSonar(); - ensure( - state.enabled && state.active && state.angleDeg === 180, - `after an angle alone: ${JSON.stringify(state)}`, - ); - state.angleDeg = 0; - ensure( - render.contactSonar().angleDeg === 180, - 'contactSonar() is a copy', - ); - for (const bad of [ - { enabled: 'yes' }, - { active: 1 }, - { angleDeg: Number.NaN }, - { range: 0 }, - { opacity: 101 }, - { sector: 0 }, - { sector: 400 }, - ]) { - let error = null; - try { - render.setContactSonar(bad); - } catch (caught) { - error = caught; - } - ensure( - error instanceof TypeError, - `setContactSonar(${JSON.stringify(bad)}) did not throw a TypeError`, - ); - } - ensure( - render.contactSonar().opacity === 40, - 'a rejected call changed the sonar', - ); - } finally { - render.setContactSonar(previous); - await settle(1); - } - }, - ], - ]; -} - -// The optional cloudCover capability (render.setCloudCover). -function cloudCoverChecks(render, settle) { - return [ - [ - 'render.setCloudCover (cloudCover): sets, copies, gives back, rejects bad values', - async () => { - const previous = render.cloudCover(); - try { - render.setCloudCover({ coverage: 0.7 }); - ensure(render.cloudCover().coverage === 0.7, 'coverage not set'); - render.cloudCover().coverage = 0; - ensure( - render.cloudCover().coverage === 0.7, - 'cloudCover() is a copy', - ); - await settle(2); - render.setCloudCover({}); - ensure(render.cloudCover().coverage === 0.7, 'omitted keeps it'); - render.setCloudCover({ coverage: null }); - ensure(render.cloudCover().coverage === null, 'null: the engine own'); - for (const bad of [-0.1, 1.2, Number.NaN, '0.5']) { - let error = null; - try { - render.setCloudCover({ coverage: bad }); - } catch (caught) { - error = caught; - } - ensure( - error instanceof TypeError, - `setCloudCover(${bad}) did not throw a TypeError`, - ); - } - ensure( - render.cloudCover().coverage === null, - 'after rejected values', - ); - } finally { - render.setCloudCover(previous); - await settle(1); - } - }, - ], - ]; -} - -// The optional bloom capability (render.setBloom). -function bloomChecks(render, settle) { - return [ - [ - 'render.setBloom (bloom): merges, copies, rejects bad values, draws', - async () => { - const previous = render.bloom(); - ensure( - typeof previous.enabled === 'boolean' && - Number.isFinite(previous.strength), - `bloom() ${JSON.stringify(previous)} is not { enabled, strength }`, - ); - const same = (expected, label) => { - const actual = render.bloom(); - ensure( - actual.enabled === expected.enabled && - actual.strength === expected.strength, - `${label}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`, - ); - }; - try { - render.setBloom({ enabled: true, strength: 0.8 }); - same({ enabled: true, strength: 0.8 }, 'after enabling'); - render.bloom().strength = 0; - same({ enabled: true, strength: 0.8 }, 'bloom() is a copy'); - // A frame with the bloom on draws without errors. - await settle(2); - render.setBloom({ strength: 0.05 }); - same({ enabled: true, strength: 0.05 }, 'in the dead zone'); - render.setBloom({ enabled: false }); - same({ enabled: false, strength: 0.05 }, 'after disabling'); - for (const bad of [ - { enabled: 1 }, - { strength: -0.1 }, - { strength: 1.5 }, - { strength: Number.NaN }, - ]) { - let error = null; - try { - render.setBloom(bad); - } catch (caught) { - error = caught; - } - ensure( - error instanceof TypeError, - `setBloom(${JSON.stringify(bad)}) did not throw a TypeError`, - ); - } - same({ enabled: false, strength: 0.05 }, 'after rejected values'); - } finally { - render.setBloom(previous); - await settle(1); - } - }, - ], - ]; -} - -// The optional visualStyles capability (render.setStyle). -function styleChecks(render, settle) { - return [ - [ - 'render.setStyle (visualStyles): merges, copies, rejects bad values, draws', - async () => { - const names = render.visualStyles; - ensure( - names.length > 0 && new Set(names).size === names.length, - `visualStyles ${JSON.stringify(names)} is not a list of names`, - ); - const [name] = names; - const previous = render.style(name); - ensure( - previous.intensity === 0, - `${name} starts at intensity ${previous.intensity}`, - ); - const [uniform] = Object.keys(previous.uniforms); - try { - render.setStyle(name, { intensity: 1 }); - ensure(render.style(name).intensity === 1, 'intensity not set'); - // A frame with the style on draws without errors. - await settle(2); - if (uniform) { - render.setStyle(name, { uniforms: { [uniform]: 0.25 } }); - const state = render.style(name); - ensure( - state.intensity === 1 && state.uniforms[uniform] === 0.25, - `after a uniform alone: ${JSON.stringify(state)}`, - ); - state.uniforms[uniform] = 9; - ensure( - render.style(name).uniforms[uniform] === 0.25, - 'style() is a copy', - ); - } - render.setStyle(name, { intensity: 0.5 }); - ensure(render.style(name).intensity === 0.5, 'crossfade step'); - for (const [label, call] of [ - ['unknown style', () => render.setStyle('no-such-style', {})], - ['intensity 2', () => render.setStyle(name, { intensity: 2 })], - [ - 'intensity -0.1', - () => render.setStyle(name, { intensity: -0.1 }), - ], - [ - 'unknown uniform', - () => render.setStyle(name, { uniforms: { noSuchUniform: 1 } }), - ], - ...(uniform - ? [ - [ - 'NaN uniform', - () => - render.setStyle(name, { - uniforms: { [uniform]: Number.NaN }, - }), - ], - ] - : []), - ]) { - let error = null; - try { - call(); - } catch (caught) { - error = caught; - } - ensure( - error instanceof TypeError, - `setStyle with ${label} did not throw a TypeError`, - ); - } - ensure( - render.style(name).intensity === 0.5, - 'a rejected call changed the style', - ); - } finally { - render.setStyle(name, previous); - await settle(1); - } - }, - ], - ]; -} - -// The maps switch order, observed through maps.current(). -function mapChecks(maps, { imagery: [first, second], photoreal }) { - const shows = (baseImagery, tiles, label) => { - const current = maps.current(); - ensure( - current.baseImagery === baseImagery, - `${label}: base imagery is not the expected source`, - ); - ensure( - current.photoreal === tiles, - `${label}: photoreal tiles are not the expected source`, - ); - ensure( - maps.photorealActive() === (tiles !== null), - `${label}: photorealActive is ${maps.photorealActive()}`, - ); - }; - return [ - [ - 'maps: setPhotorealTiles(null) brings back the last base imagery', - async () => { - await maps.setBaseImagery(first); - shows(first, null, 'after setBaseImagery'); - await maps.setPhotorealTiles(photoreal); - shows(null, photoreal, 'tiles replace the base imagery'); - await maps.setPhotorealTiles(null); - shows(first, null, 'null'); - }, - ], - [ - 'maps: the last imagery or photoreal call decides what shows', - async () => { - await maps.setBaseImagery(first); - await Promise.all([ - maps.setBaseImagery(second), - maps.setPhotorealTiles(photoreal), - ]); - shows(null, photoreal, 'tiles last'); - await maps.setPhotorealTiles(null); - const restored = maps.current().baseImagery; - // The second source was superseded before or after it showed. - ensure( - restored === first || restored === second, - 'null: the last base imagery shown is not back', - ); - await Promise.all([ - maps.setPhotorealTiles(photoreal), - maps.setBaseImagery(second), - ]); - ensure( - maps.current().baseImagery === second, - 'imagery last: the second source is not on the globe', - ); - await maps.setPhotorealTiles(null); - shows(second, null, 'null after imagery'); - }, - ], - [ - 'maps.releasePhotorealTiles: a released source no longer shows or counts', - async () => { - await maps.setPhotorealTiles(photoreal); - await maps.setPhotorealTiles(null); - maps.releasePhotorealTiles(photoreal); - maps.releasePhotorealTiles(photoreal); - ensure(!maps.photorealActive(), 'photorealActive after release'); - ensure(maps.current().photoreal === null, 'current after release'); - }, - ], - ]; -} - -function meshChecks( - engine, - { settle, until, nadirView, readRegion, makeCanvas }, -) { - // Above any terrain under (20°E, 10°N), which stands about 400 m high. - const positions = [ - [19.98, 10.02, 3000], - [20.02, 10.02, 3000], - [19.98, 9.98, 3000], - [20.02, 9.98, 3000], - ].flatMap((p) => { - const at = cartesian3.fromDegrees(...p); - return [at.x, at.y, at.z]; - }); - const options = { - positions, - indices: [0, 2, 1, 2, 3, 1], - uv: [0, 1, 1, 1, 0, 0, 1, 0], - }; - const centre = () => { - const { width, height } = engine.host.size(); - return { x: width / 2, y: height / 2 }; - }; - const checks = [ - [ - 'meshes: copied triangles, visibility, updates and lifetime', - async () => { - const id = 'contract-mesh'; - const source = [...positions]; - const mesh = engine.meshes.createMesh({ - ...options, - positions: source, - id, - show: false, - }); - try { - source.fill(0); - ensure(engine.meshes.list().includes(id), 'mesh is not listed'); - ensure(mesh.show() === false, 'mesh starts hidden'); - mesh.show(true); - ensure(mesh.show(), 'mesh did not show'); - mesh.update({ color: [0, 1, 1, 0.35], depthTest: 'always' }); - mesh.update({ - positions, - indices: options.indices, - depthTest: 'normal', - }); - await settle(); - let rejected = false; - try { - mesh.update({ indices: [0, 1, 99] }); - } catch { - rejected = true; - } - ensure(rejected, 'invalid indices were accepted'); - ensure(mesh.show(), 'invalid update changed visibility'); - } finally { - mesh.destroy(); - mesh.destroy(); - } - ensure(!engine.meshes.list().includes(id), 'destroyed mesh is listed'); - ensure(!mesh.show(), 'destroyed mesh is shown'); - }, - ], - [ - 'meshes: picks use the live id and ignore hidden or unpickable triangles', - async () => { - await nadirView(10000); - const id = 'contract-mesh-pick'; - const mesh = engine.meshes.createMesh({ - ...options, - id, - pickId: 'first', - }); - try { - await until( - () => engine.picker.pick(centre())?.id === 'first', - 'mesh pick', - ); - ensure(engine.picker.pick(centre()).owner === id, 'mesh owner'); - mesh.update({ pickId: 'second' }); - await until( - () => engine.picker.pick(centre())?.id === 'second', - 'updated mesh pick', - ); - mesh.show(false); - await settle(); - ensure( - engine.picker.pick(centre())?.owner !== id, - 'hidden mesh picked', - ); - mesh.update({ show: true, pickId: null }); - await settle(); - ensure( - engine.picker.pick(centre())?.owner !== id, - 'unpickable mesh picked', - ); - } finally { - mesh.destroy(); - } - }, - ], - ]; - if (readRegion && makeCanvas) - checks.push([ - 'meshes: image pixels update on the same canvas', - async () => { - await nadirView(10000); - const canvas = makeCanvas(8, 8); - const ctx = canvas.getContext('2d'); - ctx.fillStyle = '#ff0000'; - ctx.fillRect(0, 0, 8, 8); - const mesh = engine.meshes.createMesh({ - ...options, - id: 'contract-mesh-image', - image: canvas, - }); - const pixel = () => { - const { x, y } = centre(); - return readRegion(Math.floor(x), Math.floor(y), 1, 1); - }; - try { - await until(() => { - const p = pixel(); - return p[0] > p[1] + 50; - }, 'red mesh image'); - ctx.fillStyle = '#00ff00'; - ctx.fillRect(0, 0, 8, 8); - mesh.update({ image: canvas }); - await until(() => { - const p = pixel(); - return p[1] > p[0] + 50; - }, 'updated green mesh image'); - } finally { - mesh.destroy(); - } - }, - ]); - return checks; -} - -/** Runs every check in order; never throws for a failing check. */ -export async function runContractSuite(engine, options) { - const results = []; - for (const { name, run } of createContractSuite(engine, options)) { - try { - await run(); - results.push({ name, ok: true }); - } catch (error) { - results.push({ - name, - ok: false, - error: error instanceof Error ? error : new Error(String(error)), - }); - } - } - return results; -} +export * from './contractSuite/index.mjs'; diff --git a/src/globe/testing/contractSuite/camera.mjs b/src/globe/testing/contractSuite/camera.mjs new file mode 100644 index 000000000..a5cd60de4 --- /dev/null +++ b/src/globe/testing/contractSuite/camera.mjs @@ -0,0 +1,1591 @@ +import { + cartesian2, + cartesian3, + cartographic, + ellipsoid, + math, + matrix4, + WGS84, + ensure, + near, + angleBetween, + isCartesian3, + localAxes, + inRange, + unit, + along, +} from './helpers.mjs'; +import { enuOffset } from '../../follow.js'; + +export function poseChecks(engine, { settle, until, nadirView }) { + const { camera } = engine; + return [ + [ + 'camera.setView: heading, pitch and roll round-trip through pose', + async () => { + const destination = cartesian3.fromDegrees(-70, 45, 25000); + const orientation = { heading: 1.2, pitch: -0.6, roll: 0.05 }; + camera.setView({ destination, orientation }); + await settle(); + const pose = camera.pose(); + near( + cartesian3.distance(pose.position, destination), + 0, + 0.01, + 'position error (m)', + ); + near( + angleBetween(pose.heading, orientation.heading), + 0, + 1e-6, + 'heading error', + ); + near(pose.pitch, orientation.pitch, 1e-6, 'pitch'); + near(angleBetween(pose.roll, orientation.roll), 0, 1e-6, 'roll error'); + }, + ], + [ + 'camera.setView: direction and up orientation', + async () => { + const destination = cartesian3.fromDegrees(5, 50, 3e5); + const direction = unit( + cartesian3.negate(destination, cartesian3.create()), + ); + const north = cartesian3.cross( + cartesian3.cross(direction, cartesian3.UNIT_Z, cartesian3.create()), + direction, + cartesian3.create(), + ); + const up = unit(north); + camera.setView({ destination, orientation: { direction, up } }); + await settle(); + const pose = camera.pose(); + near( + cartesian3.dot(pose.direction, direction), + 1, + 1e-9, + 'direction·expected', + ); + near(cartesian3.dot(pose.up, up), 1, 1e-9, 'up·expected'); + }, + ], + [ + 'camera.livePosition: the pose position and height, without a pose', + async () => { + camera.setView({ + destination: cartesian3.fromDegrees(-3.7, 40.4, 12000), + orientation: { heading: 0.4, pitch: -0.5, roll: 0 }, + }); + await settle(); + const live = camera.livePosition; + ensure(isCartesian3(live), 'livePosition is a Cartesian3'); + near( + cartesian3.distance(live, camera.pose().position), + 0, + 1e-6, + 'livePosition vs pose().position', + ); + near( + camera.height(), + camera.pose().cartographic.height, + 0.01, + 'height()', + ); + const destination = cartesian3.fromDegrees(2.35, 48.85, 30000); + camera.setView({ destination }); + await settle(); + ensure( + camera.livePosition === live, + 'livePosition is the same engine-owned object on every read', + ); + near( + cartesian3.distance(camera.livePosition, destination), + 0, + 1e-3, + 'livePosition follows the camera', + ); + }, + ], + [ + 'camera.pose: cartographic, frame vectors and fovy are consistent', + async () => { + camera.setView({ + destination: cartesian3.fromDegrees(139.7, 35.6, 8000), + orientation: { heading: 4, pitch: -0.3, roll: 0 }, + }); + await settle(); + const pose = camera.pose(); + const expected = cartographic.fromCartesian(pose.position, WGS84); + near( + pose.cartographic.longitude, + expected.longitude, + 1e-9, + 'longitude', + ); + near(pose.cartographic.latitude, expected.latitude, 1e-9, 'latitude'); + near(pose.cartographic.height, expected.height, 0.01, 'height'); + for (const [name, vector] of [ + ['direction', pose.direction], + ['up', pose.up], + ['right', pose.right], + ]) + near(cartesian3.magnitude(vector), 1, 1e-6, `|${name}|`); + near(cartesian3.dot(pose.direction, pose.up), 0, 1e-6, 'direction·up'); + const right = cartesian3.cross( + pose.direction, + pose.up, + cartesian3.create(), + ); + near( + cartesian3.dot(right, pose.right), + 1, + 1e-6, + 'right = direction × up', + ); + ensure( + pose.fovy > 0 && pose.fovy < Math.PI, + `fovy ${pose.fovy} out of range`, + ); + }, + ], + [ + 'camera.percentageChanged: readable and writable', + async () => { + const previous = camera.percentageChanged; + try { + camera.percentageChanged = 0.125; + near(camera.percentageChanged, 0.125, 0, 'percentageChanged'); + } finally { + camera.percentageChanged = previous; + } + }, + ], + [ + 'camera.changed: changed, moveStart and moveEnd follow a move', + async () => { + // moveStart marks a move from rest: let the nadir move end first + // (Cesium raises moveEnd only after cameraEventWaitTime, 500 ms). + let rested = 0; + const removeRest = camera.moveEnd.addEventListener(() => rested++); + try { + // 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(); + } + const previous = camera.percentageChanged; + const seen = { changed: 0, moveStart: 0, moveEnd: 0 }; + const removers = Object.keys(seen).map((name) => + camera[name].addEventListener(() => seen[name]++), + ); + try { + camera.percentageChanged = 0.01; + camera.setView({ + destination: cartesian3.fromDegrees(60, -30, 2e6), + orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, + }); + await until(() => seen.moveEnd > 0, 'moveEnd'); + ensure(seen.moveStart > 0, 'moveStart did not fire'); + ensure(seen.changed > 0, 'changed did not fire'); + } finally { + for (const remove of removers) remove(); + camera.percentageChanged = previous; + } + }, + ], + ]; +} + +export function flightChecks(engine, { settle, until, nadirView, centre }) { + const { camera } = engine; + return [ + [ + 'camera.flyTo: duration 0 lands and completes before flyTo returns', + async () => { + await nadirView(); + const destination = cartesian3.fromDegrees(-3.7, 40.4, 50000); + const outcome = []; + camera.flyTo({ + destination, + orientation: { heading: 0.5, pitch: -0.8, roll: 0 }, + duration: 0, + complete: () => outcome.push('complete'), + cancel: () => outcome.push('cancel'), + }); + ensure( + outcome.join() === 'complete', + `flyTo returned with [${outcome}] instead of [complete]`, + ); + ensure(!camera.flightActive(), 'a duration-0 flight is still active'); + const pose = camera.pose(); + near( + cartesian3.distance(pose.position, destination), + 0, + 0.01, + 'position error (m)', + ); + near(angleBetween(pose.heading, 0.5), 0, 1e-6, 'heading error'); + near(pose.pitch, -0.8, 1e-6, 'pitch'); + }, + ], + [ + 'camera.flyTo: cancelFlight calls cancel and never complete', + async () => { + await nadirView(); + const outcome = []; + camera.flyTo({ + destination: cartesian3.fromDegrees(100, 0, 5e6), + duration: 5, + easing: 'linear', + complete: () => outcome.push('complete'), + cancel: () => outcome.push('cancel'), + }); + await settle(2); + camera.cancelFlight(); + await until(() => outcome.length > 0, 'flight cancellation'); + await settle(3); + ensure(outcome.join() === 'cancel', `flight ended with ${outcome}`); + }, + ], + [ + 'camera.completeFlight: lands a running flight at once and calls complete', + async () => { + await nadirView(); + camera.completeFlight(); + const outcome = []; + const destination = cartesian3.fromDegrees(100, 0, 5e6); + camera.flyTo({ + destination, + orientation: { heading: 0.5, pitch: -0.8, roll: 0 }, + duration: 5, + complete: () => outcome.push('complete'), + cancel: () => outcome.push('cancel'), + }); + await settle(2); + camera.completeFlight(); + ensure( + outcome.join() === 'complete', + `completeFlight returned with [${outcome}] instead of [complete]`, + ); + ensure(!camera.flightActive(), 'the flight is still active'); + const pose = camera.pose(); + near( + cartesian3.distance(pose.position, destination), + 0, + 0.01, + 'position error (m)', + ); + near(angleBetween(pose.heading, 0.5), 0, 1e-6, 'heading error'); + near(pose.pitch, -0.8, 1e-6, 'pitch'); + await settle(3); + ensure(outcome.join() === 'complete', `flight ended with ${outcome}`); + }, + ], + [ + 'camera.setView: cancels an active flight and retains its destination', + async () => { + await nadirView(); + const outcome = []; + camera.flyTo({ + destination: cartesian3.fromDegrees(100, 0, 5e6), + duration: 5, + complete: () => outcome.push('complete'), + cancel: () => outcome.push('cancel'), + }); + await settle(2); + const destination = cartesian3.fromDegrees(20, 10, 200000); + camera.setView({ destination }); + await settle(3); + ensure(outcome.join() === 'cancel', `flight ended with ${outcome}`); + ensure(!camera.flightActive(), 'setView left the flight active'); + near( + cartesian3.distance(camera.pose().position, destination), + 0, + 0.01, + 'setView position error (m)', + ); + }, + ], + [ + 'camera.flyTo: a newer flight cancels the previous one', + async () => { + await nadirView(); + const outcome = []; + camera.flyTo({ + destination: cartesian3.fromDegrees(100, 0, 5e6), + duration: 5, + complete: () => outcome.push('first complete'), + cancel: () => outcome.push('first cancel'), + }); + await settle(1); + camera.flyTo({ + destination: cartesian3.fromDegrees(20, 10, 2e6), + duration: 0, + complete: () => outcome.push('second complete'), + }); + await until(() => outcome.includes('second complete'), 'second flight'); + ensure( + outcome.join() === 'first cancel,second complete', + `flights ended with ${outcome}`, + ); + }, + ], + [ + 'camera.flyToSphere: ends at the range, looking at the centre', + async () => { + await nadirView(); + const center = cartesian3.fromDegrees(2.35, 48.85, 100); + let done = false; + camera.flyToSphere( + { center, radius: 500 }, + { + heading: 0.3, + pitch: -0.5, + range: 5000, + duration: 0, + complete: () => (done = true), + }, + ); + await until(() => done, 'sphere flight completion'); + const pose = camera.pose(); + near(cartesian3.distance(pose.position, center), 5000, 5, 'range (m)'); + const toCenter = unit( + cartesian3.subtract(center, pose.position, cartesian3.create()), + ); + near( + cartesian3.dot(toCenter, pose.direction), + 1, + 1e-6, + 'direction·toCentre', + ); + }, + ], + [ + 'camera.lookAt: holds the range toward the target until release', + async () => { + await nadirView(); + const target = cartesian3.fromDegrees(-0.12, 51.5, 0); + camera.lookAt(target, { heading: 0.4, pitch: -0.7, range: 2000 }); + await settle(); + const pose = camera.pose(); + near(cartesian3.distance(pose.position, target), 2000, 1, 'range (m)'); + const toTarget = unit( + cartesian3.subtract(target, pose.position, cartesian3.create()), + ); + near( + cartesian3.dot(toTarget, pose.direction), + 1, + 1e-6, + 'direction·toTarget', + ); + camera.release(); + await settle(); + near( + cartesian3.distance(camera.pose().position, pose.position), + 0, + 0.01, + 'release moved the camera (m)', + ); + }, + ], + [ + 'projector.worldToWindow: a point ahead projects to the canvas centre', + async () => { + await nadirView(); + const pose = camera.pose(); + const window = engine.projector.worldToWindow( + along(pose.position, pose.direction, 1000), + ); + ensure(window, 'a point ahead did not project'); + const expected = centre(); + near(window.x, expected.x, 1, 'x (px)'); + near(window.y, expected.y, 1, 'y (px)'); + }, + ], + [ + 'projector.worldToWindow: a point behind the camera is undefined', + async () => { + await nadirView(); + const pose = camera.pose(); + const behind = along(pose.position, pose.direction, -1000); + ensure( + engine.projector.worldToWindow(behind) === undefined, + 'a point behind the camera projected', + ); + }, + ], + [ + 'projector.worldToWindow: up on screen is camera up, and result is filled', + async () => { + await nadirView(); + const pose = camera.pose(); + const ahead = along(pose.position, pose.direction, 1000); + const result = cartesian2.create(); + const upper = engine.projector.worldToWindow( + along(ahead, pose.up, 100), + result, + ); + ensure( + upper === result, + 'worldToWindow did not return its result argument', + ); + const lefter = engine.projector.worldToWindow( + along(ahead, pose.right, -100), + ); + const expected = centre(); + ensure( + upper.y < expected.y, + `camera up projected below the centre (${upper.y})`, + ); + ensure( + lefter.x < expected.x, + `camera left projected right of the centre (${lefter.x})`, + ); + }, + ], + [ + 'camera.pickRay: the centre ray starts at the camera along its direction', + async () => { + await nadirView(); + const pose = camera.pose(); + const pickRay = camera.pickRay(centre()); + ensure(isCartesian3(pickRay?.origin), 'pickRay has no origin'); + near( + cartesian3.distance(pickRay.origin, pose.position), + 0, + 1, + 'origin error (m)', + ); + near( + cartesian3.dot(unit(pickRay.direction), pose.direction), + 1, + 1e-6, + 'direction·pose', + ); + }, + ], + [ + 'camera.pickEllipsoid: the nadir hits the surface and the sky misses', + async () => { + await nadirView(); + const hit = camera.pickEllipsoid(centre()); + ensure(isCartesian3(hit), 'the nadir missed the ellipsoid'); + const point = cartographic.fromCartesian(hit, WGS84); + near(point.height, 0, 1, 'surface height (m)'); + // Off the surface, Cesium's local up is the scaled-surface normal, + // which misses the geodetic nadir by about 0.0015° at 1000 km. + near(math.toDegrees(point.longitude), 20, 0.01, 'longitude (deg)'); + near(math.toDegrees(point.latitude), 10, 0.01, 'latitude (deg)'); + camera.setView({ + destination: cartesian3.fromDegrees(20, 10, 1e6), + orientation: { heading: 0, pitch: 0.6, roll: 0 }, + }); + await settle(); + ensure( + camera.pickEllipsoid(centre()) === undefined, + 'the sky hit the ellipsoid', + ); + }, + ], + [ + 'projector.worldToWindow: round-trips camera.pickEllipsoid', + async () => { + await nadirView(); + const { width, height } = engine.host.size(); + const screen = cartesian2.create(width / 4, height / 3); + const hit = camera.pickEllipsoid(screen); + ensure(isCartesian3(hit), 'the screen point missed the ellipsoid'); + const back = engine.projector.worldToWindow(hit); + ensure(back, 'the picked point did not project'); + near(back.x, screen.x, 1, 'x (px)'); + near(back.y, screen.y, 1, 'y (px)'); + }, + ], + [ + 'camera.viewRectangle: contains the nadir point', + async () => { + await nadirView(); + const rectangle = camera.viewRectangle(); + ensure(rectangle, 'no view rectangle looking straight down'); + const lon = math.toRadians(20); + const lat = math.toRadians(10); + ensure( + rectangle.west < lon && lon < rectangle.east, + `longitude outside ${rectangle.west}..${rectangle.east}`, + ); + ensure( + rectangle.south < lat && lat < rectangle.north, + `latitude outside ${rectangle.south}..${rectangle.north}`, + ); + }, + ], + ]; +} + +export function swingChecks(engine, { settle, until }) { + const { camera } = engine; + return [ + [ + 'camera.follow: swings to an ENU offset and cancels on handoff', + async () => { + const target = cartesian3.fromDegrees(10, 30, 5000); + const goal = { x: 0, y: 4800, z: 3300 }; + const handle = camera.follow({ + position: () => target, + viewFrom: { x: 0, y: -4800, z: 3300 }, + swingTo: goal, + }); + try { + await until( + () => + cartesian3.distance( + enuOffset(target, camera.pose().position), + goal, + ) < 5, + 'the shared follow offset', + ); + ensure(camera.following() === handle, 'swing replaced the follow'); + handle.retarget({ swingTo: { x: 0, y: -4800, z: 3300 } }); + handle.retarget({ swingTo: null }); + await settle(3); + near( + cartesian3.distance( + enuOffset(target, camera.pose().position), + goal, + ), + 0, + 5, + 'cancelled swing moved the camera', + ); + camera.setView({ + destination: cartesian3.fromDegrees(10, 30, 50000), + }); + await settle(3); + ensure( + camera.following() === null, + 'camera handoff retained the swing', + ); + } finally { + handle.stop(); + } + }, + ], + ]; +} + +export function cameraChecks(engine, { settle, until, nadirView, centre }) { + const { camera } = engine; + return [ + [ + 'camera.pose: angle ranges follow Cesium', + async () => { + const destination = cartesian3.fromDegrees(30, 20, 20000); + camera.setView({ + destination, + orientation: { heading: -0.5, pitch: -0.4, roll: -0.2 }, + }); + await settle(); + let pose = camera.pose(); + inRange(pose.heading, 0, math.TWO_PI, 'heading'); + inRange(pose.roll, 0, math.TWO_PI, 'roll'); + near(pose.heading, math.TWO_PI - 0.5, 1e-6, 'heading of −0.5'); + near(pose.roll, math.TWO_PI - 0.2, 1e-6, 'roll of −0.2'); + ensure( + pose.pitch >= -math.PI_OVER_TWO && pose.pitch <= math.PI_OVER_TWO, + `pitch ${pose.pitch} outside [−π/2, π/2]`, + ); + camera.setView({ + destination, + orientation: { heading: 0.7, pitch: -math.PI_OVER_TWO, roll: 0.3 }, + }); + await settle(); + pose = camera.pose(); + near(pose.roll, 0, 0, 'roll looking straight down'); + inRange(pose.heading, 0, math.TWO_PI, 'heading looking straight down'); + }, + ], + [ + 'camera.setView: heading π/2 at pitch 0 looks east', + async () => { + const destination = cartesian3.fromDegrees(-120, 35, 3000); + camera.setView({ + destination, + orientation: { heading: math.PI_OVER_TWO, pitch: 0, roll: 0 }, + }); + await settle(); + const pose = camera.pose(); + const axes = localAxes(pose.position); + near( + cartesian3.dot(pose.direction, axes.east), + 1, + 1e-6, + 'direction·east', + ); + near(cartesian3.dot(pose.up, axes.up), 1, 1e-6, 'up·localUp'); + }, + ], + [ + 'camera.setView: pitch −π/2 looks down the local up', + async () => { + const destination = cartesian3.fromDegrees(-120, 35, 3000); + camera.setView({ + destination, + orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, + }); + await settle(); + const pose = camera.pose(); + const axes = localAxes(pose.position); + near(cartesian3.dot(pose.direction, axes.up), -1, 1e-6, 'direction·up'); + near( + cartesian3.dot(pose.up, axes.north), + 1, + 1e-6, + 'up·north at heading 0', + ); + }, + ], + [ + 'camera.setView: positive roll tilts up toward the camera right', + async () => { + const destination = cartesian3.fromDegrees(10, 45, 3000); + camera.setView({ + destination, + orientation: { heading: 0.4, pitch: -0.2, roll: 0 }, + }); + await settle(); + const level = camera.pose(); + camera.setView({ + destination, + orientation: { heading: 0.4, pitch: -0.2, roll: 0.3 }, + }); + await settle(); + const rolled = camera.pose(); + near( + cartesian3.dot(rolled.up, level.right), + Math.sin(0.3), + 1e-6, + 'up·levelRight', + ); + near( + cartesian3.dot(rolled.direction, level.direction), + 1, + 1e-9, + 'roll changed the direction', + ); + }, + ], + [ + 'camera.flightActive: true during a flight, false after it ends', + async () => { + await nadirView(); + ensure(!camera.flightActive(), 'a flight is active before flyTo'); + camera.flyTo({ + destination: cartesian3.fromDegrees(100, 0, 5e6), + duration: 5, + }); + ensure(camera.flightActive(), 'no flight is active after flyTo'); + camera.cancelFlight(); + ensure( + !camera.flightActive(), + 'the flight is active after cancelFlight', + ); + }, + ], + [ + 'camera.flyToSphere: Cesium default heading, pitch and range', + async () => { + await nadirView(); + const center = cartesian3.fromDegrees(2.35, 48.85, 100); + const radius = 1000; + let done = false; + camera.flyToSphere( + { center, radius }, + { duration: 0, complete: () => (done = true) }, + ); + ensure( + done, + 'a duration-0 sphere flight did not complete synchronously', + ); + const pose = camera.pose(); + const { width, height } = engine.host.size(); + const tanPhi = Math.tan(pose.fovy / 2); + const tanTheta = (width / height) * tanPhi; + const range = Math.max(radius / tanTheta, radius / tanPhi); + near( + cartesian3.distance(pose.position, center), + range, + range * 1e-6, + 'range (m)', + ); + const axes = localAxes(center); + const toCamera = unit( + cartesian3.subtract(pose.position, center, cartesian3.create()), + ); + near( + cartesian3.dot(toCamera, axes.up), + Math.sin(Math.PI / 4), + 1e-6, + 'pitch −π/4', + ); + near( + cartesian3.dot(toCamera, axes.east), + 0, + 1e-6, + 'heading 0 (east offset)', + ); + ensure( + cartesian3.dot(toCamera, axes.north) < 0, + 'camera is not south of the centre', + ); + camera.flyToSphere({ center, radius: 0 }, { duration: 0 }); + near( + cartesian3.distance(camera.pose().position, center), + 100, + 1e-3, + 'minimum range (m)', + ); + const identity = camera.transform(); + for (let index = 0; index < 16; index++) + near( + identity[index], + matrix4.IDENTITY[index], + 0, + `transform[${index}]`, + ); + }, + ], + [ + 'camera.lookAtTransform: heading/pitch/range and Cartesian offsets, transform and release', + async () => { + await nadirView(); + const target = cartesian3.fromDegrees(-0.12, 51.5, 0); + const { frame, up } = localAxes(target); + camera.lookAtTransform(frame, { + heading: 0.4, + pitch: -0.7, + range: 2000, + }); + await settle(); + let pose = camera.pose(); + near( + cartesian3.distance(pose.position, target), + 2000, + 1e-3, + 'range (m)', + ); + const aim = () => + cartesian3.dot( + unit( + cartesian3.subtract( + target, + camera.pose().position, + cartesian3.create(), + ), + ), + camera.pose().direction, + ); + near(aim(), 1, 1e-9, 'direction·toTarget'); + const held = camera.transform(); + for (let index = 0; index < 16; index++) + near(held[index], frame[index], 1e-6, `transform[${index}]`); + camera.lookAtTransform(frame, cartesian3.create(0, -1000, 1000)); + await settle(); + pose = camera.pose(); + near( + cartesian3.distance(pose.position, target), + Math.SQRT2 * 1000, + 1e-3, + 'Cartesian offset range (m)', + ); + near(aim(), 1, 1e-9, 'Cartesian offset direction·toTarget'); + ensure( + cartesian3.dot(pose.up, up) > 0, + 'up does not follow the frame +Z', + ); + camera.release(); + await settle(); + near( + cartesian3.distance(camera.pose().position, pose.position), + 0, + 0.01, + 'release moved the camera (m)', + ); + const released = camera.transform(); + for (let index = 0; index < 16; index++) + near( + released[index], + matrix4.IDENTITY[index], + 0, + `released transform[${index}]`, + ); + }, + ], + [ + 'camera.zoomIn: zoomIn and zoomOut move along the view direction', + async () => { + camera.setView({ + destination: cartesian3.fromDegrees(20, 10, 50000), + orientation: { heading: 0.3, pitch: -0.9, roll: 0 }, + }); + await settle(); + const start = camera.pose(); + camera.zoomIn(2000); + await settle(); + const zoomed = camera.pose().position; + const moved = cartesian3.subtract( + zoomed, + start.position, + cartesian3.create(), + ); + near( + cartesian3.dot(moved, start.direction), + 2000, + 1e-3, + 'zoomIn distance (m)', + ); + near( + cartesian3.magnitude(moved), + 2000, + 1e-3, + 'zoomIn stays on the view axis', + ); + camera.zoomOut(500); + await settle(); + near( + cartesian3.distance(camera.pose().position, start.position), + 1500, + 1e-3, + 'zoomOut distance (m)', + ); + }, + ], + [ + 'camera.viewRectangle: wraps west > east across the antimeridian', + async () => { + camera.setView({ + destination: cartesian3.fromDegrees(180, 0, 1e6), + orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, + }); + await settle(); + const rectangle = camera.viewRectangle(); + ensure(rectangle, 'no view rectangle over the antimeridian'); + ensure( + rectangle.west > rectangle.east, + `west ${rectangle.west} is not east of east ${rectangle.east}`, + ); + ensure( + rectangle.west > 0 && rectangle.east < 0, + 'the rectangle does not cross ±180°', + ); + ensure( + rectangle.south < 0 && rectangle.north > 0, + 'the rectangle misses the equator', + ); + }, + ], + [ + 'camera.setView: missing angles default to heading 0, pitch −π/2, roll 0', + async () => { + const destination = cartesian3.fromDegrees(40, 30, 20000); + const expectDefaults = (label) => { + const pose = camera.pose(); + near(pose.pitch, -math.PI_OVER_TWO, 1e-6, `${label} pitch`); + near(angleBetween(pose.heading, 0), 0, 1e-6, `${label} heading`); + near(pose.roll, 0, 1e-6, `${label} roll`); + }; + const tilted = { heading: 1, pitch: -0.3, roll: 0.2 }; + camera.setView({ destination, orientation: tilted }); + camera.setView({ destination }); + await settle(); + expectDefaults('setView without orientation'); + camera.setView({ destination, orientation: tilted }); + camera.setView({ destination, orientation: {} }); + expectDefaults('setView with empty orientation'); + camera.setView({ destination, orientation: tilted }); + camera.flyTo({ destination, duration: 0 }); + expectDefaults('flyTo without orientation'); + camera.setView({ destination, orientation: tilted }); + camera.setView({ destination, orientation: { heading: 1 } }); + const partial = camera.pose(); + near(angleBetween(partial.heading, 1), 0, 1e-6, 'partial heading'); + near(partial.pitch, -math.PI_OVER_TWO, 1e-6, 'partial pitch default'); + }, + ], + [ + 'camera.viewRectangle: a horizontal view is bounded by the horizon', + async () => { + camera.setView({ + destination: cartesian3.fromDegrees(20, 10, 100000), + orientation: { heading: 0, pitch: 0, roll: 0 }, + }); + await settle(); + const rectangle = camera.viewRectangle(); + ensure(rectangle, 'no view rectangle looking at the horizon'); + for (const side of ['west', 'south', 'east', 'north']) + ensure(Number.isFinite(rectangle[side]), `${side} is not finite`); + // Looking north, Cesium's rectangle is built from the canvas corners + // that hit the globe (the bottom ones) and horizon points for the + // rest; it need not contain the camera's own position. + const { width, height } = engine.host.size(); + for (const x of [0, width]) { + const ground = camera.pickEllipsoid(cartesian2.create(x, height)); + ensure( + isCartesian3(ground), + `the bottom corner at x=${x} missed the globe`, + ); + const seen = cartographic.fromCartesian(ground, WGS84); + ensure( + rectangle.west <= seen.longitude + 1e-9 && + seen.longitude - 1e-9 <= rectangle.east && + rectangle.south <= seen.latitude + 1e-9 && + seen.latitude - 1e-9 <= rectangle.north, + `the visible corner at x=${x} lies outside the rectangle`, + ); + } + const lon = math.toRadians(20); + ensure( + rectangle.west < lon && lon < rectangle.east, + `camera longitude outside ${rectangle.west}..${rectangle.east}`, + ); + ensure( + rectangle.east - rectangle.west < math.toRadians(30) && + rectangle.north - rectangle.south < math.toRadians(30), + 'the rectangle is not bounded by the horizon', + ); + }, + ], + [ + 'camera.viewRectangle: undefined when looking away from the Earth', + async () => { + camera.setView({ + destination: cartesian3.fromDegrees(20, 10, 1e6), + orientation: { heading: 0, pitch: math.PI_OVER_TWO, roll: 0 }, + }); + await settle(); + const rectangle = camera.viewRectangle(); + ensure( + rectangle === undefined, + `looking away from the Earth gave ${JSON.stringify(rectangle)}`, + ); + }, + ], + [ + 'camera.viewRectangle: spans all longitudes over a pole', + async () => { + camera.setView({ + destination: cartesian3.fromDegrees(0, 89, 1e6), + orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, + }); + await settle(); + const rectangle = camera.viewRectangle(); + ensure(rectangle, 'no view rectangle over the pole'); + near(rectangle.west, -Math.PI, 1e-9, 'west'); + near(rectangle.east, Math.PI, 1e-9, 'east'); + near(rectangle.north, math.PI_OVER_TWO, 1e-9, 'north'); + ensure( + rectangle.south > 0, + `south ${rectangle.south} crosses the equator`, + ); + }, + ], + [ + 'projector.viewProjection: agrees with worldToWindow', + async () => { + camera.setView({ + destination: cartesian3.fromDegrees(20, 10, 30000), + orientation: { heading: 0.6, pitch: -0.5, roll: 0.1 }, + }); + await settle(); + const { width, height } = engine.host.size(); + const result = new Array(16).fill(0); + const matrix = engine.projector.viewProjection(result); + ensure( + matrix === result, + 'viewProjection did not fill its result argument', + ); + ensure( + matrix.length === 16 && matrix.every(Number.isFinite), + 'not 16 finite numbers', + ); + let compared = 0; + for (const [x, y] of [ + [width / 2, height / 2], + [width / 5, height / 4], + [width * 0.9, height * 0.8], + ]) { + const point = camera.pickEllipsoid(cartesian2.create(x, y)); + if (!point) continue; + const window = engine.projector.worldToWindow(point); + ensure(window, 'a picked point did not project'); + const clip = [0, 1, 2, 3].map( + (row) => + matrix[row] * point.x + + matrix[4 + row] * point.y + + matrix[8 + row] * point.z + + matrix[12 + row], + ); + ensure(clip[3] > 0, 'a visible point has clip.w <= 0'); + near(((clip[0] / clip[3] + 1) / 2) * width, window.x, 0.5, 'x (px)'); + near(((1 - clip[1] / clip[3]) / 2) * height, window.y, 0.5, 'y (px)'); + compared++; + } + 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: 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 () => { + await nadirView(); + ensure( + engine.projector.horizonVisible(cartesian3.fromDegrees(20, 10, 0)), + 'the nadir is hidden', + ); + ensure( + !engine.projector.horizonVisible( + cartesian3.fromDegrees(-160, -10, 0), + ), + 'the antipode is visible', + ); + ensure( + engine.projector.horizonVisible(cartesian3.fromDegrees(-70, 10, 2e7)), + 'a high point beside the limb is hidden', + ); + }, + ], + ]; +} + +export function aspectChecks(engine, { nadirView }) { + const { camera } = engine; + return [ + [ + 'camera.pose: aspectRatio is the view width over its height', + async () => { + await nadirView(); + const { width, height } = engine.host.size(); + // Cesium's frustum uses the drawing buffer, rounded to device pixels. + near(camera.pose().aspectRatio, width / height, 0.01, 'aspectRatio'); + }, + ], + ]; +} + +export function followChecks( + engine, + { settle, until, nadirView, centre, pointer }, +) { + const { camera } = engine; + return [ + [ + 'camera.commanded: fires for each camera command, never for reads', + async () => { + await nadirView(); + let raised = 0; + const remove = camera.commanded.addEventListener(() => raised++); + const target = cartesian3.fromDegrees(20, 10, 0); + const commands = [ + [ + 'setView', + () => + camera.setView({ + destination: cartesian3.fromDegrees(20, 10, 2e6), + }), + ], + [ + 'flyTo', + () => + camera.flyTo({ + destination: cartesian3.fromDegrees(21, 10, 2e6), + duration: 0, + }), + ], + [ + 'flyToSphere', + () => + camera.flyToSphere( + { center: target, radius: 100 }, + { duration: 0 }, + ), + ], + [ + 'lookAt', + () => camera.lookAt(target, { heading: 0, pitch: -1, range: 5e3 }), + ], + ['cancelFlight', () => camera.cancelFlight()], + ]; + try { + camera.pose(); + camera.height(); + camera.viewRectangle(); + camera.pickEllipsoid(centre()); + ensure(raised === 0, `reads raised camera.commanded ${raised}x`); + for (const [name, command] of commands) { + const before = raised; + command(); + ensure(raised > before, `${name} did not raise camera.commanded`); + } + camera.release(); + } finally { + remove(); + } + const count = raised; + camera.setView({ destination: cartesian3.fromDegrees(20, 10, 1e6) }); + ensure(raised === count, 'a removed listener was called'); + }, + ], + [ + 'camera.follow: holds a moving target centred, keeps the minimum range, ends on stop or a command', + async () => { + // An aircraft at 3 km over JFK flying east at 250 m/s, one step per + // drawn frame (the step after postRender: every stage of the next + // frame sees the same position, and the check reads the drawn one). + const start = cartographic.fromDegrees(-73.78, 40.64, 3000); + const stepRad = 250 / 60 / (6378137 * Math.cos(start.latitude)); + let step = 0; + let drawn = 0; + const at = (index) => + cartesian3.fromRadians( + start.longitude + index * stepRad, + start.latitude, + start.height, + ); + const removeStep = engine.frames.postRender.addEventListener(() => { + drawn = step; + step++; + }); + const changes = []; + const removeChanged = camera.followChanged.addEventListener((handle) => + changes.push(handle), + ); + const offCentre = () => { + const screen = engine.projector.worldToWindow(at(drawn)); + if (!screen) return Infinity; + const middle = centre(); + return Math.hypot(screen.x - middle.x, screen.y - middle.y); + }; + const range = () => + cartesian3.distance(camera.pose().position, at(drawn)); + try { + camera.setView({ + destination: cartesian3.fromDegrees(-73.8, 40.6, 8000), + orientation: { heading: 0, pitch: -0.6, roll: 0 }, + }); + await settle(); + // A layer's own handle as `native`: echoed, whatever the engine. + const followed = { id: 'contract-followed' }; + const handle = camera.follow({ + native: followed, + position: () => at(step), + viewFrom: { x: 0, y: -1000, z: 400 }, + }); + ensure( + camera.following() === handle, + 'following() is not the handle', + ); + ensure(handle.native === followed, 'the handle lost its native'); + ensure( + changes.at(-1) === handle, + 'followChanged did not report the handle', + ); + await until(() => offCentre() <= 3, 'the follow centring its target'); + near(range(), Math.hypot(1000, 400), 5, 'follow range at viewFrom'); + // target(): where the followed target is now. + ensure( + cartesian3.distance(handle.target(), at(step)) < 1, + 'following().target() is not the target', + ); + // 2 s of motion (at least 120 frames), centred within 3 px. + const began = performance.now(); + const firstStep = step; + let worst = 0; + for ( + let frames = 0; + frames < 600 && (frames < 120 || performance.now() - began < 2000); + frames++ + ) { + await settle(1); + worst = Math.max(worst, offCentre()); + } + ensure( + worst <= 3, + `the target drifted ${worst.toFixed(2)} px off centre`, + ); + ensure(step - firstStep >= 120, 'the target did not move'); + if (pointer?.drag && pointer?.wheel) { + // A left drag orbits the target: same range (±5 %), centred. + const { width, height } = engine.host.size(); + const before = range(); + await pointer.drag( + width / 2, + height / 2, + width / 2 + 200, + height / 2 + 60, + ); + await settle(4); + ensure(camera.following() === handle, 'a drag ended the follow'); + ensure( + Math.abs(range() - before) <= 0.05 * before, + `a drag changed the range ${before.toFixed(0)} → ${range().toFixed(0)} m`, + ); + ensure(offCentre() <= 3, 'the target left the centre after a drag'); + // A 240 px drag right turns the heading clockwise as Cesium's + // tracked camera: 1.77 × 240 / width × 360° (±15 %). + // Cesium's gesture inertia carries a turn on for a while: the + // heading is read once it has been still for 5 frames. + const headingAtRest = async () => { + let last = camera.pose().heading; + let still = 0; + await until(() => { + const now = camera.pose().heading; + still = + Math.abs(math.negativePiToPi(now - last)) < 1e-4 + ? still + 1 + : 0; + last = now; + return still >= 5; + }, 'the heading at rest'); + return last; + }; + const headingBefore = await headingAtRest(); + await pointer.drag( + width / 2 - 120, + height / 2, + width / 2 + 120, + height / 2, + ); + const headingAfter = await headingAtRest(); + const turned = + (math.negativePiToPi(headingAfter - headingBefore) * 180) / + Math.PI; + const expected = (1.77 * 240 * 360) / width; + // Clockwise, at Cesium's rate (the harness holds the pointer + // still before releasing it: no inertia). + ensure( + Math.abs(turned - expected) <= 0.15 * expected, + `a 240 px drag right turned the heading ${turned.toFixed(1)}°, Cesium turns ${expected.toFixed(1)}°`, + ); + ensure(offCentre() <= 3, 'the target left the centre after a turn'); + // A right drag down closes in (Cesium zoom3D), above the floor. + if (pointer.dragRight) { + const far = range(); + await pointer.dragRight( + width / 2, + height / 2 - 100, + width / 2, + height / 2 + 100, + ); + await headingAtRest(); + ensure( + range() < 0.8 * far && range() >= 149, + `a right drag down moved the range ${far.toFixed(0)} → ${range().toFixed(0)} m`, + ); + ensure( + offCentre() <= 3, + 'the target left the centre after a dolly', + ); + } + // The wheel zooms toward the target down to the floor, never below. + let lowest = Infinity; + for (let notch = 0; notch < 40; notch++) { + await pointer.wheel(width / 2, height / 2, -100); + await settle(2); + lowest = Math.min(lowest, range()); + } + await settle(4); + lowest = Math.min(lowest, range()); + ensure(lowest >= 149, `the wheel reached ${lowest.toFixed(1)} m`); + ensure( + range() <= 150 * 1.05, + `40 wheel notches stopped at ${range().toFixed(1)} m, not the 150 m floor`, + ); + ensure( + offCentre() <= 3, + 'the target left the centre after the wheel', + ); + ensure(camera.following() === handle, 'the wheel ended the follow'); + } + // A zoom through the target stops at the minimum range. + camera.zoomIn(5000); + await settle(4); + ensure(camera.following() === handle, 'zoomIn ended the follow'); + ensure(range() >= 149, `zoomIn reached ${range().toFixed(1)} m`); + ensure(offCentre() <= 3, 'the target left the centre after zoomIn'); + // stop() frees the camera: the target moves on without it. + handle.stop(); + ensure(camera.following() === null, 'following() after stop'); + ensure(changes.at(-1) === null, 'followChanged did not report null'); + camera.release(); + // Gesture inertia (Cesium) may carry the camera a few frames: + // wait for it to rest, then the target moves on without it. + let last = camera.pose().position; + let stillFrames = 0; + await until(() => { + const now = camera.pose().position; + stillFrames = + cartesian3.distance(now, last) < 1e-3 ? stillFrames + 1 : 0; + last = now; + return stillFrames >= 5; + }, 'the camera at rest after stop'); + const free = camera.pose().position; + await settle(10); + ensure( + cartesian3.distance(camera.pose().position, free) < 1e-3, + `the camera still moves after stop (${cartesian3 + .distance(camera.pose().position, free) + .toFixed(2)} m in 10 frames; following ${Boolean( + camera.following(), + )})`, + ); + // A command that takes the camera ends the follow. + camera.follow({ + position: () => at(step), + viewFrom: { x: 0, y: -1000, z: 400 }, + }); + await settle(4); + camera.setView({ + destination: cartesian3.fromDegrees(20, 10, 1e6), + orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, + }); + ensure(camera.following() === null, 'setView did not end the follow'); + ensure(changes.at(-1) === null, 'followChanged missed the command'); + await settle(); + near(camera.height(), 1e6, 1, 'height after the command'); + } finally { + camera.following()?.stop(); + camera.release(); + removeChanged(); + removeStep(); + } + }, + ], + ]; +} + +export function cameraBehaviourChecks( + engine, + { settle, until, nadirView, centre, pointer }, +) { + const { camera } = engine; + const checks = [ + ...['heading', 'roll'].map((angle) => [ + `camera.changed: a pure ${angle} change raises the event`, + async () => { + await nadirView(); + const previous = camera.percentageChanged; + camera.percentageChanged = 0.001; + const position = camera.pose().position; + let changes = 0; + const remove = camera.changed.addEventListener(() => changes++); + try { + camera.setView({ + destination: position, + orientation: { heading: 0, pitch: -1, roll: 0 }, + }); + await settle(3); + changes = 0; + camera.setView({ + destination: position, + orientation: { heading: 0, pitch: -1, roll: 0, [angle]: 0.8 }, + }); + await until(() => changes > 0, `${angle} changed`); + near( + cartesian3.distance(camera.pose().position, position), + 0, + 0.01, + 'rotation moved the camera', + ); + } finally { + remove(); + camera.percentageChanged = previous; + } + }, + ]), + [ + 'camera.following: set, retarget and stop retain identity and announce changes', + async () => { + await nadirView(); + const first = cartesian3.fromDegrees(20, 10, 1000); + const second = cartesian3.fromDegrees(20.01, 10, 1000); + const changes = []; + const remove = camera.followChanged.addEventListener((value) => + changes.push(value), + ); + let handle; + try { + const label = { id: 'contract-follow', position: () => first }; + handle = camera.follow({ + position: () => first, + viewFrom: { x: 0, y: -1000, z: 500 }, + label, + }); + ensure( + camera.following() === handle, + 'following did not return the handle', + ); + ensure( + changes.length === 1 && changes[0] === handle, + 'followChanged missed the start', + ); + ensure(handle.label?.id === label.id, 'follow label lost'); + handle.retarget({ position: () => second }); + await settle(3); + ensure(camera.following() === handle, 'retarget replaced the handle'); + near( + cartesian3.distance(handle.target(), second), + 0, + 0.01, + 'retarget position', + ); + ensure( + changes.length === 2 && changes[1] === handle, + 'followChanged missed the retarget', + ); + handle.stop(); + ensure(camera.following() === null, 'following did not clear'); + ensure( + changes.length === 3 && changes[2] === null, + 'followChanged missed the stop', + ); + handle.stop(); + ensure(changes.length === 3, 'stop announced twice'); + } finally { + handle?.stop(); + camera.release(); + remove(); + } + }, + ], + ]; + if (pointer?.drag) + checks.push([ + 'camera.flyTo: a pointer gesture during a flight does not cut it', + async () => { + await nadirView(); + let complete = 0; + let cancel = 0; + const destination = cartesian3.fromDegrees(21, 11, 5e5); + const { x, y } = centre(); + try { + camera.flyTo({ + destination, + duration: 2, + complete: () => complete++, + cancel: () => cancel++, + }); + await settle(2); + ensure(camera.flightActive(), 'flight ended before the gesture'); + await pointer.drag(x, y, x + 80, y + 40); + ensure(cancel === 0, 'pointer cancelled the flight'); + await until(() => complete > 0, 'flight completion'); + ensure(complete === 1 && cancel === 0, 'flight callbacks'); + near( + cartesian3.distance(camera.pose().position, destination), + 0, + 1, + 'flight destination', + ); + } finally { + camera.cancelFlight(); + } + }, + ]); + return checks; +} diff --git a/src/globe/testing/contractSuite/clock.mjs b/src/globe/testing/contractSuite/clock.mjs new file mode 100644 index 000000000..3654380c8 --- /dev/null +++ b/src/globe/testing/contractSuite/clock.mjs @@ -0,0 +1,83 @@ +import { ensure } from './helpers.mjs'; + +export function clockChecks(engine, { until }) { + return [ + [ + 'clock.frameTime: one frame time per frame, latched before preUpdate, never decreasing', + async () => { + const records = []; + const read = (name) => + records.push({ + name, + frame: engine.clock.frameTime(), + now: engine.clock.now(), + }); + const removers = ['preUpdate', 'preRender', 'postRender'].map((name) => + engine.frames[name].addEventListener(() => read(name)), + ); + try { + await until( + () => + records.filter(({ name }) => name === 'postRender').length >= 3, + 'three drawn frames', + ); + } finally { + for (const remove of removers) remove(); + } + ensure( + Number.isFinite(engine.clock.now()) && + Number.isFinite(engine.clock.frameTime()), + 'clock.now or clock.frameTime is not a number', + ); + // From the first preUpdate on: every frame event reads the frame time + // its frame's preUpdate read, and that time never goes back. + const start = records.findIndex(({ name }) => name === 'preUpdate'); + ensure(start >= 0, 'frames.preUpdate never fired'); + let frame = null; + for (const record of records.slice(start)) { + ensure( + record.frame <= record.now, + `${record.name}: frameTime ${record.frame} is after now ${record.now}`, + ); + if (record.name === 'preUpdate') { + ensure( + frame === null || record.frame >= frame.frame, + `frame time went back from ${frame?.frame} to ${record.frame}`, + ); + frame = record; + continue; + } + ensure( + record.frame === frame.frame, + `${record.name} read frame time ${record.frame}, its frame's is ${frame.frame}`, + ); + } + }, + ], + ]; +} + +export function clockLatchChecks(engine, { settle }) { + return [ + [ + 'clock.frameTime: advances before the next preUpdate', + async () => { + await settle(); + const before = engine.clock.frameTime(); + let observed; + const remove = engine.frames.preUpdate.addEventListener(() => { + observed ??= engine.clock.frameTime(); + }); + try { + await settle(); + ensure( + observed > before, + `preUpdate read stale frame time ${observed}, previous ${before}`, + ); + } finally { + remove(); + } + }, + ], + ]; +} diff --git a/src/globe/testing/contractSuite/events.mjs b/src/globe/testing/contractSuite/events.mjs new file mode 100644 index 000000000..41747ba4b --- /dev/null +++ b/src/globe/testing/contractSuite/events.mjs @@ -0,0 +1,177 @@ +import { ensure } from './helpers.mjs'; +import { assertGlobeEngine } from '../../contract.js'; + +export function shapeChecks(engine) { + return [ + [ + 'kind: the engine has every contract capability', + async () => { + assertGlobeEngine(engine); + }, + ], + [ + 'host.size: a positive CSS size', + async () => { + const { width, height } = engine.host.size(); + ensure( + width > 0 && height > 0, + `size ${width}x${height} is not positive`, + ); + }, + ], + ]; +} + +export function eventChecks(engine, { settle, until, nadirView }) { + const { camera } = engine; + return [ + [ + 'frames.postRender: removed listeners are not called', + async () => { + await nadirView(); + let viaHandle = 0; + let viaRemove = 0; + const remove = camera.moveStart.addEventListener(() => viaHandle++); + const listener = () => viaRemove++; + engine.frames.postRender.addEventListener(listener); + remove(); + ensure( + engine.frames.postRender.removeEventListener(listener) === true, + 'removeEventListener did not report removal', + ); + await nadirView(3e6); + await settle(3); + ensure( + viaHandle === 0 && viaRemove === 0, + 'a removed listener was called', + ); + }, + ], + [ + 'frames.preUpdate: preUpdate, preRender and postRender fire in order with timestamps', + async () => { + const order = []; + const removers = ['preUpdate', 'preRender', 'postRender'].map((name) => + engine.frames[name].addEventListener((time) => + order.push([name, time]), + ), + ); + try { + await until( + () => order.some(([name]) => name === 'postRender'), + 'postRender', + ); + } finally { + for (const remove of removers) remove(); + } + const firstPost = order.findIndex(([name]) => name === 'postRender'); + const frame = order.slice(0, firstPost + 1); + const index = (name) => frame.map(([entry]) => entry).lastIndexOf(name); + ensure(index('preRender') >= 0, 'preRender did not precede postRender'); + ensure(index('preUpdate') >= 0, 'preUpdate did not precede postRender'); + ensure( + index('preUpdate') < index('preRender'), + 'preUpdate ran after preRender', + ); + for (const [name, time] of order) + ensure( + Number.isFinite(time), + `${name} timestamp ${time} is not a number`, + ); + }, + ], + ]; +} + +export function creditChecks(engine) { + return [ + [ + 'credits.addStatic: returns an idempotent remover', + async () => { + const remove = engine.credits.addStatic('Contract check', { + onScreen: false, + }); + ensure(typeof remove === 'function', 'addStatic returned no remover'); + remove(); + remove(); + }, + ], + ]; +} + +export function listenerChecks(engine, { settle, until }) { + return [ + [ + 'frames.postRender: adding a listener twice registers it once', + async () => { + let once = 0; + let twice = 0; + const reference = () => once++; + const listener = () => twice++; + const removers = [ + engine.frames.postRender.addEventListener(reference), + engine.frames.postRender.addEventListener(listener), + engine.frames.postRender.addEventListener(listener), + ]; + try { + await until(() => once >= 2, 'postRender'); + } finally { + for (const remove of removers) remove(); + } + ensure( + twice === once, + `a listener added twice fired ${twice} times in ${once} frames`, + ); + removers[1](); + removers[2](); + await settle(2); + ensure(twice === once, 'a removed listener still fired'); + }, + ], + [ + 'frames.postRender: removal during dispatch skips the listener; re-adding keeps it', + async () => { + const event = engine.frames.postRender; + const seen = []; + const victim = () => seen.push('victim'); + const survivor = () => seen.push('survivor'); + let dispatches = 0; + const remover = () => { + dispatches++; + if (dispatches !== 1) return; + event.removeEventListener(victim); + event.removeEventListener(survivor); + event.addEventListener(survivor); + }; + event.addEventListener(remover); + event.addEventListener(victim); + event.addEventListener(survivor); + try { + await until(() => dispatches >= 3, 'three postRender dispatches'); + } finally { + for (const listener of [remover, victim, survivor]) + event.removeEventListener(listener); + } + ensure( + !seen.includes('victim'), + 'a listener removed during dispatch ran', + ); + ensure( + seen.includes('survivor'), + 'a listener removed and re-added during dispatch was lost', + ); + }, + ], + ]; +} + +export function lifetimeChecks(engine) { + return [ + [ + 'isDestroyed: false while the engine lives', + async () => { + ensure(engine.isDestroyed() === false, 'a live engine is destroyed'); + }, + ], + ]; +} diff --git a/src/globe/testing/contractSuite/ground.mjs b/src/globe/testing/contractSuite/ground.mjs new file mode 100644 index 000000000..ab44bd0af --- /dev/null +++ b/src/globe/testing/contractSuite/ground.mjs @@ -0,0 +1,1140 @@ +import { + cartesian3, + ellipsoid, + math, + throwsType, + like, + MAGENTA, + GREEN, + show, + screenOf, + WGS84, + RADIAL_RADIUS_M, + RADIAL_VIEW_M, + ensure, + along, + near, + nearColour, + meanAt, +} from './helpers.mjs'; + +export function groundChecks( + engine, + { settle, until, nadirView, readRegion, pointer }, +) { + const { ground } = engine; + const cell = (west, south, east, north, extra = {}) => ({ + west, + south, + east, + north, + ...extra, + }); + const square = (lon, lat, half) => [ + [lon - half, lat - half], + [lon + half, lat - half], + [lon + half, lat + half], + [lon - half, lat + half], + ]; + + return [ + [ + 'projector.horizonVisible: a lowered horizon ellipsoid keeps below-sea-level ground visible', + async () => { + const eye = cartesian3.fromDegrees(-74.006, 40.7128, -19.6); + engine.camera.setView({ destination: eye }); + await settle(); + const point = cartesian3.fromDegrees(-74.006, 40.71289, -22); + const options = { + ellipsoid: { + radii: { x: 6377137, y: 6377137, z: 6355752.314245179 }, + }, + }; + ensure( + engine.projector.horizonVisible(point, options), + 'nearby ground below WGS84 stays visible', + ); + ensure( + !engine.projector.horizonVisible( + cartesian3.fromDegrees(105.994, -40.7128), + options, + ), + 'far side stays hidden', + ); + throwsType( + () => + engine.projector.horizonVisible(point, { + ellipsoid: { radii: { x: 0, y: 1, z: 1 } }, + }), + 'zero horizon radius', + ); + await nadirView(1e6); + }, + ], + [ + 'ground.createSet: corridor reveals advance without replacing their path', + async () => { + const set = ground.createSet({ + id: 'contract-reveal', + kind: 'corridor', + reveal: true, + }); + try { + set.replace([ + { + positions: [ + [19.7, 10], + [20.3, 10], + ], + widthPx: 12, + color: [1, 0, 1, 1], + }, + ]); + set.commit(); + set.setReveal(0.25); + throwsType(() => set.setReveal(-1), 'negative reveal'); + throwsType(() => set.setReveal(2), 'large reveal'); + if (!readRegion) return; + await nadirView(350000); + await until(() => set.ready(), 'reveal ground ready'); + await settle(); + const magenta = (lon) => { + const { x, y } = screenOf(engine, lon, 10); + const p = readRegion(Math.round(x), Math.round(y), 1, 1); + return Math.min(p[0], p[2]) - p[1]; + }; + ensure(magenta(19.8) > 60, 'revealed start draws'); + ensure(magenta(20.2) < 60, 'unrevealed end is hidden'); + set.setReveal(0.9); + await settle(); + ensure(magenta(20.2) > 60, 'reveal advances over the existing path'); + } finally { + set.destroy(); + } + }, + ], + [ + 'ground.createPulseField: pulse fields update fronts, move, hide and release without a clock', + async () => { + const options = { + id: 'contract-ground-pulses', + center: cartesian3.fromDegrees(20, 10), + radiusM: 250000, + heightM: 2500, + color: [0, 1, 1, 0.25], + lineColor: [0, 1, 1, 1], + haloColor: [0, 0, 0, 0.8], + depth: 0.35, + lineWidth: 3, + haloWidth: 1, + }; + throwsType( + () => ground.createPulseField({ ...options, radiusM: -1 }), + 'negative radius', + ); + const field = ground.createPulseField(options); + const listed = () => ground.list().find(({ id }) => id === options.id); + try { + ensure(!listed().shown, 'new field is visible'); + field.draw({ radii: [0.2, 0.6], alphas: [0.4, 0.2], level: 1 }); + ensure( + listed().shown && !listed().animated, + 'caller-driven field starts no clock', + ); + throwsType( + () => field.draw({ radii: [2], alphas: [1], level: 1 }), + 'invalid front', + ); + ensure(listed().shown, 'invalid update changed visibility'); + field.move(cartesian3.fromDegrees(21, 10)); + await settle(); + field.draw({ radii: [], alphas: [], level: 0 }); + ensure(!listed().shown, 'resting field is visible'); + } finally { + field.destroy(); + } + field.destroy(); + field.draw({ radii: [0.5], alphas: [1], level: 1 }); + ensure(field.isDestroyed() && !listed(), 'destroyed field retained'); + }, + ], + [ + 'ground.createPulseField: pulse bands change pixels and rest clears them', + async () => { + if (!readRegion) return; + await nadirView(1e6); + // The base frame must be the settled surface: a check before this one + // can leave tiles of another view still loading. + await until( + () => engine.surface.tilesLoaded?.() !== false, + 'surface settles', + ); + await settle(5); + const at = engine.projector.worldToWindow( + cartesian3.fromDegrees(20.9, 10, 2500), + ); + const sample = () => + readRegion(Math.round(at.x) - 3, Math.round(at.y) - 3, 6, 6); + const base = sample(); + const difference = (a, b) => { + let sum = 0; + for (let i = 0; i < a.length; i++) + if (i % 4 !== 3) sum += Math.abs(a[i] - b[i]); + return sum / (a.length * 0.75); + }; + const field = ground.createPulseField({ + id: 'contract-pulse-pixels', + center: cartesian3.fromDegrees(20, 10), + radiusM: 200000, + heightM: 2500, + color: [1, 0, 1, 1], + lineColor: [1, 0, 1, 1], + haloColor: [0, 0, 0, 1], + depth: 0.5, + lineWidth: 3, + haloWidth: 1, + }); + try { + field.draw({ radii: [0.6], alphas: [1], level: 1 }); + await until( + () => difference(sample(), base) > 25, + 'pulse band draws', + ); + field.draw({ radii: [0.2], alphas: [1], level: 1 }); + await until( + () => difference(sample(), base) < 10, + 'front moves past the sampled band', + ); + field.draw({ radii: [0.6], alphas: [1], level: 1 }); + await until(() => difference(sample(), base) > 25, 'front returns'); + field.draw({ radii: [0.6], alphas: [1], level: 0 }); + await until( + () => difference(sample(), base) < 10, + 'rest clears the field', + ); + } finally { + field.destroy(); + } + }, + ], + [ + 'ground.createSet: sets are created, committed, listed, shown, hidden and destroyed', + async () => { + throwsType( + () => ground.createSet({ id: 'contract-bad', kind: 'blob' }), + 'an unknown kind', + ); + throwsType( + () => + ground.createSet({ id: 'contract-bad', kind: 'cell', drape: 'x' }), + 'an unknown drape', + ); + const set = ground.createSet({ + id: 'contract-ground', + kind: 'polygon', + order: 3, + }); + const others = []; + try { + let duplicate = null; + try { + ground.createSet({ id: 'contract-ground', kind: 'disc' }); + } catch (error) { + duplicate = error; + } + ensure(duplicate, 'a second live set with the same id'); + ensure(set.id === 'contract-ground', `id ${set.id}`); + ensure( + listed()?.kind === 'polygon' && + listed().count === 0 && + listed().shown === true && + listed().order === 3, + `list() before a commit: ${JSON.stringify(listed())}`, + ); + set.replace([ + { + rings: [square(20, 10, 0.5), square(20, 10, 0.2).reverse()], + fill: [1, 0, 0, 0.5], + outline: { color: [1, 1, 1, 1], widthPx: 2 }, + pickId: 'p1', + }, + { + rings: [[...square(21, 10, 0.3), [20.7, 9.7]]], + fill: [0, 1, 0, 1], + }, + ]); + ensure(set.count() === 0, 'replace() drew before commit()'); + set.commit(); + ensure(set.count() === 2, `count ${set.count()} after commit`); + ensure(listed()?.count === 2, 'list() missed the commit'); + throwsType( + () => + set.replace([ + { + rings: [ + [ + [0, 0], + [1, 1], + ], + ], + }, + ]), + 'a ring of two positions', + ); + throwsType( + () => + set.replace([{ rings: [square(0, 0, 1)], fill: [2, 0, 0, 1] }]), + 'a fill outside 0..1', + ); + set.commit(); + ensure(set.count() === 2, 'a refused replace() changed the set'); + await until(() => set.ready(), 'the polygons drawn'); + set.show(false); + ensure( + set.shown() === false && listed()?.shown === false, + 'show(false) not reported', + ); + set.show(true); + ensure(set.shown() === true, 'show(true) not reported'); + // Every kind takes its own items. + const disc = ground.createSet({ id: 'contract-discs', kind: 'disc' }); + others.push(disc); + disc.replace([ + { + center: [20, 10], + radiusM: 20_000, + fill: [0, 0, 1, 0.4], + outline: { color: [0, 0, 1, 1], widthPx: 3 }, + }, + ]); + disc.commit(); + const cells = ground.createSet({ + id: 'contract-cells', + kind: 'cell', + }); + others.push(cells); + cells.replace([ + cell(179.5, 0, -179.5, 1, { fill: [1, 1, 0, 0.5] }), + cell(20, 10, 20.5, 10.5, { fill: [1, 1, 0, 0.5] }), + ]); + cells.commit(); + const corridors = ground.createSet({ + id: 'contract-corridors', + kind: 'corridor', + }); + others.push(corridors); + corridors.replace([ + { + positions: [ + [19, 9], + [21, 9], + ], + color: [1, 0, 1, 1], + widthPx: 4, + }, + { + positions: [ + [19, 11], + [20, 11.2], + [21, 11], + ], + color: [0, 1, 1, 1], + widthM: 5000, + }, + ]); + corridors.commit(); + throwsType( + () => + corridors.replace([ + { + positions: [ + [0, 0], + [1, 0], + ], + color: [1, 1, 1, 1], + }, + ]), + 'a corridor without a width', + ); + await until( + () => others.every((other) => other.ready()), + 'discs, cells and corridors drawn', + ); + ensure( + ['contract-discs', 'contract-cells', 'contract-corridors'].every( + (id) => ground.list().some((entry) => entry.id === id), + ), + 'list() misses a set', + ); + set.replace([]); + set.commit(); + ensure(set.count() === 0, 'an empty commit kept items'); + } finally { + set.destroy(); + for (const other of others) other.destroy(); + } + ensure(!listed(), 'a destroyed set is still listed'); + set.destroy(); + let refused = null; + try { + set.commit(); + } catch (error) { + refused = error; + } + ensure(refused, 'a destroyed set took a commit'); + function listed() { + return ground.list().find(({ id }) => id === 'contract-ground'); + } + }, + ], + [ + 'ground.createSet: picks resolve to the pickId of the item on top', + async () => { + await nadirView(1e6); + const cells = ground.createSet({ + id: 'contract-ground-cells', + kind: 'cell', + pickable: true, + }); + const polygons = ground.createSet({ + id: 'contract-ground-polygons', + kind: 'polygon', + pickable: true, + order: 1, + }); + const quiet = ground.createSet({ + id: 'contract-ground-quiet', + kind: 'disc', + }); + const clicks = []; + const remove = engine.input.on('click', (event) => clicks.push(event), { + pick: true, + }); + try { + cells.replace([ + cell(19, 9.5, 20, 10.5, { fill: [1, 0, 0, 0.6], pickId: 'cell-a' }), + cell(19.2, 9.7, 19.4, 9.9, { fill: [1, 0, 0, 0.6] }), + ]); + cells.commit(); + polygons.replace([ + { + rings: [square(20.6, 10, 0.4)], + fill: [0, 0, 1, 0.6], + pickId: 'poly-b', + }, + ]); + polygons.commit(); + quiet.replace([ + { + center: [20.6, 10], + radiusM: 15_000, + fill: [0, 1, 0, 0.6], + pickId: 'q', + }, + ]); + quiet.commit(); + await until( + () => cells.ready() && polygons.ready() && quiet.ready(), + 'the pickable fills drawn', + ); + await settle(3); + const pickAt = (lon, lat) => + engine.picker.pick(screenOf(engine, lon, lat)); + const onCell = pickAt(19.5, 10.2); + ensure( + onCell?.id === 'cell-a' && onCell.owner === 'contract-ground-cells', + `a pick on the cell found ${JSON.stringify(onCell?.id)} of ${onCell?.owner}`, + ); + ensure(onCell.instance === 0, `instance ${onCell.instance}`); + const noId = pickAt(19.3, 9.8); + ensure( + noId?.owner !== 'contract-ground-cells' || noId.id === 'cell-a', + `an item without a pickId was picked (${JSON.stringify(noId?.id)})`, + ); + // Higher order on top, an unpickable set under it never answers. + const onPolygon = pickAt(20.6, 10.25); + ensure( + onPolygon?.id === 'poly-b', + `a pick on the polygon found ${JSON.stringify(onPolygon?.id)}`, + ); + const onQuiet = pickAt(20.6, 10); + ensure( + onQuiet?.id === 'poly-b' && + onQuiet.owner !== 'contract-ground-quiet', + `the unpickable disc answered (${JSON.stringify(onQuiet?.id)} of ${onQuiet?.owner})`, + ); + const outside = pickAt(18, 12); + ensure( + outside === undefined || outside.id === undefined, + `a pick off every fill found ${JSON.stringify(outside?.id)}`, + ); + polygons.show(false); + await settle(3); + const hidden = pickAt(20.6, 10.25); + ensure(hidden?.id !== 'poly-b', 'a hidden set answered a pick'); + polygons.show(true); + await settle(3); + if (!pointer) return; + const before = clicks.length; + const at = screenOf(engine, 19.5, 10.2); + await pointer.click(at.x, at.y); + await until(() => clicks.length > before, 'a click payload'); + const clicked = clicks.at(-1); + ensure( + clicked.picked?.id === 'cell-a', + `a click on the cell picked ${JSON.stringify(clicked.picked?.id)}`, + ); + } finally { + remove(); + cells.destroy(); + polygons.destroy(); + quiet.destroy(); + } + }, + ], + [ + 'ground.createSet: fills, outlines and corridors draw on the globe (pixels)', + async () => { + if (!readRegion) return; + await nadirView(1e6); + const MAGENTA = [1, 0, 1, 1]; + const GREEN = [0, 1, 0, 1]; + + // Pixels of a colour in a box centred on a ground point. + const countAt = (lon, lat, colour, half = 12) => { + const { x, y } = screenOf(engine, lon, lat); + const data = readRegion( + Math.round(x) - half, + Math.round(y) - half, + 2 * half, + 2 * half, + ); + let count = 0; + for (let i = 0; i < data.length; i += 4) + if (like(colour, data, i)) count++; + return count / (4 * half * half); + }; + const cells = ground.createSet({ + id: 'contract-paint-cells', + kind: 'cell', + }); + const discs = ground.createSet({ + id: 'contract-paint-discs', + kind: 'disc', + order: 1, + }); + const corridors = ground.createSet({ + id: 'contract-paint-corridors', + kind: 'corridor', + order: 2, + }); + const extra = []; + try { + ensure( + countAt(19.5, 10, MAGENTA) < 0.05, + 'the globe is already magenta under the cell', + ); + cells.replace([cell(19, 9.5, 20, 10.5, { fill: MAGENTA })]); + cells.commit(); + discs.replace([ + { + center: [21, 10], + radiusM: 40_000, + fill: GREEN, + outline: { color: MAGENTA, widthPx: 4 }, + }, + ]); + discs.commit(); + corridors.replace([ + { + positions: [ + [19, 8.5], + [21, 8.5], + ], + color: GREEN, + widthPx: 6, + }, + ]); + corridors.commit(); + await until( + () => cells.ready() && discs.ready() && corridors.ready(), + 'the fills built', + ); + await until( + () => countAt(19.5, 10, MAGENTA) > 0.9, + 'the magenta cell drawn', + ); + ensure( + countAt(21, 10, GREEN, 8) > 0.9, + `the disc's centre is ${countAt(21, 10, GREEN, 8).toFixed(2)} green`, + ); + // The outline: magenta on the disc's rim, 40 km east of its centre. + const rimLon = 21 + 40_000 / (111_320 * Math.cos(math.toRadians(10))); + ensure( + countAt(rimLon, 10, MAGENTA, 3) > 0.3, + `the disc's outline is ${countAt(rimLon, 10, MAGENTA, 3).toFixed(2)} magenta at the rim`, + ); + // A 6 px corridor: a column of pixels across it. + const mid = screenOf(engine, 20, 8.5); + const column = readRegion( + Math.round(mid.x), + Math.round(mid.y) - 10, + 1, + 20, + ); + let lit = 0; + for (let i = 0; i < column.length; i += 4) + if (like(GREEN, column, i)) lit++; + ensure(lit >= 4 && lit <= 8, `the 6 px corridor is ${lit} px wide`); + cells.show(false); + await until( + () => countAt(19.5, 10, MAGENTA) < 0.05, + 'the hidden cell gone', + ); + cells.show(true); + await until( + () => countAt(19.5, 10, MAGENTA) > 0.9, + 'the cell shown again', + ); + cells.replace([]); + cells.commit(); + await until( + () => countAt(19.5, 10, MAGENTA) < 0.05, + 'the emptied cell gone', + ); + // A polygon of many vertices with a hole (engines may bin it). + const ring = (radius, count) => + Array.from({ length: count }, (_, index) => { + const angle = (index / count) * 2 * Math.PI; + return [ + 18 + radius * Math.cos(angle), + 11 + radius * Math.sin(angle), + ]; + }); + const many = ground.createSet({ + id: 'contract-paint-polygon', + kind: 'polygon', + }); + extra.push(many); + many.replace([ + { rings: [ring(0.7, 240), ring(0.25, 120)], fill: MAGENTA }, + ]); + many.commit(); + await until(() => many.ready(), 'the large polygon built'); + await until( + () => countAt(18.45, 11, MAGENTA, 6) > 0.9, + 'the large polygon drawn', + ); + ensure( + countAt(18, 11, MAGENTA, 6) < 0.05, + `the hole is ${countAt(18, 11, MAGENTA, 6).toFixed(2)} magenta`, + ); + ensure( + countAt(18, 12.2, MAGENTA, 6) < 0.05, + 'the large polygon spills outside its ring', + ); + // Round both rings, just inside and just outside each. + for (let step = 0; step < 12; step++) { + const angle = (step / 12) * 2 * Math.PI + 0.1; + const at = (radius) => [ + 18 + radius * Math.cos(angle), + 11 + radius * Math.sin(angle), + ]; + for (const [radius, filled] of [ + [0.62, true], + [0.78, false], + [0.33, true], + [0.17, false], + ]) { + const share = countAt(...at(radius), MAGENTA, 2); + ensure( + filled ? share > 0.9 : share < 0.1, + `at ${radius}° from the centre, ${(step * 30).toFixed(0)}° round: ${share.toFixed(2)} magenta`, + ); + } + } + } finally { + cells.destroy(); + discs.destroy(); + corridors.destroy(); + for (const set of extra) set.destroy(); + } + }, + ], + [ + 'ground.createSet: corridors glow, pulse and dash, and keep frames coming while they move', + async () => { + const line = (lat, extra) => ({ + positions: [ + [19, lat], + [21, lat], + ], + color: [1, 0, 1, 1], + widthPx: 12, + ...extra, + }); + const bad = ground.createSet({ + id: 'contract-looks-bad', + kind: 'corridor', + }); + try { + for (const [item, label] of [ + [ + { + ...line(9, { glow: { power: 0.25 } }), + widthPx: undefined, + widthM: 900, + }, + 'a glow on a metre corridor', + ], + [ + line(9, { pulse: { periodMs: 1000, min: 0.8, max: 0.2 } }), + 'a pulse min over max', + ], + [ + line(9, { pulse: { periodMs: 0, min: 0, max: 1 } }), + 'a pulse of no period', + ], + [ + line(9, { dash: { cells: 8, duty: 1 } }), + 'a dash lit all its cell', + ], + [line(9, { glow: { power: 0 } }), 'a glow of no power'], + ]) + throwsType(() => bad.replace([item]), label); + } finally { + bad.destroy(); + } + const glowing = ground.createSet({ + id: 'contract-glow', + kind: 'corridor', + order: 5, + }); + const pulsing = ground.createSet({ + id: 'contract-pulse', + kind: 'corridor', + order: 5, + }); + const dashed = ground.createSet({ + id: 'contract-dash', + kind: 'corridor', + order: 5, + }); + const listed = (id) => ground.list().find((entry) => entry.id === id); + // Frames drawn in a window, with nothing asked of the engine. + const framesIn = async (ms) => { + let frames = 0; + const remove = engine.frames.postRender.addEventListener( + () => frames++, + ); + await new Promise((resolve) => setTimeout(resolve, ms)); + remove(); + return frames; + }; + try { + if (readRegion) await nadirView(1e6); + const idle = readRegion ? await framesIn(1000) : 0; + glowing.replace([ + line(9, { + color: [0, 1, 0, 1], + widthPx: 24, + glow: { power: 0.25 }, + }), + ]); + glowing.commit(); + ensure( + listed('contract-glow')?.animated === false, + 'a glow alone does not animate', + ); + pulsing.replace([ + line(10, { pulse: { periodMs: 800, min: 0.1, max: 1 } }), + ]); + pulsing.commit(); + ensure( + listed('contract-pulse')?.animated === true, + 'a pulse animates', + ); + dashed.replace([ + line(11, { dash: { cells: 10, duty: 0.5, speed: 2 } }), + ]); + dashed.commit(); + ensure(listed('contract-dash')?.animated === true, 'a dash animates'); + await until( + () => glowing.ready() && pulsing.ready() && dashed.ready(), + 'the animated corridors built', + ); + if (!readRegion) return; + // (A page that already draws every frame shows nothing more.) + const moving = await framesIn(1000); + ensure( + moving >= Math.min(idle + 20, 50), + `${moving} frames in 1 s while corridors pulse and flow, ${idle} before`, + ); + // Pixels along a line of latitude between 19.2° and 20.8°E. + const along = (lat, samples = 120) => { + const out = []; + for (let k = 0; k < samples; k++) { + const at = screenOf( + engine, + 19.2 + (1.6 * k) / (samples - 1), + lat, + ); + out.push([ + ...readRegion(Math.round(at.x), Math.round(at.y), 1, 1), + ]); + } + return out; + }; + const magenta = ([r, g, b]) => r > 200 && b > 200 && g < 60; + // The glow: white at its centre line, green toward its edges. + { + const centre = screenOf(engine, 20, 9); + const column = readRegion( + Math.round(centre.x), + Math.round(centre.y) - 12, + 1, + 25, + ); + const mid = column.slice(12 * 4, 12 * 4 + 4); + ensure( + mid[0] > 200 && mid[1] > 200 && mid[2] > 200, + `the glow's centre is ${[...mid.slice(0, 3)]}, not white`, + ); + let greenish = 0; + for (let i = 0; i < column.length; i += 4) + if (column[i + 1] > 120 && column[i] < column[i + 1] - 60) + greenish++; + ensure( + greenish >= 2, + `the glow fades to green over ${greenish} px`, + ); + } + // The pulse: magenta at its peak, faint at its trough. + { + let peak = 0; + let trough = 0; + const started = performance.now(); + while (performance.now() - started < 1600) { + const centre = screenOf(engine, 20, 10); + const pixel = readRegion( + Math.round(centre.x), + Math.round(centre.y), + 1, + 1, + ); + if (magenta(pixel)) peak++; + else trough++; + await settle(1); + } + ensure( + peak > 0 && trough > 0, + `the pulse was magenta ${peak} times, not ${trough}`, + ); + } + // The dash: lit and dark cells along the line, moving. + { + const first = along(11).map(magenta); + let changes = 0; + for (let k = 1; k < first.length; k++) + if (first[k] !== first[k - 1]) changes++; + const lit = first.filter(Boolean).length / first.length; + ensure( + changes >= 8 && lit > 0.25 && lit < 0.75, + `the dash is ${lit.toFixed(2)} lit with ${changes} changes along it`, + ); + await new Promise((resolve) => setTimeout(resolve, 150)); + await settle(2); + const second = along(11).map(magenta); + const moved = second.filter( + (value, k) => value !== first[k], + ).length; + ensure( + moved >= 12, + `the dash moved at ${moved} of 120 pixels in 150 ms`, + ); + } + pulsing.show(false); + dashed.show(false); + await settle(2); + const still = await framesIn(1000); + ensure( + still <= idle + 3, + `${still} frames in 1 s once hidden, ${idle} before`, + ); + } finally { + glowing.destroy(); + pulsing.destroy(); + dashed.destroy(); + } + }, + ], + [ + 'ground.createSet: radial discs retain their look and draw a brighter rim than fill', + async () => { + const background = ground.createSet({ + id: 'contract-radial-background', + kind: 'cell', + }); + const disc = ground.createSet({ + id: 'contract-radial', + kind: 'disc', + order: 1, + }); + try { + background.replace([ + { west: 19, south: 9, east: 21, north: 11, fill: [0, 0, 0, 1] }, + ]); + background.commit(); + disc.replace([ + { + center: [20, 10], + // A launch pad's size: Cesium maps a large ground disc's + // texture coordinates differently, and only pads use this look. + radiusM: RADIAL_RADIUS_M, + fill: [0, 1, 1, 1], + radial: { + fillAlpha: 0.105, + rimAlpha: 0.72, + rimStart: 0.952, + rimEnd: 0.985, + emission: 0.35, + }, + }, + ]); + disc.commit(); + ensure( + ground.list().find((set) => set.id === 'contract-radial').count === + 1, + 'radial disc committed', + ); + if (!readRegion) return; + await nadirView(RADIAL_VIEW_M); + await until( + () => disc.ready() && background.ready(), + 'radial ground ready', + ); + await settle(); + const metresPerDegree = 111319.49 * Math.cos((10 * Math.PI) / 180); + const at = (radius, angle) => { + const { x, y } = screenOf( + engine, + 20 + (radius * Math.cos(angle)) / metresPerDegree, + 10 + (radius * Math.sin(angle)) / 110574, + ); + const bytes = readRegion(Math.round(x), Math.round(y), 1, 1); + return bytes[1] + bytes[2]; + }; + // Cesium leaves part of a small ground disc's fill undrawn (its + // old launch pad shows the same gap): the brightest inner sample. + const fillAt = () => + Math.max( + ...[0, 1, 2, 3, 4, 5].map((k) => + at(RADIAL_RADIUS_M * 0.5, (k * Math.PI) / 3), + ), + ); + // Cesium classifies onto the terrain loaded at this height, which + // can take longer than the usual frame budget after the descent. + // The app's own arrival flight can land after the view was set: + // the view is set again until the disc draws. + // Pixels come from the last drawn frame, screen positions from the + // camera now: both must be at the view, with no flight under way. + const away = () => + engine.camera.flightActive() || + engine.camera.height() > RADIAL_VIEW_M * 1.5; + for ( + let frame = 0; + frame < 1200 && (away() || fillAt() <= 5); + frame++ + ) { + if (away()) { + engine.camera.cancelFlight(); + await nadirView(RADIAL_VIEW_M); + } + await settle(1); + } + const fill = fillAt(); + // The rim is a few pixels wide: its brightest sample across it. + let rim = 0; + for (let step = 0; step <= 25; step++) + rim = Math.max(rim, at(RADIAL_RADIUS_M * (0.85 + step * 0.01), 0)); + ensure(fill > 5, `radial fill is visible: ${fill}`); + ensure( + rim > fill + 50, + `radial rim ${rim} is brighter than fill ${fill}`, + ); + disc.show(false); + await settle(); + ensure(fillAt() < fill, 'hidden radial fill clears'); + } finally { + disc.destroy(); + background.destroy(); + } + }, + ], + ]; +} + +export function groundBehaviourChecks( + engine, + { settle, until, nadirView, centre, readRegion, fixtures, mapSources }, +) { + const checks = [ + [ + 'ground.createSet: a ground fill colour draws as asked within 10 per channel', + async () => { + await nadirView(10000); + const set = engine.ground.createSet({ + id: 'contract-fill-colour', + kind: 'cell', + }); + try { + set.replace([ + { + west: 19.98, + south: 9.98, + east: 20.02, + north: 10.02, + fill: [0.2, 0.6, 0.4, 1], + }, + ]); + set.commit(); + await until(() => set.ready(), 'ground fill ready'); + if (readRegion) + await until( + () => + nearColour( + meanAt(engine, readRegion, 20, 10, 2), + [51, 153, 102], + 10, + ), + 'ground colour within 10 per channel', + ); + } finally { + set.destroy(); + } + }, + ], + [ + 'ground.createSet: terrain drape draws on the terrain surface', + async () => { + await engine.maps.setPhotorealTiles(null); + await nadirView(10000); + const set = engine.ground.createSet({ + id: 'contract-terrain-drape', + kind: 'cell', + drape: 'terrain', + pickable: true, + }); + try { + set.replace([ + { + west: 19.98, + south: 9.98, + east: 20.02, + north: 10.02, + fill: [1, 0, 1, 1], + pickId: 'terrain-drape', + }, + ]); + set.commit(); + await until(() => set.ready(), 'terrain drape ready'); + await until( + () => engine.picker.pick(centre())?.id === 'terrain-drape', + 'terrain drape pick', + ); + if (readRegion) + await until( + () => + nearColour(meanAt(engine, readRegion, 20, 10, 2), MAGENTA, 10), + 'terrain drape pixels', + ); + if (fixtures?.tiles) { + await engine.maps.setPhotorealTiles(mapSources.photoreal); + ensure( + engine.picker.pick(centre())?.id !== 'terrain-drape', + 'terrain drape picked on tiles', + ); + } + } finally { + set.destroy(); + await engine.maps.setPhotorealTiles(null); + } + }, + ], + [ + 'ground.createSet: corridor width is in CSS pixels at the device scale', + async () => { + await nadirView(10000); + const set = engine.ground.createSet({ + id: 'contract-corridor-width', + kind: 'corridor', + }); + try { + set.replace([ + { + positions: [ + [19.98, 10], + [20.02, 10], + ], + widthPx: 12, + color: [1, 0, 1, 1], + }, + ]); + set.commit(); + await until(() => set.ready(), 'pixel corridor ready'); + await settle(3); + if (!readRegion) return; + const { x, y } = centre(); + const pixels = readRegion(Math.round(x), Math.round(y) - 24, 1, 48); + let width = 0; + for (let i = 0; i < pixels.length; i += 4) + if (pixels[i] > 200 && pixels[i + 1] < 70 && pixels[i + 2] > 200) + width++; + near(width, 12, 2, 'corridor CSS width'); + } finally { + set.destroy(); + } + }, + ], + ]; + if (fixtures?.tiles) + checks.push([ + 'ground.createSet: tiles drape ignores terrain and picks only tiles', + async () => { + await nadirView(10000); + const set = engine.ground.createSet({ + id: 'contract-tiles-drape', + kind: 'cell', + drape: 'tiles', + pickable: true, + }); + try { + set.replace([ + { + west: 19.98, + south: 9.98, + east: 20.02, + north: 10.02, + fill: [1, 0, 1, 1], + pickId: 'tiles-drape', + }, + ]); + set.commit(); + await engine.maps.setPhotorealTiles(null); + ensure( + engine.picker.pick(centre())?.id !== 'tiles-drape', + 'tiles drape picked on terrain', + ); + await engine.maps.setPhotorealTiles(mapSources.photoreal); + ensure( + engine.picker.pick(centre())?.id === 'tiles-drape', + 'tiles drape missed tiles', + ); + } finally { + set.destroy(); + await engine.maps.setPhotorealTiles(null); + } + }, + ]); + return checks; +} diff --git a/src/globe/testing/contractSuite/helpers.mjs b/src/globe/testing/contractSuite/helpers.mjs new file mode 100644 index 000000000..4af9e90ef --- /dev/null +++ b/src/globe/testing/contractSuite/helpers.mjs @@ -0,0 +1,123 @@ +import * as geo from '../../../geo/index.js'; + +const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo; +const throwsType = (run, label) => { + let error = null; + try { + run(); + } catch (caught) { + error = caught; + } + ensure(error instanceof TypeError, `${label} did not throw a TypeError`); +}; +const like = (colour, data, i) => + colour.every( + (channel, k) => k > 2 || Math.abs(data[i + k] - channel * 255) <= 40, + ); +const MAGENTA = [255, 0, 255]; +const GREEN = [0, 255, 0]; +const nearColour = (actual, expected, tolerance = 40) => + actual.every((value, k) => Math.abs(value - expected[k]) <= tolerance); +const show = (colour) => colour.map((value) => value.toFixed(0)).join(','); +const screenOf = (engine, lon, lat) => + engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat)); +const meanAt = (engine, readRegion, lon, lat, half) => { + const { x, y } = screenOf(engine, lon, lat); + const data = readRegion( + Math.round(x) - half, + Math.round(y) - half, + 2 * half, + 2 * half, + ); + const mean = [0, 0, 0]; + for (let i = 0; i < data.length; i += 4) + for (let k = 0; k < 3; k++) mean[k] += data[i + k]; + return mean.map((sum) => sum / (data.length / 4)); +}; + +const WGS84 = ellipsoid.WGS84; + +class ContractError extends Error { + constructor(message) { + super(message); + this.name = 'ContractError'; + } +} + +// The radial ground look's disc in the contract checks, a launch pad's radius. +const RADIAL_RADIUS_M = 500; +// The camera height that shows that disc and its rim a few pixels wide. +const RADIAL_VIEW_M = 4000; + +function ensure(condition, message) { + if (!condition) throw new ContractError(message); +} + +function near(actual, expected, tolerance, label) { + ensure( + typeof actual === 'number' && Math.abs(actual - expected) <= tolerance, + `${label}: ${actual} is not within ${tolerance} of ${expected}`, + ); +} + +// Smallest difference between two angles, in radians. +const angleBetween = (a, b) => Math.abs(math.negativePiToPi(a - b)); + +const isCartesian3 = (value) => + value != null && + Number.isFinite(value.x) && + Number.isFinite(value.y) && + Number.isFinite(value.z); + +// The local east/north/up axes at a position. +function localAxes(position) { + const frame = geo.transforms.eastNorthUpToFixedFrame(position, WGS84); + return { + frame, + east: cartesian3.create(frame[0], frame[1], frame[2]), + north: cartesian3.create(frame[4], frame[5], frame[6]), + up: cartesian3.create(frame[8], frame[9], frame[10]), + }; +} + +const inRange = (value, low, high, label) => + ensure( + typeof value === 'number' && value >= low && value < high, + `${label} ${value} is outside [${low}, ${high})`, + ); + +const unit = (vector) => cartesian3.normalize(vector, cartesian3.create()); +const along = (origin, direction, distance) => + cartesian3.add( + origin, + cartesian3.multiplyByScalar(direction, distance, cartesian3.create()), + cartesian3.create(), + ); + +export { + cartesian2, + cartesian3, + cartographic, + ellipsoid, + math, + matrix4, + throwsType, + like, + MAGENTA, + GREEN, + nearColour, + show, + screenOf, + meanAt, + WGS84, + RADIAL_RADIUS_M, + RADIAL_VIEW_M, + ensure, + near, + angleBetween, + isCartesian3, + localAxes, + inRange, + unit, + along, +}; diff --git a/src/globe/testing/contractSuite/imagery.mjs b/src/globe/testing/contractSuite/imagery.mjs new file mode 100644 index 000000000..46369da8a --- /dev/null +++ b/src/globe/testing/contractSuite/imagery.mjs @@ -0,0 +1,727 @@ +import { + ellipsoid, + throwsType, + MAGENTA, + GREEN, + nearColour, + show, + screenOf, + meanAt, + ensure, +} from './helpers.mjs'; + +export function imageryChecks( + engine, + { settle, until, nadirView, readRegion }, +) { + const { imagery } = engine; + + // A flat colour as RGBA bytes (no canvas needed), size × size texels. + const flat = (rgba, size = 8) => { + const data = new Uint8Array(size * size * 4); + for (let i = 0; i < data.length; i += 4) data.set(rgba, i); + return { width: size, height: size, data }; + }; + const box = { west: 19, south: 9.5, east: 20, north: 10.5 }; + const listed = (id) => imagery.list().find((entry) => entry.id === id); + return [ + [ + 'imagery.createLayer: layers are created, listed, restyled, ordered and destroyed', + async () => { + const source = { kind: 'image', image: flat([255, 0, 255, 255]) }; + throwsType( + () => + imagery.createLayer({ id: 'contract-bad', source: { kind: 'x' } }), + 'an unknown source kind', + ); + throwsType( + () => + imagery.createLayer({ + id: 'contract-bad', + source: { + kind: 'tiles', + url: 'https://example.test/{z}/{x}.png', + }, + }), + 'a template without {y}', + ); + throwsType( + () => + imagery.createLayer({ + id: 'contract-bad', + source: { + kind: 'image', + image: { width: 2, height: 2, data: new Uint8Array(3) }, + }, + }), + 'RGBA bytes of the wrong length', + ); + throwsType( + () => imagery.createLayer({ id: 'contract-bad', source, alpha: 2 }), + 'an alpha above 1', + ); + throwsType( + () => + imagery.createLayer({ id: 'contract-bad', source, split: 'up' }), + 'an unknown split', + ); + throwsType( + () => + imagery.createLayer({ + id: 'contract-bad', + source: { + ...source, + rectangle: { west: 0, south: 5, east: 1, north: 4 }, + }, + }), + 'a rectangle upside down', + ); + ensure(!listed('contract-bad'), 'a refused layer is listed'); + ensure( + ['globe', 'tiles', 'none'].includes(imagery.surface()), + `surface() ${imagery.surface()}`, + ); + const top = imagery.createLayer({ + id: 'contract-imagery-top', + source, + order: 2, + }); + const bottom = imagery.createLayer({ + id: 'contract-imagery-bottom', + source: { + kind: 'image', + image: flat([0, 255, 0, 255]), + rectangle: box, + }, + alpha: 0.5, + show: false, + split: 'left', + }); + const split = imagery.splitPosition(); + try { + let duplicate = null; + try { + imagery.createLayer({ id: 'contract-imagery-top', source }); + } catch (error) { + duplicate = error; + } + ensure(duplicate, 'a second live layer with the same id'); + ensure(top.id === 'contract-imagery-top', `id ${top.id}`); + const ids = imagery.list().map(({ id }) => id); + ensure( + ids.indexOf('contract-imagery-bottom') < + ids.indexOf('contract-imagery-top'), + `list() is not in draw order: ${ids.join(', ')}`, + ); + ensure( + listed('contract-imagery-bottom')?.kind === 'image' && + listed('contract-imagery-bottom').shown === false && + listed('contract-imagery-bottom').alpha === 0.5 && + listed('contract-imagery-bottom').split === 'left' && + listed('contract-imagery-top').order === 2, + `list(): ${JSON.stringify(imagery.list())}`, + ); + ensure( + bottom.shown() === false && + bottom.alpha() === 0.5 && + bottom.split() === 'left', + 'the options are not the layer state', + ); + bottom.show(true); + bottom.setAlpha(0.25); + bottom.setSplit('right'); + ensure( + bottom.shown() === true && + bottom.alpha() === 0.25 && + bottom.split() === 'right' && + listed('contract-imagery-bottom').alpha === 0.25, + 'a restyle is not reported', + ); + throwsType(() => bottom.setAlpha(-1), 'a negative alpha'); + throwsType(() => bottom.setSplit('down'), 'an unknown side'); + throwsType( + () => bottom.setSource({ kind: 'image' }), + 'an empty source', + ); + ensure( + bottom.source().kind === 'image', + 'a refused source replaced it', + ); + bottom.setSource({ + kind: 'tiles', + url: 'https://tiles.invalid/{z}/{x}/{y}.png', + maxLevel: 3, + rectangle: box, + }); + ensure( + bottom.source().kind === 'tiles' && + listed('contract-imagery-bottom').kind === 'tiles', + 'setSource() did not replace the source', + ); + imagery.setSplitPosition(0.25); + ensure(imagery.splitPosition() === 0.25, 'the split position'); + throwsType( + () => imagery.setSplitPosition(1.5), + 'a split outside 0..1', + ); + await until(() => top.ready(), 'the image layer ready'); + await settle(); + } finally { + imagery.setSplitPosition(split); + top.destroy(); + bottom.destroy(); + } + ensure(!listed('contract-imagery-top'), 'a destroyed layer is listed'); + ensure(!top.ready(), 'a destroyed layer is ready'); + top.destroy(); + let refused = null; + try { + top.setAlpha(1); + } catch (error) { + refused = error; + } + ensure(refused, 'a destroyed layer took a restyle'); + }, + ], + [ + 'imagery.createLayer: images draw on the ground by alpha, show, order and split (pixels)', + async () => { + if (!readRegion) return; + await nadirView(1e6); + // The mean colour in a box centred on a ground point. + + // The map's own tiles loaded: what lies under the layers holds still. + await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded'); + await settle(4); + const base = meanAt(engine, readRegion, 19.5, 10, 6); + ensure( + !nearColour(base, MAGENTA), + 'the globe is already magenta there', + ); + const magenta = imagery.createLayer({ + id: 'contract-paint-magenta', + source: { + kind: 'image', + image: flat([...MAGENTA, 255]), + rectangle: box, + }, + }); + const layers = [magenta]; + try { + await until(() => magenta.ready(), 'the magenta layer ready'); + await until( + () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA), + 'the magenta layer drawn', + ); + ensure( + nearColour(meanAt(engine, readRegion, 21, 10, 6), base, 60) || + !nearColour(meanAt(engine, readRegion, 21, 10, 6), MAGENTA), + 'the layer spills outside its rectangle', + ); + // Alpha: at 0 the globe under it, at 0.5 half way to magenta. + magenta.setAlpha(0); + await until( + () => !nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA), + 'alpha 0 drawn', + ); + await settle(2); + const below = meanAt(engine, readRegion, 19.5, 10, 6); + magenta.setAlpha(0.5); + const half = below.map((value, k) => (value + MAGENTA[k]) / 2); + await until( + () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), half, 30), + `alpha 0.5 drawn (${show(half)} wanted)`, + ); + magenta.setAlpha(1); + magenta.show(false); + await until( + () => + nearColour(meanAt(engine, readRegion, 19.5, 10, 6), below, 30), + 'the hidden layer gone', + ); + magenta.show(true); + await until( + () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA), + 'the layer shown again', + ); + // Order: a higher order draws on top, a lower one under it. + const green = imagery.createLayer({ + id: 'contract-paint-green', + source: { + kind: 'image', + image: flat([...GREEN, 255]), + rectangle: box, + }, + order: 1, + }); + layers.push(green); + const lower = imagery.createLayer({ + id: 'contract-paint-under', + source: { + kind: 'image', + image: flat([0, 255, 255, 255]), + rectangle: box, + }, + order: -1, + }); + layers.push(lower); + await until( + () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), GREEN), + 'the higher order on top', + ); + green.destroy(); + await until( + () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA), + 'the destroyed layer gone', + ); + // A new source in place: the same layer, another colour. + magenta.setSource({ + kind: 'image', + image: flat([...GREEN, 255]), + rectangle: box, + }); + await until( + () => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), GREEN), + 'the new source drawn', + ); + magenta.setSource({ + kind: 'image', + image: flat([...MAGENTA, 255]), + rectangle: box, + }); + lower.destroy(); + // Split: a left layer draws only left of the split position. + const width = engine.host.size().width; + const middle = screenOf(engine, 19.5, 10).x; + imagery.setSplitPosition(middle / width); + magenta.setSplit('left'); + const left = screenOf(engine, 19.25, 10); + const right = screenOf(engine, 19.75, 10); + const at = ({ x, y }) => { + const data = readRegion(Math.round(x) - 3, Math.round(y) - 3, 6, 6); + const mean = [0, 0, 0]; + for (let i = 0; i < data.length; i += 4) + for (let k = 0; k < 3; k++) mean[k] += data[i + k] / 36; + return mean; + }; + await until( + () => + nearColour(at(left), MAGENTA) && !nearColour(at(right), MAGENTA), + `the left split drawn (left ${show(at(left))}, right ${show(at(right))})`, + ); + magenta.setSplit('right'); + await until( + () => + !nearColour(at(left), MAGENTA) && nearColour(at(right), MAGENTA), + 'the right split drawn', + ); + magenta.setSplit('none'); + await until( + () => + nearColour(at(left), MAGENTA) && nearColour(at(right), MAGENTA), + 'the unsplit layer drawn', + ); + } finally { + imagery.setSplitPosition(0.5); + for (const layer of layers) layer.destroy(); + } + await until( + () => + [MAGENTA, GREEN, [0, 255, 255]].every( + (colour) => + !nearColour(meanAt(engine, readRegion, 19.5, 10, 6), colour), + ), + 'every layer gone', + ); + }, + ], + [ + 'imagery.createLayer: tile schemes, transforms, staged sources and raised layers are checked and kept', + async () => { + const magenta = { kind: 'image', image: flat([255, 0, 255, 255]) }; + const green = { kind: 'image', image: flat([0, 255, 0, 255]) }; + const template = { + kind: 'tiles', + url: 'https://tiles.invalid/{z}/{x}/{y}.png', + maxLevel: 3, + rectangle: box, + }; + throwsType( + () => + imagery.createLayer({ + id: 'contract-bad', + source: { ...template, scheme: 'polar' }, + }), + 'an unknown tile scheme', + ); + throwsType( + () => + imagery.createLayer({ + id: 'contract-bad', + source: { ...template, transform: 'mask' }, + }), + 'a transform that is not a function', + ); + throwsType( + () => + imagery.createLayer({ + id: 'contract-bad', + source: magenta, + raise: -1, + }), + 'a raise below the ellipsoid', + ); + throwsType( + () => + imagery.createLayer({ + id: 'contract-bad', + source: template, + raise: 6000, + }), + 'a raised tiles layer', + ); + ensure(!listed('contract-bad'), 'a refused layer is listed'); + const geographic = imagery.createLayer({ + id: 'contract-imagery-geographic', + source: { ...template, scheme: 'geographic', transform: (i) => i }, + }); + const staged = imagery.createLayer({ + id: 'contract-imagery-staged', + source: { ...magenta, rectangle: box }, + }); + const raisedLayer = imagery.createLayer({ + id: 'contract-imagery-raised', + source: { ...magenta, rectangle: box }, + raise: 6000, + }); + // A promise's outcome, read frame by frame. + const outcome = (promise) => { + const result = { value: undefined }; + promise.then((value) => (result.value = value)); + return result; + }; + try { + ensure( + geographic.source().scheme === 'geographic' && + typeof geographic.source().transform === 'function', + 'the tile scheme or transform is not kept', + ); + ensure( + listed('contract-imagery-raised') && + raisedLayer.raise() === 6000 && + staged.raise() === null, + `raise(): ${raisedLayer.raise()}, ${staged.raise()}`, + ); + throwsType( + () => staged.setDetail({ image: flat([0, 0, 0, 255]) }), + 'a detail on a draped layer', + ); + throwsType( + () => raisedLayer.setSource(template), + 'tiles on a raised layer', + ); + throwsType( + () => + raisedLayer.setDetail({ + image: flat([0, 0, 0, 255]), + rectangle: { west: 170, south: 0, east: -170, north: 1 }, + }), + 'a detail across the antimeridian', + ); + // Staged: the drawn source stays until the new one is drawn. + await until(() => staged.ready(), 'the staged layer ready'); + const first = outcome( + staged.setSource({ ...green, rectangle: box }, { staged: true }), + ); + ensure( + staged.source().image === magenta.image || + first.value === true || + staged.source().image === green.image, + 'a staged source replaced nothing', + ); + await until(() => first.value !== undefined, 'the staged source'); + ensure(first.value === true, 'the staged source failed'); + ensure( + staged.source().image === green.image && !staged.staging(), + 'the staged source is not the layer source', + ); + // A newer source wins; an aborted one leaves the drawn source. + const superseded = outcome( + staged.setSource({ ...magenta, rectangle: box }, { staged: true }), + ); + const winner = outcome( + staged.setSource( + { kind: 'image', image: flat([0, 0, 255, 255]), rectangle: box }, + { staged: true }, + ), + ); + await until( + () => superseded.value !== undefined && winner.value !== undefined, + 'the superseded and the newer staged sources', + ); + ensure( + superseded.value === false && winner.value === true, + `superseded ${superseded.value}, newer ${winner.value}`, + ); + const drawnImage = staged.source().image; + const controller = new AbortController(); + const aborted = outcome( + staged.setSource( + { ...magenta, rectangle: box }, + { staged: true, signal: controller.signal }, + ), + ); + controller.abort(); + await until(() => aborted.value !== undefined, 'the aborted source'); + ensure( + aborted.value === false && staged.source().image === drawnImage, + 'an aborted staged source replaced the drawn one', + ); + // A new credit alone keeps the source's pixels. + geographic.setSource({ + ...template, + scheme: 'geographic', + transform: geographic.source().transform, + credit: 'Contract credit', + }); + ensure( + geographic.source().credit === 'Contract credit', + 'the credit did not change', + ); + // A hidden layer takes a staged source at once. + staged.show(false); + const hidden = outcome( + staged.setSource({ ...green, rectangle: box }, { staged: true }), + ); + await until(() => hidden.value !== undefined, 'the hidden staged'); + ensure(hidden.value === true, 'a hidden layer refused a stage'); + // A raised layer's detail. + const west = { ...box, east: 19.5 }; + await until(() => raisedLayer.ready(), 'the raised layer drawn'); + const detail = outcome( + raisedLayer.setDetail({ + image: flat([0, 255, 0, 255]), + rectangle: west, + }), + ); + await until(() => detail.value !== undefined, 'the detail drawn'); + ensure( + detail.value === true && + raisedLayer.detail()?.rectangle.east === 19.5, + 'the detail is not kept', + ); + await raisedLayer.setDetail(null); + ensure(raisedLayer.detail() === null, 'the detail is not cleared'); + } finally { + geographic.destroy(); + staged.destroy(); + raisedLayer.destroy(); + } + ensure(!listed('contract-imagery-raised'), 'a raised layer is listed'); + }, + ], + [ + 'imagery.createLayer: geographic tiles through a transform, staged and raised layers draw (pixels)', + async () => { + if (!readRegion) return; + await nadirView(1e6); + + await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded'); + await settle(4); + // Every tile, whatever the app serves, painted magenta by the + // transform: geographic tiles over the box only. + const painted = (image) => { + const size = image.width; + const data = new Uint8Array(size * size * 4); + for (let i = 0; i < data.length; i += 4) + data.set([255, 0, 255, 255], i); + if (typeof document === 'undefined') return image; + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = size; + const context = canvas.getContext('2d'); + const pixels = context.createImageData(size, size); + pixels.data.set(data); + context.putImageData(pixels, 0, 0); + return canvas; + }; + const layers = []; + try { + const tiles = imagery.createLayer({ + id: 'contract-paint-geographic', + source: { + kind: 'tiles', + url: '/icons/icon-32.png?z={z}&x={x}&y={y}', + tileSize: 32, + maxLevel: 4, + scheme: 'geographic', + transform: painted, + rectangle: box, + }, + }); + layers.push(tiles); + await until( + () => nearColour(meanAt(engine, readRegion, 19.5, 10, 5), MAGENTA), + `the transformed tiles drawn (${show(meanAt(engine, readRegion, 19.5, 10, 5))})`, + ); + ensure( + !nearColour(meanAt(engine, readRegion, 21, 10, 5), MAGENTA), + 'the tiles spill outside their rectangle', + ); + // Staged: green replaces the magenta tiles once it is drawn. + const staged = tiles.setSource( + { kind: 'image', image: flat([...GREEN, 255]), rectangle: box }, + { staged: true }, + ); + let swapped; + staged.then((value) => (swapped = value)); + await until(() => swapped !== undefined, 'the staged image'); + ensure(swapped === true, 'the staged image failed'); + await until( + () => nearColour(meanAt(engine, readRegion, 19.5, 10, 5), GREEN), + `the staged image drawn (${show(meanAt(engine, readRegion, 19.5, 10, 5))})`, + ); + tiles.destroy(); + // Raised 6 km: drawn over the box (a little parallax at 1000 km), + // a detail in place of its west half. + const raisedLayer = imagery.createLayer({ + id: 'contract-paint-raised', + source: { + kind: 'image', + image: flat([...MAGENTA, 255]), + rectangle: box, + }, + raise: 6000, + }); + layers.push(raisedLayer); + await until( + () => nearColour(meanAt(engine, readRegion, 19.6, 10, 5), MAGENTA), + `the raised layer drawn (${show(meanAt(engine, readRegion, 19.6, 10, 5))})`, + ); + raisedLayer.setDetail({ + image: flat([...GREEN, 255]), + rectangle: { ...box, east: 19.5 }, + }); + await until( + () => + nearColour(meanAt(engine, readRegion, 19.25, 10, 5), GREEN) && + nearColour(meanAt(engine, readRegion, 19.75, 10, 5), MAGENTA), + `the detail drawn (west ${show(meanAt(engine, readRegion, 19.25, 10, 5))}, east ${show(meanAt(engine, readRegion, 19.75, 10, 5))})`, + ); + raisedLayer.setDetail(null); + await until( + () => nearColour(meanAt(engine, readRegion, 19.25, 10, 5), MAGENTA), + 'the detail cleared', + ); + } finally { + for (const layer of layers) layer.destroy(); + } + await until( + () => + !nearColour(meanAt(engine, readRegion, 19.5, 10, 5), MAGENTA) && + !nearColour(meanAt(engine, readRegion, 19.5, 10, 5), GREEN), + 'every layer gone', + ); + }, + ], + ]; +} + +export function imageryBehaviourChecks( + engine, + { until, settle, nadirView, readRegion, resize, makeCanvas }, +) { + const source = { + kind: 'image', + image: { width: 1, height: 1, data: new Uint8Array([51, 153, 102, 255]) }, + rectangle: { west: 19, south: 9, east: 21, north: 11 }, + }; + const checks = [ + [ + 'imagery.createLayer: an imagery pixel draws as asked within 10 per channel', + async () => { + await nadirView(10000); + const layer = engine.imagery.createLayer({ + id: 'contract-imagery-colour', + source, + }); + try { + await until(() => layer.ready(), 'imagery colour ready'); + if (readRegion) + await until( + () => + nearColour( + meanAt(engine, readRegion, 20, 10, 2), + [51, 153, 102], + 10, + ), + 'imagery colour within 10 per channel', + ); + } finally { + layer.destroy(); + } + }, + ], + ]; + if (resize) + checks.push([ + 'imagery.createLayer: resize replans the visible tiles', + async () => { + const previous = engine.host.size(); + let layer; + const drawn = () => { + const { width, height } = engine.host.size(); + return [0.1, 0.9].every((fraction) => { + const pixel = readRegion( + Math.round(width * fraction), + Math.round(height / 2), + 1, + 1, + ); + return nearColour( + Array.from(pixel).slice(0, 3), + [51, 153, 102], + 10, + ); + }); + }; + try { + await resize(400, 800); + await nadirView(1e6); + layer = engine.imagery.createLayer({ + id: 'contract-imagery-resize', + source: makeCanvas + ? { + kind: 'tiles', + url: '/icons/icon-32.png?contract={z}/{x}/{y}', + tileSize: 32, + maxLevel: 8, + transform: () => { + const canvas = makeCanvas(32, 32); + const context = canvas.getContext('2d'); + context.fillStyle = '#339966'; + context.fillRect(0, 0, 32, 32); + return canvas; + }, + } + : source, + }); + await until(() => layer.ready(), 'imagery before resize'); + if (readRegion) await until(drawn, 'imagery drawn before resize'); + await resize(1280, 800); + await until(() => { + const { width, height } = engine.host.size(); + return width === 1280 && height === 800; + }, 'the resized canvas size'); + await settle(4); + await until(() => layer.ready(), 'imagery after resize'); + // A ready provider or transformed tile need not have drawn yet. + // The wider view must gain coverage on both sides, cached or new. + if (readRegion) await until(drawn, 'imagery drawn after resize'); + } finally { + layer?.destroy(); + await resize(previous.width, previous.height); + } + }, + ]); + return checks; +} diff --git a/src/globe/testing/contractSuite/imagery.test.mjs b/src/globe/testing/contractSuite/imagery.test.mjs new file mode 100644 index 000000000..0289b45ba --- /dev/null +++ b/src/globe/testing/contractSuite/imagery.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createFakeEngine } from '../fakeEngine.js'; +import { fakeContractOptions } from '../contractFixtures.js'; +import { createContractSuite } from './index.mjs'; + +for (const scenario of ['delayed', 'cached', 'missing left', 'missing right']) { + test(`imagery resize check: ${scenario} tiles`, async () => { + const engine = createFakeEngine(); + const previous = engine.host.size(); + const options = fakeContractOptions(engine); + const createLayer = engine.imagery.createLayer.bind(engine.imagery); + let transform; + let resized = false; + let frames = 0; + engine.imagery.createLayer = (input) => { + transform = input.source.transform; + transform(); + return createLayer(input); + }; + const check = createContractSuite(engine, { + ...options, + maxWaitFrames: 20, + makeCanvas: () => ({ getContext: () => ({ fillRect() {} }) }), + resize: async (width, height) => { + await options.resize(width, height); + resized = width === 1280 && height === 800; + frames = 0; + }, + waitFrames: async (count) => { + await options.waitFrames(count); + if (resized) { + frames += count; + if (frames === 1 && scenario !== 'cached') transform(); + } + }, + readRegion: (x, y, width, height) => { + const size = engine.host.size(); + assert.ok(x >= 0 && x < size.width); + assert.equal(y, size.height / 2); + assert.equal(width, 1); + assert.equal(height, 1); + let painted = true; + if (resized) { + if (scenario === 'delayed') painted = frames >= 12; + if (scenario === 'missing left') painted = x > size.width / 2; + if (scenario === 'missing right') painted = x < size.width / 2; + } + return new Uint8Array( + painted ? [51, 153, 102, 255] : [242, 239, 233, 255], + ); + }, + }).find(({ name }) => name.startsWith('imagery.createLayer: resize')); + try { + if (scenario.startsWith('missing')) + await assert.rejects( + check.run(), + /imagery drawn after resize did not happen within 20 frames/, + ); + else await check.run(); + assert.deepEqual(engine.host.size(), previous); + assert.deepEqual(engine.imagery.list(), []); + } finally { + engine.destroy(); + } + }); +} diff --git a/src/globe/testing/contractSuite/index.mjs b/src/globe/testing/contractSuite/index.mjs new file mode 100644 index 000000000..b5f4d2ac7 --- /dev/null +++ b/src/globe/testing/contractSuite/index.mjs @@ -0,0 +1,217 @@ +// Behavioural checks every GlobeEngine must pass. The suite is engine-agnostic +// and imports nothing from Node, so it runs in Node against the fake engine and +// in a headless browser against the Cesium and Navara engines: +// +// const checks = createContractSuite(engine, { waitFrames }); +// for (const { name, run } of checks) await run(); +// +// `waitFrames(count)` resolves after the engine has rendered `count` frames. +// Checks request renders themselves, so engines in request-render mode work. +// Each check sets the view it needs; checks may leave the camera anywhere. + +import { engineCapabilities } from '../../contract.js'; +import { + mapErrorChecks, + terrainRevisionChecks, + photorealChecks, + terrainChecks, + mapChecks, +} from './maps.mjs'; +import { + moverListChecks, + failedModelChecks, + spriteChecks, + moverChecks, +} from './movers.mjs'; +import { groundBehaviourChecks, groundChecks } from './ground.mjs'; +import { imageryBehaviourChecks, imageryChecks } from './imagery.mjs'; +import { clockLatchChecks, clockChecks } from './clock.mjs'; +import { + terrainSampleChecks, + surfaceChecks, + globePickChecks, +} from './surface.mjs'; +import { + renderBehaviourChecks, + renderChecks, + contactSonarChecks, + cloudCoverChecks, + bloomChecks, + styleChecks, +} from './render.mjs'; +import { + cameraBehaviourChecks, + poseChecks, + flightChecks, + swingChecks, + cameraChecks, + aspectChecks, + followChecks, +} from './camera.mjs'; +import { cartesian2, cartesian3, math, ensure } from './helpers.mjs'; +import { + shapeChecks, + eventChecks, + creditChecks, + listenerChecks, + lifetimeChecks, +} from './events.mjs'; +import { lineChecks, dashChecks } from './lines.mjs'; +import { pickInputChecks, pointerChecks, inputChecks } from './input.mjs'; +import { meshChecks } from './meshes.mjs'; + +/** + * @param {import('../../contract.js').GlobeEngine} engine + * @param {{ waitFrames: (count: number) => Promise, maxWaitFrames?: number, + * mapSources?: { imagery: object[], photoreal: object }, + * readRegion?: (x: number, y: number, width: number, height: number) => Uint8ClampedArray, + * makeCanvas?: (width: number, height: number) => object, + * resize?: (width: number, height: number) => Promise, + * fixtures?: object, + * pointer?: { click: (x: number, y: number) => Promise, move: (x: number, y: number) => Promise, + * drag?: (x: number, y: number, endX: number, endY: number) => Promise } }} options + * `mapSources` (two imagery sources and a photoreal one the engine can + * show) adds the maps checks; they leave photoreal tiles hidden. + * `readRegion` (RGBA bytes of the last drawn frame in a CSS-pixel box) and + * `makeCanvas` (a 2D canvas) add the pixel checks of drawn objects. + * `pointer` (a real left click and pointer move at a canvas position) + * adds the pick checks; `drag` adds the flight gesture check. + * `fixtures` supplies deterministic provider inputs; `resize` exercises + * imagery replanning. waitFrames must observe frames without requesting + * them, and reject if no requested frame arrives. + * @returns {{ name: string, run: () => Promise }[]} + */ +export function createContractSuite( + engine, + { + waitFrames, + maxWaitFrames = 240, + mapSources = null, + readRegion = null, + makeCanvas = null, + pointer = null, + fixtures = null, + resize = null, + }, +) { + if (typeof waitFrames !== 'function') + throw new TypeError('createContractSuite needs a waitFrames(count) helper'); + const { camera } = engine; + + const settle = async (count = 2) => { + for (let index = 0; index < count; index++) { + engine.render.request(); + await waitFrames(1); + } + }; + const until = async (predicate, label) => { + for (let frame = 0; frame < maxWaitFrames; frame++) { + if (predicate()) return; + await settle(1); + } + ensure( + predicate(), + `${label} did not happen within ${maxWaitFrames} frames`, + ); + }; + + // A view 1000 km above (20°E, 10°N) looking straight down, heading north. + const nadirView = async (height = 1e6) => { + camera.setView({ + destination: cartesian3.fromDegrees(20, 10, height), + orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }, + }); + await settle(); + }; + const centre = () => { + const { width, height } = engine.host.size(); + return cartesian2.create(width / 2, height / 2); + }; + + const capabilities = engineCapabilities(engine); + const context = { + settle, + until, + nadirView, + centre, + readRegion, + makeCanvas, + pointer, + waitFrames, + fixtures, + resize, + mapSources, + }; + const checks = [ + ...shapeChecks(engine, context), + ...poseChecks(engine, context), + ...eventChecks(engine, context), + ...clockChecks(engine, context), + ...spriteChecks(engine, context), + ...lineChecks(engine, context), + ...pickInputChecks(engine, context), + ...dashChecks(engine, context), + ...moverChecks(engine, context), + ...renderChecks(engine, context), + ...flightChecks(engine, context), + ...surfaceChecks(engine, context), + ...pointerChecks(engine, context), + ...swingChecks(engine, context), + ...inputChecks(engine, context), + ...creditChecks(engine, context), + ...photorealChecks(engine, context), + ...listenerChecks(engine, context), + ...cameraChecks(engine, context), + ...lifetimeChecks(engine, context), + ...aspectChecks(engine, context), + ...globePickChecks(engine, context), + ...terrainChecks(engine, context), + ...followChecks(engine, context), + ]; + checks.push( + ...meshChecks(engine, { settle, until, nadirView, readRegion, makeCanvas }), + ...groundChecks(engine, { settle, until, nadirView, readRegion, pointer }), + ...imageryChecks(engine, { settle, until, nadirView, readRegion }), + ); + if (mapSources?.photoreal && mapSources.imagery?.length >= 2) + checks.push(...mapChecks(engine.maps, mapSources)); + if (capabilities.visualStyles) + checks.push(...styleChecks(engine.render, settle)); + if (capabilities.bloom) checks.push(...bloomChecks(engine.render, settle)); + if (capabilities.cloudCover) + checks.push(...cloudCoverChecks(engine.render, settle)); + if (capabilities.contactSonar) + checks.push(...contactSonarChecks(engine.render, settle)); + checks.push( + ...cameraBehaviourChecks(engine, context), + ...renderBehaviourChecks(engine, context), + ...terrainSampleChecks(engine, context), + ...terrainRevisionChecks(engine, context), + ...clockLatchChecks(engine, context), + ...groundBehaviourChecks(engine, context), + ...imageryBehaviourChecks(engine, context), + ...mapErrorChecks(engine, context), + ...moverListChecks(engine), + ); + if (fixtures?.failModel || makeCanvas) + checks.push(...failedModelChecks(engine, context)); + return checks.map(([name, run]) => ({ name, run })); +} + +/** Runs every check in order; never throws for a failing check. */ +export async function runContractSuite(engine, options) { + const results = []; + for (const { name, run } of createContractSuite(engine, options)) { + try { + await run(); + results.push({ name, ok: true }); + } catch (error) { + results.push({ + name, + ok: false, + error: error instanceof Error ? error : new Error(String(error)), + }); + } + } + return results; +} diff --git a/src/globe/testing/contractSuite/input.mjs b/src/globe/testing/contractSuite/input.mjs new file mode 100644 index 000000000..906d9bac7 --- /dev/null +++ b/src/globe/testing/contractSuite/input.mjs @@ -0,0 +1,260 @@ +import { cartesian3, ensure } from './helpers.mjs'; +import { INPUT_TYPES, MODIFIERS } from '../../contract.js'; + +export function pickInputChecks( + engine, + { settle, until, nadirView, makeCanvas, pointer }, +) { + const { camera } = engine; + return [ + [ + 'input.on: picks on click and move payloads, drill, lines', + async () => { + await nadirView(1e6); + const at = (lon, lat, height = 20_000) => + cartesian3.fromDegrees(lon, lat, height); + const atlasCanvas = makeCanvas + ? makeCanvas(16, 16) + : { width: 16, height: 16 }; + if (makeCanvas) { + const context = atlasCanvas.getContext('2d'); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, 16, 16); + } + const set = engine.movers.createSet({ + id: 'contract-picks', + kind: 'sprite', + capacity: 3, + order: 100, + depthTest: 'always', + pickable: true, + sprite: { + atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } }, + sizePx: 24, + }, + }); + const quiet = engine.movers.createSet({ + id: 'contract-quiet', + kind: 'sprite', + capacity: 1, + order: 100, + depthTest: 'always', + sprite: { + atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } }, + sizePx: 24, + }, + }); + const line = engine.lines.createLine({ + id: 'contract-pick-line', + widthPx: 8, + color: [1, 1, 0, 1], + pickId: 'line-1', + }); + const clicks = []; + const moves = []; + const removers = [ + engine.input.on('click', (event) => clicks.push(event), { + pick: true, + drill: 8, + pickWindow: { width: 9, height: 9 }, + }), + engine.input.on('move', (event) => moves.push(event), { + pick: true, + }), + ]; + try { + const a = at(20, 10); + const b = at(21, 10.5); + set.set(0, { position: a, pickId: 'a', color: [1, 0, 1, 1] }); + set.set(1, { position: b, pickId: 'b', color: [1, 0, 1, 1] }); + set.set(2, { position: b, pickId: 'c', color: [1, 0, 1, 1] }); + set.setCount(3); + set.commit(); + line.setPositions([at(19, 9), at(21, 9)]); + const unpickable = at(19.5, 11); + quiet.set(0, { position: unpickable, pickId: 'quiet' }); + quiet.setCount(1); + quiet.commit(); + await settle(4); + if (!pointer) return; + const screen = (position) => engine.projector.worldToWindow(position); + const clickAt = async (position) => { + const before = clicks.length; + const { x, y } = screen(position); + await pointer.click(x, y); + await until(() => clicks.length > before, 'a click payload'); + return clicks[clicks.length - 1]; + }; + const onA = await clickAt(a); + ensure( + onA.picked?.id === 'a' && onA.picked?.owner === 'contract-picks', + `a click on sprite a picked ${JSON.stringify(onA.picked?.id)} of ${onA.picked?.owner}`, + ); + ensure(onA.picked.instance === 0, `instance ${onA.picked.instance}`); + const empty = await clickAt(at(18, 12)); + ensure( + empty.picked === undefined || empty.picked.id === undefined, + `a click on the map picked ${JSON.stringify(empty.picked?.id)}`, + ); + const onB = await clickAt(b); + const drilled = (onB.drilled ?? []).map(({ id }) => id); + ensure( + drilled.includes('b') && drilled.includes('c'), + `two sprites in one place drilled ${JSON.stringify(drilled)}`, + ); + const onQuiet = await clickAt(unpickable); + ensure( + onQuiet.picked?.owner !== 'contract-quiet' && + !(onQuiet.drilled ?? []).some( + ({ owner }) => owner === 'contract-quiet', + ), + `a pickable: false sprite was picked (${JSON.stringify(onQuiet.picked?.id)})`, + ); + const linePosition = at(20, 9); + const lineCameraBefore = cartesian3.clone( + engine.camera.pose().position, + ); + const onLine = await clickAt(linePosition); + ensure( + onLine.picked?.id === 'line-1', + `a click on the line picked ${JSON.stringify(onLine.picked?.id)}; ${ + onLine.picked?.id === 'line-1' + ? '' + : JSON.stringify({ + event: onLine.position, + projectedNow: screen(linePosition), + directNow: engine.picker.pick(onLine.position)?.id, + drilled: (onLine.drilled ?? []).map(({ id }) => id), + cameraMovedM: cartesian3.distance( + lineCameraBefore, + engine.camera.pose().position, + ), + }) + }`, + ); + const target = screen(a); + const movesBefore = moves.length; + await pointer.move(target.x + 1, target.y); + await until( + () => moves.length > movesBefore && moves.at(-1).picked?.id === 'a', + 'a hover payload over sprite a', + ); + } finally { + for (const remove of removers) remove(); + set.destroy(); + quiet.destroy(); + line.destroy(); + } + }, + ], + ]; +} + +export function pointerChecks(engine, { settle, pointer }) { + const { camera } = engine; + return [ + [ + 'input.acquirePointer: exclusive subscriptions, release and camera gestures', + async () => { + const input = engine.input; + const before = input.cameraInputsEnabled(); + const claim = input.acquirePointer(); + ensure(claim, 'no pointer claim'); + const removers = []; + let ambient = 0, + owned = 0; + try { + ensure(input.acquirePointer() === null, 'a second claim succeeded'); + ensure( + input.cameraInputsEnabled() === before, + 'claim disabled camera gestures', + ); + removers.push(input.on('click', () => ambient++)); + removers.push(input.on('click', () => owned++, { pointer: claim })); + if (pointer) { + await pointer.click(100, 100); + await settle(); + ensure(owned === 1 && ambient === 0, 'exclusive click leaked'); + } + claim.release(); + claim.release(); + if (pointer) { + await pointer.click(100, 100); + await settle(); + ensure( + owned === 1 && ambient === 1, + 'released click stayed exclusive', + ); + } + const next = input.acquirePointer(); + ensure(next, 'release did not free the pointer'); + claim.release(); + ensure( + input.acquirePointer() === null, + 'stale release freed its successor', + ); + next.release(); + } finally { + claim.release(); + for (const remove of removers) remove(); + } + }, + ], + ]; +} + +export function inputChecks(engine) { + const { camera } = engine; + return [ + [ + 'input.on: every type subscribes and unsubscribes; camera inputs toggle', + async () => { + for (const type of INPUT_TYPES) { + for (const modifier of [undefined, ...MODIFIERS]) { + const off = engine.input.on(type, () => {}, { modifier }); + ensure( + typeof off === 'function', + `input.on('${type}', ${modifier}) returned no remover`, + ); + off(); + off(); + } + } + }, + ], + [ + 'input.suspendCameraInputs: counted, the last resume restores the state before the first', + async () => { + const { input } = engine; + const before = input.cameraInputsEnabled(); + ensure( + typeof before === 'boolean', + `cameraInputsEnabled() returned ${before}`, + ); + const outer = input.suspendCameraInputs(); + const inner = input.suspendCameraInputs(); + try { + ensure( + typeof outer === 'function' && typeof inner === 'function', + 'suspendCameraInputs returned no resume', + ); + ensure(!input.cameraInputsEnabled(), 'on under a suspension'); + inner(); + inner(); + ensure( + !input.cameraInputsEnabled(), + 'an inner resume lifted the outer suspension', + ); + outer(); + ensure( + input.cameraInputsEnabled() === before, + 'last resume did not restore inputs', + ); + } finally { + inner(); + outer(); + } + }, + ], + ]; +} diff --git a/src/globe/testing/contractSuite/lines.mjs b/src/globe/testing/contractSuite/lines.mjs new file mode 100644 index 000000000..e2f74d98a --- /dev/null +++ b/src/globe/testing/contractSuite/lines.mjs @@ -0,0 +1,299 @@ +import { cartesian3, math, show, ensure, near, along } from './helpers.mjs'; + +export function lineChecks( + engine, + { settle, until, nadirView, centre, readRegion, pointer }, +) { + return [ + [ + 'lines.createLine: a line draws between its projections, turns with its model matrix and dims where hidden', + async () => { + await nadirView(1e6); + // 20 km up: above any ground the scene draws. + const at = (lon, lat, height = 20_000) => + cartesian3.fromDegrees(lon, lat, height); + const line = engine.lines.createLine({ + id: 'contract-line', + widthPx: 8, + color: [1, 0, 1, 1], + }); + const hidden = engine.lines.createLine({ + id: 'contract-line-hidden', + widthPx: 10, + color: [1, 0, 1, 1], + depthFailAlpha: 0.6, + }); + try { + line.setPositions([at(19, 9), at(20, 9), at(21, 9)]); + ensure( + engine.lines + .list() + .some(({ id, points }) => id === 'contract-line' && points === 3), + 'lines.list() does not show the line', + ); + if (!readRegion) return; + // Magenta-leaning pixels (red and blue over green; a dimmed copy + // over a light map stays above 30) near a point. + // The strongest magenta lean near a point (for dimmer-than). + const strength = (position) => { + const screen = engine.projector.worldToWindow(position); + const data = readRegion( + Math.round(screen.x) - 6, + Math.round(screen.y) - 6, + 12, + 12, + ); + let best = -255; + for (let i = 0; i < data.length; i += 4) + best = Math.max( + best, + Math.min(data[i], data[i + 2]) - data[i + 1], + ); + return best; + }; + const magentaAt = (position) => { + const screen = engine.projector.worldToWindow(position); + if (!screen) return 0; + const data = readRegion( + Math.round(screen.x) - 6, + Math.round(screen.y) - 6, + 12, + 12, + ); + let count = 0; + for (let i = 0; i < data.length; i += 4) + if ( + Math.min(data[i], data[i + 2]) - data[i + 1] > 30 && + data[i] > 120 + ) + count++; + return count; + }; + await until( + () => magentaAt(at(19.5, 9)) > 10 && magentaAt(at(20.5, 9)) > 10, + 'the line drawn along both segments', + ); + ensure(magentaAt(at(19.5, 10.5)) === 0, 'line pixels off the line'); + // Turned 1° about the Earth's axis: the line moves 1° east. + const turn = (radians) => { + const c = Math.cos(radians); + const s = Math.sin(radians); + return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + }; + line.setModelMatrix(turn(math.toRadians(1))); + await until( + () => magentaAt(at(21.5, 9)) > 10 && magentaAt(at(19.25, 9)) === 0, + 'the turned line drawn 1° east', + ); + const shownStrength = strength(at(20.5, 9)); + line.show(false); + await until(() => magentaAt(at(20.5, 9)) === 0, 'the line hidden'); + // Round the back of the Earth along the great circle west of the + // view centre, out to a visible end past the western horizon. The + // far side, behind the Earth's centre, shows only by its dimmed + // depth-fail copy. Cesium culls a line the globe hides whole, and + // draws in full what lies under the horizon but before the centre. + const west = (degrees) => { + const t = (degrees * Math.PI) / 180; + const [lon0, lat0] = [(20 * Math.PI) / 180, (10 * Math.PI) / 180]; + const [x, y, z] = [ + Math.cos(lat0) * Math.cos(lon0) * Math.cos(t) + + Math.sin(lon0) * Math.sin(t), + Math.cos(lat0) * Math.sin(lon0) * Math.cos(t) - + Math.cos(lon0) * Math.sin(t), + Math.sin(lat0) * Math.cos(t), + ]; + return at( + (Math.atan2(y, x) * 180) / Math.PI, + (Math.asin(z) * 180) / Math.PI, + ); + }; + const deep = () => west(150); + hidden.setPositions([200, 150, 100, 50, 20].map(west)); + await until( + () => magentaAt(deep()) > 10, + 'the hidden line drawn by its depth-fail copy', + ); + const hiddenStrength = strength(deep()); + ensure( + hiddenStrength < shownStrength, + `the depth-fail copy (${hiddenStrength}) is not dimmer than the line (${shownStrength})`, + ); + hidden.destroy(); + await until(() => magentaAt(deep()) === 0, 'the destroyed line gone'); + } finally { + line.destroy(); + hidden.destroy(); + } + ensure( + !engine.lines.list().some(({ id }) => id.startsWith('contract-line')), + 'a destroyed line is still listed', + ); + }, + ], + [ + 'lines.createLine: batched segments stay disconnected and retain individual picks', + async () => { + await nadirView(1e6); + const at = (lon, lat) => cartesian3.fromDegrees(lon, lat, 20_000); + const id = 'contract-segments'; + const batch = engine.lines.createSegments({ + id, + widthPx: 10, + color: [1, 0, 1, 1], + }); + const screen = (lon, lat) => + engine.projector.worldToWindow(at(lon, lat)); + const pick = (lon, lat) => engine.picker.pick(screen(lon, lat)); + const magenta = (lon, lat) => { + const p = screen(lon, lat); + const pixels = readRegion( + Math.round(p.x) - 5, + Math.round(p.y) - 5, + 10, + 10, + ); + let count = 0; + for (let i = 0; i < pixels.length; i += 4) + if (Math.min(pixels[i], pixels[i + 2]) - pixels[i + 1] > 30) + count++; + return count; + }; + try { + batch.replace([ + { positions: [at(19, 9.5), at(19, 10.5)], pickId: 'west' }, + { + positions: [at(21, 9.5), at(21, 10.5)], + widthPx: 12, + pickId: 'east', + }, + { positions: [at(20, 10.8), at(20, 11.2)] }, + ]); + batch.commit(); + ensure( + engine.lines.list().find((entry) => entry.id === id)?.points === 6, + 'batch endpoints', + ); + if (!pointer && !readRegion) return; + await until( + () => pick(19, 10)?.id === 'west' && pick(21, 10)?.id === 'east', + 'both segment picks', + ); + ensure(pick(19, 10)?.owner === id, 'batch pick owner'); + ensure( + pick(20, 11)?.owner !== id, + 'segment without a pick id is ignored', + ); + ensure(pick(20, 10)?.owner !== id, 'no connecting segment pick'); + if (readRegion) { + await until( + () => magenta(19, 10) > 10 && magenta(21, 10) > 10, + 'both segments drawn', + ); + ensure(magenta(20, 10) === 0, 'no connecting segment pixels'); + } + batch.show(false); + await settle(3); + ensure(pick(19, 10)?.owner !== id, 'hidden batch is not picked'); + batch.replace([ + { positions: [at(21, 9.5), at(21, 10.5)], pickId: 'replacement' }, + ]); + batch.commit(); + await settle(3); + ensure(pick(21, 10)?.owner !== id, 'replacement stays hidden'); + batch.show(true); + await until( + () => pick(21, 10)?.id === 'replacement', + 'replacement pick', + ); + ensure(pick(19, 10)?.owner !== id, 'removed segment is not picked'); + batch.replace([]); + batch.commit(); + await settle(3); + ensure(pick(21, 10)?.owner !== id, 'empty batch is not picked'); + } finally { + batch.destroy(); + batch.destroy(); + } + ensure( + !engine.lines.list().some((entry) => entry.id === id), + 'batch released', + ); + }, + ], + ]; +} + +export function dashChecks(engine, { until, nadirView, readRegion }) { + return [ + [ + 'lines.createLine: pixel dashes are regular, in CSS pixels', + async () => { + if (!readRegion) return; + await nadirView(1e6); + const line = engine.lines.createLine({ + id: 'contract-dashes', + widthPx: 6, + color: [1, 0, 1, 1], + dash: { lengthPx: 16, gapPx: 16 }, + }); + try { + const a = cartesian3.fromDegrees(19, 9, 20_000); + const b = cartesian3.fromDegrees(21, 9, 20_000); + line.setPositions([a, b]); + const sa = engine.projector.worldToWindow(a); + const sb = engine.projector.worldToWindow(b); + // On/off runs (CSS px) along the middle of the line. + const runs = () => { + const left = Math.round(Math.min(sa.x, sb.x)) + 4; + const width = Math.round(Math.abs(sb.x - sa.x)) - 8; + const y = Math.round((sa.y + sb.y) / 2); + const data = readRegion(left, y, width, 1); + const on = []; + const off = []; + let current = null; + let length = 0; + for (let x = 0; x < width; x++) { + const i = x * 4; + const lit = + Math.min(data[i], data[i + 2]) - data[i + 1] > 60 && + data[i] > 150; + if (lit === current) length++; + else { + if (current !== null) (current ? on : off).push(length); + current = lit; + length = 1; + } + } + // The first and last runs are cut by the window. + return { on: on.slice(1), off: off.slice(1) }; + }; + const median = (values) => + [...values].sort((x, y) => x - y)[Math.floor(values.length / 2)]; + await until(() => runs().on.length >= 3, 'dashes drawn'); + const { on, off } = runs(); + const spread = (values) => Math.max(...values) - Math.min(...values); + // What each engine draws for 16/16, for the harness to compare + // across device pixel ratios (Cesium's PolylineDash is the + // reference; Navara's must not change with the ratio). + globalThis.__qaDashes = { + dash: median(on), + gap: median(off), + period: median(on) + median(off), + }; + ensure( + on.length >= 3 && spread(on) <= 2 && spread(off) <= 2, + `irregular dashes: runs ${JSON.stringify(on)} / ${JSON.stringify(off)}`, + ); + if (engine.kind === 'navara') + ensure( + Math.abs(median(on) - 16) <= 2 && Math.abs(median(off) - 16) <= 2, + `dash ${median(on)} px, gap ${median(off)} px (16/16 asked)`, + ); + } finally { + line.destroy(); + } + }, + ], + ]; +} diff --git a/src/globe/testing/contractSuite/maps.mjs b/src/globe/testing/contractSuite/maps.mjs new file mode 100644 index 000000000..ed10d398f --- /dev/null +++ b/src/globe/testing/contractSuite/maps.mjs @@ -0,0 +1,325 @@ +import { createEngineEvent, engineCapabilities } from '../../contract.js'; +import { MapSourceController } from '../../../maps/controller.js'; +import { ensure } from './helpers.mjs'; + +export function photorealChecks(engine) { + return [ + [ + 'maps.setPhotorealTiles: photoreal is inactive', + async () => { + engine.maps.setPhotorealTiles(null); + ensure( + engine.maps.photorealActive() === false, + 'photoreal still active', + ); + }, + ], + ]; +} + +export function terrainChecks(engine) { + return [ + [ + 'maps.setTerrain: keyless terrain resolves; an unknown kind rejects', + async () => { + await engine.maps.setTerrain({ id: 'keyless', kind: 'keyless' }); + let rejected = null; + try { + await engine.maps.setTerrain({ + id: 'no-such-terrain', + kind: 'no-such-terrain', + }); + } catch (error) { + rejected = error; + } + ensure(rejected !== null, 'an unknown terrain kind resolved'); + // The keyless terrain still applies after the rejection. + await engine.maps.setTerrain({ id: 'keyless', kind: 'keyless' }); + }, + ], + ]; +} + +export function mapChecks(maps, { imagery: [first, second], photoreal }) { + const shows = (baseImagery, tiles, label) => { + const current = maps.current(); + ensure( + current.baseImagery === baseImagery, + `${label}: base imagery is not the expected source`, + ); + ensure( + current.photoreal === tiles, + `${label}: photoreal tiles are not the expected source`, + ); + ensure( + maps.photorealActive() === (tiles !== null), + `${label}: photorealActive is ${maps.photorealActive()}`, + ); + }; + return [ + [ + 'maps.setBaseImagery: setPhotorealTiles(null) brings back the last base imagery', + async () => { + await maps.setBaseImagery(first); + shows(first, null, 'after setBaseImagery'); + await maps.setPhotorealTiles(photoreal); + shows(null, photoreal, 'tiles replace the base imagery'); + await maps.setPhotorealTiles(null); + shows(first, null, 'null'); + }, + ], + [ + 'maps.setBaseImagery: the last imagery or photoreal call decides what shows', + async () => { + await maps.setBaseImagery(first); + await Promise.all([ + maps.setBaseImagery(second), + maps.setPhotorealTiles(photoreal), + ]); + shows(null, photoreal, 'tiles last'); + await maps.setPhotorealTiles(null); + const restored = maps.current().baseImagery; + // The second source was superseded before or after it showed. + ensure( + restored === first || restored === second, + 'null: the last base imagery shown is not back', + ); + await Promise.all([ + maps.setPhotorealTiles(photoreal), + maps.setBaseImagery(second), + ]); + ensure( + maps.current().baseImagery === second, + 'imagery last: the second source is not on the globe', + ); + await maps.setPhotorealTiles(null); + shows(second, null, 'null after imagery'); + }, + ], + [ + 'maps.releasePhotorealTiles: a released source no longer shows or counts', + async () => { + await maps.setPhotorealTiles(photoreal); + await maps.setPhotorealTiles(null); + maps.releasePhotorealTiles(photoreal); + maps.releasePhotorealTiles(photoreal); + ensure(!maps.photorealActive(), 'photorealActive after release'); + ensure(maps.current().photoreal === null, 'current after release'); + }, + ], + ]; +} + +export function terrainRevisionChecks(engine, { fixtures }) { + return [ + [ + 'maps.terrainRevision: changes when setTerrain replaces the surface', + async () => { + await engine.maps.setTerrain({ kind: 'ellipsoid' }); + const before = engine.maps.terrainRevision(); + await engine.maps.setTerrain({ kind: 'keyless' }); + ensure( + engine.maps.terrainRevision() > before, + 'setTerrain kept the terrain revision', + ); + }, + ], + [ + 'maps.terrainRevision: changes when falling back to the ellipsoid', + async () => { + const maps = fixtures?.maps ?? engine.maps; + await maps.setTerrain(fixtures?.terrainSource ?? { kind: 'keyless' }); + const before = maps.terrainRevision(); + try { + if (fixtures?.fallbackTerrain) await fixtures.fallbackTerrain(); + else await maps.setTerrain({ kind: 'ellipsoid' }); + ensure( + maps.terrainRevision() > before, + 'fallback kept the terrain revision', + ); + } finally { + await maps.setTerrain(fixtures?.terrainSource ?? { kind: 'keyless' }); + } + }, + ], + ]; +} + +export function mapErrorChecks(engine, { fixtures }) { + if (!fixtures) return []; + const maps = fixtures.maps ?? engine.maps; + const supported = engineCapabilities({ maps }).tileErrors; + const checks = [ + [ + 'maps.errors: tileErrors describes provider error support', + async () => { + ensure( + supported === engineCapabilities(engine).tileErrors, + 'fixture capability differs from the engine', + ); + if (supported) + ensure( + typeof maps.errors.addEventListener === 'function', + 'missing error event', + ); + else + ensure( + maps.errors === undefined, + 'unsupported tile errors were advertised', + ); + }, + ], + ]; + if (supported) + checks.push([ + 'maps.errors: forwards source, kind and status and drops replaced providers', + async () => { + const first = { + kind: 'osm', + provider: { errorEvent: createEngineEvent() }, + }; + const second = { + kind: 'osm', + provider: { errorEvent: createEngineEvent() }, + }; + const seen = []; + const remove = maps.errors.addEventListener((error) => + seen.push(error), + ); + try { + await maps.setBaseImagery(first); + first.provider.errorEvent.raiseEvent({ error: { statusCode: 503 } }); + ensure( + seen.length === 1 && + seen[0].source === first && + seen[0].kind === 'tile' && + seen[0].status === 503, + 'tile error payload', + ); + await maps.setBaseImagery(second); + first.provider.errorEvent.raiseEvent({ statusCode: 401 }); + ensure(seen.length === 1, 'replaced imagery reported an error'); + second.provider.errorEvent.raiseEvent({ statusCode: 429 }); + ensure( + seen.length === 2 && + seen[1].source === second && + seen[1].status === 429, + 'replacement errors not observed', + ); + } finally { + remove(); + } + }, + ]); + if (supported) + checks.push([ + 'maps.errors: terrain failures follow the current provider', + async () => { + const first = { + kind: 'keyless', + provider: { errorEvent: createEngineEvent() }, + }; + const second = { + kind: 'ellipsoid', + provider: { errorEvent: createEngineEvent() }, + }; + first.create = () => ({ provider: first.provider }); + second.create = () => ({ provider: second.provider, fallback: true }); + const seen = []; + const remove = maps.errors.addEventListener((error) => + seen.push(error), + ); + try { + await maps.setTerrain(first); + first.provider.errorEvent.raiseEvent({ error: { statusCode: 503 } }); + ensure( + seen.length === 1 && + seen[0].source === first && + seen[0].kind === 'terrain' && + seen[0].status === 503, + 'terrain error payload', + ); + await maps.setTerrain(second); + first.provider.errorEvent.raiseEvent({ statusCode: 401 }); + ensure(seen.length === 1, 'replaced terrain reported an error'); + second.provider.errorEvent.raiseEvent({}); + ensure( + seen.length === 2 && + seen[1].source === second && + seen[1].kind === 'terrain' && + seen[1].status === undefined, + 'unknown terrain status', + ); + } finally { + remove(); + } + }, + ]); + for (const enabled of supported ? [true, false] : [false]) + checks.push([ + `maps.errors: tile failure fallback ${enabled ? 'runs with' : 'stays off without'} tileErrors`, + async () => { + const providers = [createEngineEvent(), createEngineEvent()]; + const sources = ['contract-primary', 'contract-fallback'].map( + (id, i) => ({ + descriptor: { id, label: id, kind: 'osm' }, + imagery: async () => ({ errorEvent: providers[i] }), + ...(i === 0 && { + tileFailureFallback: { + id: 'contract-fallback', + threshold: 2, + message: 'Map tiles unavailable', + }, + }), + }), + ); + const events = []; + const controlled = { + ...engine, + maps: enabled ? maps : { ...maps, errors: undefined }, + }; + const controller = new MapSourceController({ + engine: controlled, + registry: { defaultId: 'contract-primary', sources }, + initialStack: 'contract-primary', + onChange: (state) => events.push(state), + }); + try { + await controller.setStack('contract-primary'); + const source = maps.current().baseImagery; + if (enabled) { + maps.errors.raiseEvent({ source, kind: 'terrain', status: 503 }); + maps.errors.raiseEvent({ source: {}, kind: 'tile', status: 503 }); + ensure( + controller.getActiveId() === 'contract-primary', + 'unrelated errors switched the map', + ); + maps.errors.raiseEvent({ source, kind: 'tile', status: 503 }); + ensure( + controller.getActiveId() === 'contract-primary', + 'one tile error switched the map', + ); + maps.errors.raiseEvent({ source, kind: 'tile', status: 503 }); + } else { + providers[0].raiseEvent({ statusCode: 503 }); + providers[0].raiseEvent({ statusCode: 503 }); + } + for (let i = 0; i < 60; i++) await Promise.resolve(); + ensure( + controller.getActiveId() === + (enabled ? 'contract-fallback' : 'contract-primary'), + 'tile failure fallback ignored the capability', + ); + if (!enabled) + ensure( + controller.getState().lastError === null && + events.at(-1)?.status === 'ready', + 'unsupported errors prevented map readiness', + ); + } finally { + controller.destroy(); + } + }, + ]); + return checks; +} diff --git a/src/globe/testing/contractSuite/meshes.mjs b/src/globe/testing/contractSuite/meshes.mjs new file mode 100644 index 000000000..be5f0501b --- /dev/null +++ b/src/globe/testing/contractSuite/meshes.mjs @@ -0,0 +1,167 @@ +import { cartesian3, show, ensure } from './helpers.mjs'; + +export function meshChecks( + engine, + { settle, until, nadirView, readRegion, makeCanvas }, +) { + // Above any terrain under (20°E, 10°N), which stands about 400 m high. + const positions = [ + [19.98, 10.02, 3000], + [20.02, 10.02, 3000], + [19.98, 9.98, 3000], + [20.02, 9.98, 3000], + ].flatMap((p) => { + const at = cartesian3.fromDegrees(...p); + return [at.x, at.y, at.z]; + }); + const options = { + positions, + indices: [0, 2, 1, 2, 3, 1], + uv: [0, 1, 1, 1, 0, 0, 1, 0], + }; + const centre = () => { + const { width, height } = engine.host.size(); + return { x: width / 2, y: height / 2 }; + }; + const checks = [ + [ + 'meshes.createMesh: copied triangles, visibility, updates and lifetime', + async () => { + const id = 'contract-mesh'; + const source = [...positions]; + const mesh = engine.meshes.createMesh({ + ...options, + positions: source, + id, + show: false, + }); + try { + source.fill(0); + ensure(engine.meshes.list().includes(id), 'mesh is not listed'); + ensure(mesh.show() === false, 'mesh starts hidden'); + mesh.show(true); + ensure(mesh.show(), 'mesh did not show'); + mesh.update({ color: [0, 1, 1, 0.35], depthTest: 'always' }); + mesh.update({ + positions, + indices: options.indices, + depthTest: 'normal', + }); + await settle(); + let rejected = false; + try { + mesh.update({ indices: [0, 1, 99] }); + } catch { + rejected = true; + } + ensure(rejected, 'invalid indices were accepted'); + ensure(mesh.show(), 'invalid update changed visibility'); + } finally { + mesh.destroy(); + mesh.destroy(); + } + ensure(!engine.meshes.list().includes(id), 'destroyed mesh is listed'); + ensure(!mesh.show(), 'destroyed mesh is shown'); + }, + ], + [ + 'meshes.createMesh: picks use the live id and ignore hidden or unpickable triangles', + async () => { + await nadirView(10000); + const id = 'contract-mesh-pick'; + const mesh = engine.meshes.createMesh({ + ...options, + id, + pickId: 'first', + }); + try { + await until( + () => engine.picker.pick(centre())?.id === 'first', + 'mesh pick', + ); + ensure(engine.picker.pick(centre()).owner === id, 'mesh owner'); + mesh.update({ pickId: 'second' }); + await until( + () => engine.picker.pick(centre())?.id === 'second', + 'updated mesh pick', + ); + mesh.show(false); + await settle(); + ensure( + engine.picker.pick(centre())?.owner !== id, + 'hidden mesh picked', + ); + mesh.update({ show: true, pickId: null }); + await settle(); + ensure( + engine.picker.pick(centre())?.owner !== id, + 'unpickable mesh picked', + ); + } finally { + mesh.destroy(); + } + }, + ], + ]; + if (readRegion && makeCanvas) + checks.push([ + 'meshes.createMesh: image pixels update on the same canvas', + async () => { + await nadirView(10000); + const canvas = makeCanvas(8, 8); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ff0000'; + ctx.fillRect(0, 0, 8, 8); + const mesh = engine.meshes.createMesh({ + ...options, + id: 'contract-mesh-image', + image: canvas, + }); + const pixel = () => { + const { x, y } = centre(); + return readRegion(Math.floor(x), Math.floor(y), 1, 1); + }; + try { + await until(() => { + const p = pixel(); + return p[0] > p[1] + 50; + }, 'red mesh image'); + ctx.fillStyle = '#00ff00'; + ctx.fillRect(0, 0, 8, 8); + mesh.update({ image: canvas }); + await until(() => { + const p = pixel(); + return p[1] > p[0] + 50; + }, 'updated green mesh image'); + } finally { + mesh.destroy(); + } + }, + ]); + checks.push([ + 'meshes.createMesh: a single colour draws as asked within 10 per channel', + async () => { + await nadirView(10000); + const colour = [0, 1, 0, 1]; + const mesh = engine.meshes.createMesh({ + ...options, + id: 'contract-mesh-colour', + color: colour, + }); + try { + ensure(mesh.id === 'contract-mesh-colour', 'mesh id'); + if (!readRegion) return; + await until(() => { + const { x, y } = centre(); + const pixel = readRegion(Math.round(x), Math.round(y), 1, 1); + return colour + .slice(0, 3) + .every((value, i) => Math.abs(pixel[i] - value * 255) <= 10); + }, 'mesh colour within 10 per channel'); + } finally { + mesh.destroy(); + } + }, + ]); + return checks; +} diff --git a/src/globe/testing/contractSuite/movers.mjs b/src/globe/testing/contractSuite/movers.mjs new file mode 100644 index 000000000..6048d9887 --- /dev/null +++ b/src/globe/testing/contractSuite/movers.mjs @@ -0,0 +1,584 @@ +import { + cartesian3, + like, + MAGENTA, + show, + ensure, + near, + unit, +} from './helpers.mjs'; + +export function spriteChecks( + engine, + { settle, until, nadirView, centre, readRegion, makeCanvas }, +) { + return [ + [ + 'movers.createSet: three sprites draw at their projections, hide, recolour and go', + async () => { + await nadirView(1e6); + const atlasCanvas = makeCanvas + ? makeCanvas(16, 16) + : { width: 16, height: 16 }; + if (makeCanvas) { + const context = atlasCanvas.getContext('2d'); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, 16, 16); + } + const places = [ + [20, 10], + [21.5, 10], + [20, 11.5], + ].map(([lon, lat]) => cartesian3.fromDegrees(lon, lat, 1000)); + const set = engine.movers.createSet({ + id: 'contract-sprites', + kind: 'sprite', + capacity: 3, + order: 100, + depthTest: 'always', + sprite: { + atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } }, + sizePx: 24, + }, + }); + const MAGENTA = [1, 0, 1, 1]; + // Frames drawn in a window, with nothing asked of the engine. + const framesIn = async (ms) => { + let frames = 0; + const remove = engine.frames.postRender.addEventListener( + () => frames++, + ); + await new Promise((resolve) => setTimeout(resolve, ms)); + remove(); + return frames; + }; + const idleFrames = readRegion ? await framesIn(2000) : 0; + try { + places.forEach((position, index) => + set.set(index, { position, color: MAGENTA, cell: 'box' }), + ); + set.setCount(3); + set.commit(); + ensure(set.count() === 3, `count ${set.count()}`); + ensure( + typeof set.ready() === 'boolean' && + typeof set.ready(0) === 'boolean', + 'ready() and ready(index) answer booleans', + ); + ensure( + engine.movers + .list() + .some( + ({ id, count }) => id === 'contract-sprites' && count === 3, + ), + 'movers.list() does not show the set', + ); + if (!readRegion) return; + const screens = places.map((position) => + engine.projector.worldToWindow(position), + ); + // Pixels close to a colour in a 64 px box around a point: their + // count and centroid. + // A colour as drawn: within 40 of the asked one on every channel + // (both engines draw overlays after tone mapping, unlit). + + const find = (screen, colour) => { + const half = 32; + const x0 = Math.round(screen.x) - half; + const y0 = Math.round(screen.y) - half; + const data = readRegion(x0, y0, 2 * half, 2 * half); + let count = 0; + let sx = 0; + let sy = 0; + for (let y = 0; y < 2 * half; y++) + for (let x = 0; x < 2 * half; x++) { + const i = (y * 2 * half + x) * 4; + if (like(colour, data, i)) { + count++; + sx += x0 + x + 0.5; + sy += y0 + y + 0.5; + } + } + return count + ? { count, x: sx / count, y: sy / count } + : { count, x: NaN, y: NaN }; + }; + // The atlas and the upload may take a few frames. + await until( + () => find(screens[0], MAGENTA).count > 100, + 'the first sprite drawn', + ); + screens.forEach((screen, index) => { + const seen = find(screen, MAGENTA); + ensure( + seen.count > 300, + `sprite ${index}: ${seen.count} magenta pixels, a 24 px square has 576`, + ); + ensure( + Math.hypot(seen.x - screen.x, seen.y - screen.y) <= 2, + `sprite ${index} centred at ${seen.x.toFixed(1)}, ${seen.y.toFixed(1)}, projected ${screen.x.toFixed(1)}, ${screen.y.toFixed(1)}`, + ); + }); + set.set(1, { show: false }); + set.set(0, { color: [0, 1, 0, 1] }); + set.commit(); + await until( + () => find(screens[1], MAGENTA).count < 10, + 'the hidden sprite gone', + ); + ensure( + find(screens[0], [0, 1, 0, 1]).count > 300, + 'the recoloured sprite is not green', + ); + ensure( + find(screens[2], MAGENTA).count > 300, + 'an untouched sprite changed', + ); + // Idle: once a commit has drawn, the set adds no frames (the + // page may draw for its own reasons: the app's other layers). + await settle(2); + const framesAfter = await framesIn(2000); + ensure( + framesAfter <= idleFrames + 1, + `${framesAfter} frames in 2 s after a commit, ${idleFrames} before the set`, + ); + set.destroy(); + await until( + () => find(screens[2], MAGENTA).count < 10, + 'the destroyed set gone', + ); + } finally { + set.destroy(); + } + ensure( + !engine.movers.list().some(({ id }) => id === 'contract-sprites'), + 'a destroyed set is still listed', + ); + }, + ], + [ + 'movers.createSet: colours: mid-tone sprite, point and line colours draw as asked (within 3 per channel)', + async () => { + if (!readRegion || !makeCanvas) return; + await nadirView(1e6); + const at = (lon, lat, height = 20_000) => + cartesian3.fromDegrees(lon, lat, height); + const GREY = [0.5, 0.5, 0.5, 1]; + const ORANGE = [1, 0.6, 0.2, 1]; + const atlasCanvas = makeCanvas(16, 16); + const context = atlasCanvas.getContext('2d'); + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, 16, 16); + const sprites = engine.movers.createSet({ + id: 'contract-colour-sprites', + kind: 'sprite', + capacity: 2, + order: 100, + depthTest: 'always', + sprite: { + atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } }, + sizePx: 32, + }, + }); + const points = engine.movers.createSet({ + id: 'contract-colour-points', + kind: 'point', + capacity: 2, + order: 100, + depthTest: 'always', + point: { sizePx: 32 }, + }); + const lines = [GREY, ORANGE].map((color, index) => + engine.lines.createLine({ + id: `contract-colour-line-${index}`, + widthPx: 16, + color, + }), + ); + // The centre 4×4 pixels' mean, as 0..255 per channel. + const centre = (position) => { + const screen = engine.projector.worldToWindow(position); + const data = readRegion( + Math.round(screen.x) - 2, + Math.round(screen.y) - 2, + 4, + 4, + ); + const sum = [0, 0, 0]; + for (let i = 0; i < data.length; i += 4) + for (let k = 0; k < 3; k++) sum[k] += data[i + k]; + return sum.map((value) => value / (data.length / 4)); + }; + const cases = [ + ['sprite', GREY, at(19.5, 10.5)], + ['sprite', ORANGE, at(20.5, 10.5)], + ['point', GREY, at(19.5, 9.5)], + ['point', ORANGE, at(20.5, 9.5)], + ['line', GREY, at(19.5, 10)], + ['line', ORANGE, at(20.5, 10)], + ]; + try { + sprites.set(0, { position: cases[0][2], color: GREY, cell: 'box' }); + sprites.set(1, { position: cases[1][2], color: ORANGE, cell: 'box' }); + sprites.setCount(2); + sprites.commit(); + points.set(0, { position: cases[2][2], color: GREY }); + points.set(1, { position: cases[3][2], color: ORANGE }); + points.setCount(2); + points.commit(); + // Short vertical lines through their sample points. + lines[0].setPositions([at(19.5, 9.9), at(19.5, 10.1)]); + lines[1].setPositions([at(20.5, 9.9), at(20.5, 10.1)]); + const off = (seen, colour) => + Math.max( + ...seen.map((value, k) => Math.abs(value - colour[k] * 255)), + ); + await until( + () => cases.every(([, colour, p]) => off(centre(p), colour) <= 3), + 'mid-tone colours within 3', + ); + } catch (error) { + const seen = cases + .map( + ([kind, colour, p]) => + `${kind} ${colour.slice(0, 3)} → ${centre(p) + .map((v) => v.toFixed(0)) + .join('/')}`, + ) + .join('; '); + throw new ContractError(`${error.message}: ${seen}`); + } finally { + sprites.destroy(); + points.destroy(); + for (const line of lines) line.destroy(); + } + }, + ], + ]; +} + +export function moverChecks( + engine, + { until, nadirView, centre, readRegion, makeCanvas }, +) { + const { camera } = engine; + return [ + [ + 'movers.createSet: depth tests, rotation, cells, scale, count, points and models', + async () => { + await nadirView(1e6); + const at = (lon, lat, height = 20_000) => + cartesian3.fromDegrees(lon, lat, height); + // Atlas: 'red' a red square; 'up' white in its top half only. + const atlas = makeCanvas + ? makeCanvas(32, 16) + : { width: 32, height: 16 }; + if (makeCanvas) { + const context = atlas.getContext('2d'); + context.fillStyle = '#ff0000'; + context.fillRect(0, 0, 16, 16); + context.fillStyle = '#ffffff'; + context.fillRect(16, 0, 16, 8); + } + const cells = { red: [0, 0, 0.5, 1], up: [0.5, 0, 1, 1] }; + const sprites = (id, extra = {}) => + engine.movers.createSet({ + id, + kind: 'sprite', + capacity: 3, + order: 100, + depthTest: 'always', + sprite: { atlas: { canvas: atlas, cells }, sizePx: 24 }, + ...extra, + }); + const sets = []; + const make = (set) => (sets.push(set), set); + const RED = [1, 0, 0]; + const MAGENTA = [1, 0, 1]; + // Pixels near a colour (within 60 per channel) in a box, and how + // many lie above / below the box's centre row. + const look = (position, colour, half = 40) => { + const screen = engine.projector.worldToWindow(position); + const x0 = Math.round(screen.x) - half; + const y0 = Math.round(screen.y) - half; + const data = readRegion(x0, y0, 2 * half, 2 * half); + let total = 0; + let above = 0; + for (let y = 0; y < 2 * half; y++) + for (let x = 0; x < 2 * half; x++) { + const i = (y * 2 * half + x) * 4; + if ( + colour.every( + (channel, k) => Math.abs(data[i + k] - channel * 255) <= 60, + ) + ) { + total++; + if (y < half) above++; + } + } + return { total, above, below: total - above }; + }; + try { + const a = at(19.5, 10); + const base = make(sprites('contract-cells')); + base.set(0, { position: a, cell: 'red' }); + base.set(1, { position: at(20.5, 10), cell: 'red' }); + base.set(2, { position: at(20.5, 11), cell: 'red' }); + base.setCount(3); + base.commit(); + const far = make( + sprites('contract-far', { + sprite: { + atlas: { canvas: atlas, cells }, + sizePx: 24, + // The camera is 1000 km away: past `far`, half size. + scaleByDistance: [1e3, 1, 1e5, 0.5], + }, + }), + ); + const b = at(19.5, 11); + far.set(0, { position: b, cell: 'red' }); + far.setCount(1); + far.commit(); + const deep = (lon) => at(lon, 9, -30_000); + const depth = { + normal: make(sprites('contract-normal', { depthTest: 'normal' })), + always: make(sprites('contract-always', { depthTest: 'always' })), + within: make( + sprites('contract-within', { + depthTest: { disableWithinM: 2e7 }, + }), + ), + near: make( + sprites('contract-near', { depthTest: { disableWithinM: 10 } }), + ), + }; + Object.values(depth).forEach((set, index) => { + set.set(0, { position: deep(19 + index * 0.7), cell: 'red' }); + set.setCount(1); + set.commit(); + }); + const points = make( + engine.movers.createSet({ + id: 'contract-points', + kind: 'point', + capacity: 1, + depthTest: 'always', + point: { sizePx: 20, outlinePx: 3 }, + }), + ); + const p = at(20.5, 9.5); + points.set(0, { position: p, color: [1, 0, 1, 1] }); + points.setCount(1); + points.commit(); + if (!readRegion) { + base.setCount(1); + base.commit(); + ensure(base.count() === 1, 'setCount did not shrink the set'); + return; + } + await until(() => look(a, RED).total > 300, 'the red cell drawn'); + const unit = look(a, RED).total; + ensure( + look(at(20.5, 11), RED).total > 300, + 'the third sprite is not drawn', + ); + // Scale by distance: a quarter of the area. + const farArea = look(b, RED).total; + ensure( + farArea > unit * 0.12 && farArea < unit * 0.4, + `scaleByDistance drew ${farArea} px against ${unit} at full size`, + ); + // Scale 2: four times the area. + base.set(0, { scale: 2 }); + base.commit(); + await until( + () => look(a, RED).total > unit * 3, + 'the sprite drawn at scale 2', + ); + ensure( + look(a, RED).total < unit * 5, + `scale 2 drew ${look(a, RED).total} px against ${unit}`, + ); + // Cell switch: the 'up' cell is white in its top half (magenta + // times the instance colour), then turned upside down. + base.set(0, { scale: 1, cell: 'up', color: [1, 0, 1, 1] }); + base.commit(); + await until(() => look(a, MAGENTA).total > 100, 'the up cell drawn'); + const upright = look(a, MAGENTA); + ensure( + upright.above > 10 * Math.max(1, upright.below) && + look(a, RED).total < 10, + `the up cell: ${JSON.stringify(upright)}, red ${look(a, RED).total}`, + ); + base.set(0, { rotation: Math.PI }); + base.commit(); + await until( + () => look(a, MAGENTA).below > 100, + 'the rotated cell drawn', + ); + const turned = look(a, MAGENTA); + ensure( + turned.below > 10 * Math.max(1, turned.above), + `rotation π: ${JSON.stringify(turned)}`, + ); + // setCount shrinks: the others go. + base.setCount(1); + base.commit(); + await until( + () => look(at(20.5, 11), RED).total < 10, + 'the shrunk set drawn', + ); + ensure(look(a, MAGENTA).total > 100, 'instance 0 went with them'); + // Depth: under the ground only 'always' and a disableWithinM + // reaching past the camera draw. + const drawn = (index) => + look(deep(19 + index * 0.7), RED).total > 100; + await until(() => drawn(1), "the 'always' sprite drawn"); + ensure(drawn(2), 'disableWithinM past the camera is hidden'); + // Cesium's billboards under the app's photoreal tiles (globe + // hidden) are not occluded by them, measured; Navara's overlay + // tests the scene depth. Occlusion is checked on Navara only. + if (engine.kind === 'navara') { + ensure(!drawn(0), "a 'normal' sprite under the ground is drawn"); + ensure(!drawn(3), 'disableWithinM 10 m under the ground is drawn'); + } + // Points: a magenta disc with a dark outline. + await until(() => look(p, MAGENTA, 16).total > 80, 'the point drawn'); + const ring = look(p, [0, 0, 0], 16).total; + ensure(ring > 20, `no dark outline around the point (${ring} px)`); + // Models: an aircraft drawn in magenta (blended by replace), at + // least 80 px; lit and graded on Navara, so magenta-leaning. + const models = make( + engine.movers.createSet({ + id: 'contract-models', + kind: 'model', + capacity: 1, + model: { + url: '/models/airplane.glb', + minimumPixelSize: 80, + colorBlend: { mode: 'replace', amount: 1 }, + }, + }), + ); + const m = at(19.2, 9.6, 50_000); + models.set(0, { position: m, color: [1, 0, 1, 1] }); + models.setCount(1); + models.commit(); + const modelPixels = () => { + const screen = engine.projector.worldToWindow(m); + const data = readRegion( + Math.round(screen.x) - 50, + Math.round(screen.y) - 50, + 100, + 100, + ); + let count = 0; + for (let i = 0; i < data.length; i += 4) + if ( + Math.min(data[i], data[i + 2]) - data[i + 1] > 40 && + data[i] > 60 + ) + count++; + return count; + }; + // ready() turns true once the glTF has loaded (the magenta point + // and sprites above can already fill the region before then). + await until(() => models.ready(), 'the model set ready()'); + ensure( + models.ready(0) === true, + 'the drawn instance is not ready(0)', + ); + await until(() => modelPixels() > 150, 'the model drawn'); + } finally { + for (const set of sets) set.destroy(); + } + }, + ], + ]; +} + +export function failedModelChecks(engine, { settle, until, fixtures }) { + return [ + [ + 'MoverSet.failed: a failed model stays unready and reports its failure scope', + async () => { + const id = 'contract-failed-model'; + const set = engine.movers.createSet({ + id, + kind: 'model', + capacity: 2, + model: { url: '/__contract_missing_model.glb' }, + }); + try { + set.set(0, { + position: cartesian3.fromDegrees(20, 10, 1000), + pickId: 'failed', + }); + set.setCount(1); + set.commit(); + fixtures?.failModel?.(id, true); + await until(() => set.failed(0), 'failed model'); + ensure(set.failed() === true, 'set did not report the failure'); + ensure( + set.ready(0) === false && set.ready() === false, + 'failed model is ready', + ); + ensure( + set.failed(1) === (engine.kind === 'navara'), + 'failure scope changed', + ); + await settle(3); + ensure( + set.failed(0) && !set.ready(0), + 'failed model silently recovered', + ); + } finally { + fixtures?.failModel?.(id, false); + set.destroy(); + } + ensure( + !set.failed() && !set.ready(), + 'destroyed model reports ready or failed', + ); + }, + ], + ]; +} + +export function moverListChecks(engine) { + return [ + [ + 'movers.list: shown follows active instance visibility and count', + async () => { + const set = engine.movers.createSet({ + id: 'contract-movers-shown', + kind: 'point', + capacity: 2, + point: { sizePx: 8 }, + }); + const shown = () => + engine.movers.list().find((row) => row.id === set.id)?.shown; + try { + ensure(shown() === false, 'empty set is shown'); + set.set(0, { position: cartesian3.fromDegrees(20, 10), show: false }); + set.setCount(1); + set.commit(); + ensure(shown() === false, 'hidden set is shown'); + set.set(0, { show: true }); + set.commit(); + ensure(shown() === true, 'visible set is hidden'); + set.setCount(0); + set.commit(); + ensure(shown() === false, 'zero count is shown'); + } finally { + set.destroy(); + } + ensure( + !engine.movers.list().some((row) => row.id === set.id), + 'removed set remains listed', + ); + }, + ], + ]; +} diff --git a/src/globe/testing/contractSuite/render.mjs b/src/globe/testing/contractSuite/render.mjs new file mode 100644 index 000000000..86dd391e1 --- /dev/null +++ b/src/globe/testing/contractSuite/render.mjs @@ -0,0 +1,377 @@ +import { ensure } from './helpers.mjs'; + +export function renderChecks(engine, { settle }) { + return [ + [ + 'render.setContinuous: continuous mode toggles', + async () => { + const previous = engine.render.isContinuous(); + try { + engine.render.setContinuous(true); + ensure( + engine.render.isContinuous() === true, + 'continuous not enabled', + ); + engine.render.setContinuous(false); + ensure( + engine.render.isContinuous() === false, + 'continuous not disabled', + ); + } finally { + engine.render.setContinuous(previous); + } + }, + ], + [ + 'render.setSharpen: merges partial updates, rejects bad values, draws', + async () => { + const previous = engine.render.sharpen(); + const same = (expected, label) => { + const actual = engine.render.sharpen(); + ensure( + actual.enabled === expected.enabled && + actual.amount === expected.amount, + `${label}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`, + ); + }; + ensure( + typeof previous.enabled === 'boolean' && + Number.isFinite(previous.amount), + `sharpen() ${JSON.stringify(previous)} is not { enabled, amount }`, + ); + try { + engine.render.setSharpen({ enabled: true, amount: 1.7 }); + same({ enabled: true, amount: 1.7 }, 'after enabling'); + engine.render.sharpen().amount = 0; + same({ enabled: true, amount: 1.7 }, 'sharpen() is a copy'); + // A frame with the sharpening on draws without errors. + await settle(2); + engine.render.setSharpen({ amount: 0.5 }); + same({ enabled: true, amount: 0.5 }, 'after an amount alone'); + engine.render.setSharpen({ enabled: false }); + same({ enabled: false, amount: 0.5 }, 'after disabling'); + for (const bad of [ + { enabled: 'yes' }, + { amount: -0.1 }, + { amount: Number.NaN }, + { amount: '1' }, + ]) { + let error = null; + try { + engine.render.setSharpen(bad); + } catch (caught) { + error = caught; + } + ensure( + error instanceof TypeError, + `setSharpen(${JSON.stringify(bad)}) did not throw a TypeError`, + ); + } + same({ enabled: false, amount: 0.5 }, 'after rejected values'); + await settle(1); + } finally { + engine.render.setSharpen(previous); + await settle(1); + } + }, + ], + ]; +} + +export function contactSonarChecks(render, settle) { + return [ + [ + 'render.setContactSonar: (contactSonar) merges, copies, rejects bad values, draws', + async () => { + const previous = render.contactSonar(); + ensure( + previous.enabled === false, + `the sonar starts ${JSON.stringify(previous)}`, + ); + try { + render.setContactSonar({ + enabled: true, + active: true, + angleDeg: 90, + range: 100, + opacity: 40, + sector: 30, + }); + await settle(2); + render.setContactSonar({ angleDeg: 180 }); + const state = render.contactSonar(); + ensure( + state.enabled && state.active && state.angleDeg === 180, + `after an angle alone: ${JSON.stringify(state)}`, + ); + state.angleDeg = 0; + ensure( + render.contactSonar().angleDeg === 180, + 'contactSonar() is a copy', + ); + for (const bad of [ + { enabled: 'yes' }, + { active: 1 }, + { angleDeg: Number.NaN }, + { range: 0 }, + { opacity: 101 }, + { sector: 0 }, + { sector: 400 }, + ]) { + let error = null; + try { + render.setContactSonar(bad); + } catch (caught) { + error = caught; + } + ensure( + error instanceof TypeError, + `setContactSonar(${JSON.stringify(bad)}) did not throw a TypeError`, + ); + } + ensure( + render.contactSonar().opacity === 40, + 'a rejected call changed the sonar', + ); + } finally { + render.setContactSonar(previous); + await settle(1); + } + }, + ], + ]; +} + +// The optional cloudCover capability (render.setCloudCover). + +export function cloudCoverChecks(render, settle) { + return [ + [ + 'render.setCloudCover: (cloudCover) sets, copies, gives back, rejects bad values', + async () => { + const previous = render.cloudCover(); + try { + render.setCloudCover({ coverage: 0.7 }); + ensure(render.cloudCover().coverage === 0.7, 'coverage not set'); + render.cloudCover().coverage = 0; + ensure( + render.cloudCover().coverage === 0.7, + 'cloudCover() is a copy', + ); + await settle(2); + render.setCloudCover({}); + ensure(render.cloudCover().coverage === 0.7, 'omitted keeps it'); + render.setCloudCover({ coverage: null }); + ensure(render.cloudCover().coverage === null, 'null: the engine own'); + for (const bad of [-0.1, 1.2, Number.NaN, '0.5']) { + let error = null; + try { + render.setCloudCover({ coverage: bad }); + } catch (caught) { + error = caught; + } + ensure( + error instanceof TypeError, + `setCloudCover(${bad}) did not throw a TypeError`, + ); + } + ensure( + render.cloudCover().coverage === null, + 'after rejected values', + ); + } finally { + render.setCloudCover(previous); + await settle(1); + } + }, + ], + ]; +} + +// The optional bloom capability (render.setBloom). + +export function bloomChecks(render, settle) { + return [ + [ + 'render.setBloom: (bloom) merges, copies, rejects bad values, draws', + async () => { + const previous = render.bloom(); + ensure( + typeof previous.enabled === 'boolean' && + Number.isFinite(previous.strength), + `bloom() ${JSON.stringify(previous)} is not { enabled, strength }`, + ); + const same = (expected, label) => { + const actual = render.bloom(); + ensure( + actual.enabled === expected.enabled && + actual.strength === expected.strength, + `${label}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`, + ); + }; + try { + render.setBloom({ enabled: true, strength: 0.8 }); + same({ enabled: true, strength: 0.8 }, 'after enabling'); + render.bloom().strength = 0; + same({ enabled: true, strength: 0.8 }, 'bloom() is a copy'); + // A frame with the bloom on draws without errors. + await settle(2); + render.setBloom({ strength: 0.05 }); + same({ enabled: true, strength: 0.05 }, 'in the dead zone'); + render.setBloom({ enabled: false }); + same({ enabled: false, strength: 0.05 }, 'after disabling'); + for (const bad of [ + { enabled: 1 }, + { strength: -0.1 }, + { strength: 1.5 }, + { strength: Number.NaN }, + ]) { + let error = null; + try { + render.setBloom(bad); + } catch (caught) { + error = caught; + } + ensure( + error instanceof TypeError, + `setBloom(${JSON.stringify(bad)}) did not throw a TypeError`, + ); + } + same({ enabled: false, strength: 0.05 }, 'after rejected values'); + } finally { + render.setBloom(previous); + await settle(1); + } + }, + ], + ]; +} + +// The optional visualStyles capability (render.setStyle). + +export function styleChecks(render, settle) { + return [ + [ + 'render.setStyle: (visualStyles) merges, copies, rejects bad values, draws', + async () => { + const names = render.visualStyles; + ensure( + names.length > 0 && new Set(names).size === names.length, + `visualStyles ${JSON.stringify(names)} is not a list of names`, + ); + const [name] = names; + const previous = render.style(name); + ensure( + previous.intensity === 0, + `${name} starts at intensity ${previous.intensity}`, + ); + const [uniform] = Object.keys(previous.uniforms); + try { + render.setStyle(name, { intensity: 1 }); + ensure(render.style(name).intensity === 1, 'intensity not set'); + // A frame with the style on draws without errors. + await settle(2); + if (uniform) { + render.setStyle(name, { uniforms: { [uniform]: 0.25 } }); + const state = render.style(name); + ensure( + state.intensity === 1 && state.uniforms[uniform] === 0.25, + `after a uniform alone: ${JSON.stringify(state)}`, + ); + state.uniforms[uniform] = 9; + ensure( + render.style(name).uniforms[uniform] === 0.25, + 'style() is a copy', + ); + } + render.setStyle(name, { intensity: 0.5 }); + ensure(render.style(name).intensity === 0.5, 'crossfade step'); + for (const [label, call] of [ + ['unknown style', () => render.setStyle('no-such-style', {})], + ['intensity 2', () => render.setStyle(name, { intensity: 2 })], + [ + 'intensity -0.1', + () => render.setStyle(name, { intensity: -0.1 }), + ], + [ + 'unknown uniform', + () => render.setStyle(name, { uniforms: { noSuchUniform: 1 } }), + ], + ...(uniform + ? [ + [ + 'NaN uniform', + () => + render.setStyle(name, { + uniforms: { [uniform]: Number.NaN }, + }), + ], + ] + : []), + ]) { + let error = null; + try { + call(); + } catch (caught) { + error = caught; + } + ensure( + error instanceof TypeError, + `setStyle with ${label} did not throw a TypeError`, + ); + } + ensure( + render.style(name).intensity === 0.5, + 'a rejected call changed the style', + ); + } finally { + render.setStyle(name, previous); + await settle(1); + } + }, + ], + ]; +} + +export function renderBehaviourChecks(engine, { waitFrames }) { + return [ + [ + 'render.maxTextureSize: a positive power of two', + async () => { + const size = engine.render.maxTextureSize(); + ensure( + Number.isInteger(size) && + size > 0 && + Number.isInteger(Math.log2(size)), + `texture limit ${size}`, + ); + }, + ], + [ + 'render.request: a request inside preRender draws the next frame', + async () => { + const previous = engine.render.isContinuous(); + let drawn = 0; + let requested = false; + const removePre = engine.frames.preRender.addEventListener(() => { + if (requested) return; + requested = true; + engine.render.request('contract-next-frame'); + }); + const removePost = engine.frames.postRender.addEventListener( + () => drawn++, + ); + try { + engine.render.setContinuous(false); + engine.render.request('contract-first-frame'); + await waitFrames(2); + ensure(drawn >= 2, 'the request inside the frame was lost'); + } finally { + removePre(); + removePost(); + engine.render.setContinuous(previous); + } + }, + ], + ]; +} diff --git a/src/globe/testing/contractSuite/surface.mjs b/src/globe/testing/contractSuite/surface.mjs new file mode 100644 index 000000000..05b6a1b77 --- /dev/null +++ b/src/globe/testing/contractSuite/surface.mjs @@ -0,0 +1,303 @@ +import { + cartesian2, + cartesian3, + cartographic, + ellipsoid, + math, + WGS84, + ensure, + near, + isCartesian3, +} from './helpers.mjs'; + +export function surfaceChecks(engine, { nadirView, centre }) { + return [ + [ + 'picker.pick: pick, drillPick and pickPosition return contract shapes', + async () => { + await nadirView(); + const screen = cartesian2.create(1, 1); + const picked = engine.picker.pick(screen); + ensure( + picked === undefined || 'id' in picked, + 'pick returned no id field', + ); + const drilled = engine.picker.drillPick(screen, 3); + ensure(Array.isArray(drilled), 'drillPick did not return an array'); + ensure(drilled.length <= 3, 'drillPick ignored its limit'); + ensure( + typeof engine.picker.pickPositionSupported === 'boolean', + 'pickPositionSupported is not boolean', + ); + const position = engine.picker.pickPosition(centre()); + ensure( + position === undefined || isCartesian3(position), + 'pickPosition is not a Cartesian3', + ); + }, + ], + [ + 'surface.sampleHeightDetailed: source, tile size and mover-set exclusion', + async () => { + const place = cartographic.fromDegrees(20, 10, 0); + const shape = (detail, label) => { + if (detail === undefined) return; + ensure(Number.isFinite(detail.height), `${label}: height`); + ensure( + detail.source === 'tiles' || detail.source === 'terrain', + `${label}: source ${detail.source}`, + ); + ensure( + detail.tileSizeM === undefined || + (detail.source === 'tiles' && detail.tileSizeM > 0), + `${label}: tileSizeM ${detail.tileSizeM}`, + ); + }; + const visible = engine.surface.sampleHeightDetailed(place); + shape(visible, 'visible'); + if (visible?.source === 'tiles') + near( + engine.surface.sampleHeight(place), + visible.height, + 1e-6, + 'sampleHeight and the detailed tiles answer', + ); + // A mover set in exclude: the sample works and the set stays. + const set = engine.movers.createSet({ + id: 'contract-surface-exclude', + kind: 'point', + capacity: 1, + point: { sizePx: 8 }, + }); + try { + set.set(0, { position: cartesian3.fromDegrees(20, 10, 50) }); + set.setCount(1); + set.commit(); + shape( + engine.surface.sampleHeightDetailed(place, { exclude: [set] }), + 'excluding a set', + ); + ensure(set.id === 'contract-surface-exclude', 'the set has no id'); + ensure( + engine.movers + .list() + .some(({ id }) => id === 'contract-surface-exclude'), + 'the excluded set is gone', + ); + } finally { + set.destroy(); + } + }, + ], + [ + 'surface.sampleHeight: flags, tilesLoaded, sampleHeight, terrainHeight and clampToHeight shapes', + async () => { + for (const flag of ['terrainHeightSupported', 'clampToHeightSupported']) + ensure( + typeof engine.surface[flag] === 'boolean', + `${flag} is not boolean`, + ); + const place = cartographic.fromDegrees(20, 10, 0); + if (engine.surface.terrainHeightSupported) { + const terrain = engine.surface.terrainHeight(place); + ensure( + terrain === undefined || Number.isFinite(terrain), + `terrainHeight returned ${terrain}`, + ); + } + if (engine.surface.clampToHeightSupported) { + const clamped = engine.surface.clampToHeight( + cartesian3.fromDegrees(20, 10, 5000), + ); + ensure( + clamped === undefined || isCartesian3(clamped), + 'clampToHeight is not a Cartesian3', + ); + } + ensure( + typeof engine.surface.supported === 'boolean', + 'supported is not boolean', + ); + ensure( + typeof engine.surface.tilesLoaded() === 'boolean', + 'tilesLoaded is not boolean', + ); + if (!engine.surface.supported) return; + const value = engine.surface.sampleHeight( + cartographic.fromDegrees(20, 10, 0), + ); + ensure( + value === undefined || Number.isFinite(value), + `sampleHeight returned ${value}`, + ); + }, + ], + ]; +} + +export function globePickChecks(engine, { settle, until, nadirView, centre }) { + const { camera } = engine; + return [ + [ + 'picker.pickGlobe: the nadir hits the globe and the sky misses', + async () => { + await nadirView(); + const hit = engine.picker.pickGlobe(centre()); + ensure(isCartesian3(hit), 'the nadir missed the globe'); + const point = cartographic.fromCartesian(hit, WGS84); + // Terrain, when shown, stays within Earth's relief of the ellipsoid. + near(point.height, 0, 9000, 'globe height (m)'); + near(math.toDegrees(point.longitude), 20, 0.05, 'longitude (deg)'); + near(math.toDegrees(point.latitude), 10, 0.05, 'latitude (deg)'); + camera.setView({ + destination: cartesian3.fromDegrees(20, 10, 1e6), + orientation: { heading: 0, pitch: 0.6, roll: 0 }, + }); + await settle(); + ensure( + engine.picker.pickGlobe(centre()) === undefined, + 'the sky hit the globe', + ); + }, + ], + [ + 'picker.pickPosition: projector.worldToWindow round-trip within 1.5 px', + async () => { + if (!engine.picker.pickPositionSupported) return; + // Straight down and tilted; screen points with no surface are skipped. + // Over New York, low enough for map detail. + const views = [ + { height: 20000, heading: 0, pitch: -math.PI_OVER_TWO }, + { height: 5000, heading: 0.7, pitch: -1.0 }, + ]; + let hits = 0; + const misses = []; + for (const { height: viewHeight, heading, pitch } of views) { + camera.setView({ + destination: cartesian3.fromDegrees(-73.78, 40.64, viewHeight), + orientation: { heading, pitch, roll: 0 }, + }); + // Picks read the depth of the last drawn frame: draw this view. + await settle(3); + const { width, height } = engine.host.size(); + const screens = [ + [0.5, 0.5], + [0.3, 0.4], + [0.7, 0.6], + [0.5, 0.75], + ].map(([x, y]) => + cartesian2.create(Math.round(width * x), Math.round(height * y)), + ); + // The depth a pick reads needs drawn frames. + await until( + () => engine.picker.pickPosition(screens[0]) !== undefined, + 'a surface under the centre for pickPosition', + ); + for (const screen of screens) { + const position = engine.picker.pickPosition(screen); + if (position === undefined) continue; + const back = engine.projector.worldToWindow(position); + const error = back + ? Math.hypot(back.x - screen.x, back.y - screen.y) + : Infinity; + hits += 1; + if (!(error <= 1.5)) + misses.push( + `(${screen.x}, ${screen.y}) → ${back ? `(${back.x.toFixed(2)}, ${back.y.toFixed(2)})` : 'undefined'}`, + ); + } + } + ensure(hits > 0, 'pickPosition hit no surface'); + ensure( + misses.length === 0, + `round-trip off by more than 1.5 px: ${misses.join('; ')}`, + ); + }, + ], + ]; +} + +export function terrainSampleChecks(engine, { fixtures }) { + const checks = [ + [ + 'surface.sampleTerrainMostDetailed: an empty batch stays empty', + async () => { + const result = await engine.surface.sampleTerrainMostDetailed([]); + ensure( + Array.isArray(result) && result.length === 0, + 'empty terrain batch', + ); + }, + ], + [ + 'surface.raycast: a ray away from the globe misses', + async () => { + const origin = cartesian3.fromDegrees(20, 10, 1e6); + const hit = engine.surface.raycast({ + origin, + direction: cartesian3.normalize(origin, cartesian3.create()), + far: 1000, + }); + ensure(hit === undefined, 'outward ray hit a surface'); + }, + ], + ]; + if (fixtures?.terrain) { + const { surface, sample } = fixtures.terrain; + checks.push( + [ + 'surface.sampleTerrainMostDetailed: preserves order and unknown heights', + async () => { + const positions = [2, -1, 1].map((longitude) => ({ + longitude, + latitude: 0, + height: 99, + })); + sample((place) => + place.longitude < 0 ? undefined : place.longitude * 10, + ); + try { + const result = await surface.sampleTerrainMostDetailed(positions); + ensure( + result.length === 3 && + Math.abs(result[0] - 20) < 1e-10 && + result[1] === undefined && + Math.abs(result[2] - 10) < 1e-10, + `terrain samples ${result}`, + ); + ensure( + positions.every((place) => place.height === 99), + 'sampling changed the caller positions', + ); + } finally { + sample(null); + } + }, + ], + [ + 'surface.sampleTerrainMostDetailed: passes sampling rejection to the caller', + async () => { + const failure = new Error('terrain sample unavailable'); + sample(() => { + throw failure; + }); + let caught; + try { + await surface.sampleTerrainMostDetailed([ + { longitude: 0, latitude: 0 }, + ]); + } catch (error) { + caught = error; + } finally { + sample(null); + } + ensure( + caught === failure, + 'sampling rejection was swallowed or replaced', + ); + }, + ], + ); + } + return checks; +} diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js index fb8180230..5cafbb937 100644 --- a/src/globe/testing/fakeEngine.js +++ b/src/globe/testing/fakeEngine.js @@ -202,6 +202,7 @@ export function createFakeEngine(options = {}) { up: cartesian3.create(), }; const events = { + 'maps.errors': createEngineEvent(), 'camera.changed': createEngineEvent(), 'camera.moveStart': createEngineEvent(), 'camera.moveEnd': createEngineEvent(), @@ -263,6 +264,8 @@ export function createFakeEngine(options = {}) { part.commits++; fake.renderRequests++; }, + ready: (index) => + !scripted('moverFailed', [options.id, index], () => false), // Scripted as 'moverFailed' (set id, index) for a load that fails. failed: (index) => scripted('moverFailed', [options.id, index], () => false), @@ -327,6 +330,20 @@ export function createFakeEngine(options = {}) { const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()])); const livePosition = cartesian3.create(); let lastBaseImagery = null; + const removeMapErrors = { tile: null, terrain: null }; + function watchMapErrors(source, kind) { + removeMapErrors[kind]?.(); + removeMapErrors[kind] = source?.provider?.errorEvent?.addEventListener( + (error) => { + events['maps.errors'].raiseEvent({ + source, + kind, + status: + error?.error?.statusCode ?? error?.statusCode ?? error?.status, + }); + }, + ); + } const scripts = new Map(); let transform = [...matrix4.IDENTITY]; let percentageChanged = 0.5; @@ -465,11 +482,13 @@ export function createFakeEngine(options = {}) { const pose = { position: copy3(state.position), direction: copy3(state.direction), + up: copy3(state.up), }; const differs = (a, b) => !b || !cartesian3.equalsEpsilon(a.position, b.position, 0, 1e-9) || - !cartesian3.equalsEpsilon(a.direction, b.direction, 0, 1e-12); + !cartesian3.equalsEpsilon(a.direction, b.direction, 0, 1e-12) || + !cartesian3.equalsEpsilon(a.up, b.up, 0, 1e-12); if (differs(pose, lastFramePose)) { if (!moving) { moving = true; @@ -498,7 +517,11 @@ export function createFakeEngine(options = {}) { ); const shift = cartesian3.distance(pose.position, lastChangedPose.position) / altitude; - const amount = Math.max(turn, shift); + const roll = + Math.acos( + math.clamp(cartesian3.dot(pose.up, lastChangedPose.up), -1, 1), + ) / Math.PI; + const amount = Math.max(turn, roll, shift); if (amount > percentageChanged) { lastChangedPose = pose; events['camera.changed'].raiseEvent(amount); @@ -708,6 +731,7 @@ export function createFakeEngine(options = {}) { }, }); const groundPicks = createGroundPicks({ + surface: () => (fake.maps.photoreal != null ? 'tiles' : 'terrain'), pickGlobe: (screen) => engine.picker.pickGlobe(screen), camera: { get livePosition() { @@ -909,10 +933,12 @@ export function createFakeEngine(options = {}) { // The contract's switch order: a call shows its source when made (the // fake builds nothing), so the call made last decides what shows. maps: { + errors: events['maps.errors'], async setBaseImagery(source) { record('maps.setBaseImagery', [source]); fake.maps.imagery = source; lastBaseImagery = source; + watchMapErrors(source, 'tile'); }, async setTerrain(source) { record('maps.setTerrain', [source]); @@ -925,16 +951,19 @@ export function createFakeEngine(options = {}) { throw new TypeError(`Unknown terrain source: ${kind}`); fake.maps.terrain = source; fake.maps.terrainRevision++; + watchMapErrors(source, 'terrain'); }, async setPhotorealTiles(source) { record('maps.setPhotorealTiles', [source]); if (source == null) { fake.maps.photoreal = null; fake.maps.imagery = lastBaseImagery; + watchMapErrors(lastBaseImagery, 'tile'); return; } fake.maps.photoreal = source; fake.maps.imagery = null; + watchMapErrors(null, 'tile'); }, releasePhotorealTiles(source) { record('maps.releasePhotorealTiles', [source]); @@ -949,6 +978,7 @@ export function createFakeEngine(options = {}) { }, destroy() { record('destroy', []); + for (const remove of Object.values(removeMapErrors)) remove?.(); follow.destroy(); cancelCurrentFlight(); moverRegistry.destroyAll(); @@ -1123,6 +1153,7 @@ export function createFakeEngine(options = {}) { lastFramePose = { position: copy3(state.position), direction: copy3(state.direction), + up: copy3(state.up), }; return engine; } diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs index c9fd7a959..b63f1ebca 100644 --- a/src/globe/testing/fakeEngine.test.mjs +++ b/src/globe/testing/fakeEngine.test.mjs @@ -261,7 +261,7 @@ test('fake movers keep their sets and instances for layer tests', () => { set.set(0, { position: { x: 1, y: 2, z: 3 }, color: [1, 0, 0, 1] }); set.setCount(1); assert.deepEqual(engine.movers.list(), [ - { id: 'flights', kind: 'point', count: 1, capacity: 4 }, + { id: 'flights', kind: 'point', count: 1, capacity: 4, shown: true }, ]); const before = engine.fake.renderRequests; set.commit(); diff --git a/src/maps/controller.js b/src/maps/controller.js index 8b2c232d1..796e9cfc4 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -1,3 +1,4 @@ +import { engineCapabilities } from '../globe/contract.js'; import { indexMapSources } from './registry.js'; import { acquireImageryComparison } from './imageryComparison.js'; @@ -32,9 +33,9 @@ const resolved = (provider, effectiveStackId) => ({ * and watching them as before (supplied tilesets must be ones the engine * knows, i.e. its startup tileset, or has shown). Any other engine builds * its own sources from the descriptions; the controller constructs nothing - * for it, falls back by description when a setter rejects, and has no tile - * failure watch. On destroy the engine keeps what it shows; owned tilesets - * are released and disposed. + * for it and falls back by description when a setter rejects. Tile failure + * fallback runs only with the tileErrors capability. On destroy the engine + * keeps what it shows; owned tilesets are released and disposed. */ export class MapSourceController { constructor({ @@ -340,6 +341,8 @@ export class MapSourceController { async _activateDescribedGlobeStack(stack, gen, visited = new Set()) { const { maps } = this._engine; const source = this._sources.get(stack.id); + this._removeImageryErrorListener?.(); + this._removeImageryErrorListener = null; try { await maps.setBaseImagery(this._describeImagery(source)); } catch (error) { @@ -367,7 +370,9 @@ export class MapSourceController { if (gen !== this._switchGen) return; this._terrainMode = source.terrain.id; } - return { effectiveStackId: stack.id, fallbackMessage: null }; + const resolution = { effectiveStackId: stack.id, fallbackMessage: null }; + this._watchProvider(resolution, gen); + return resolution; } /** Engine description of an imagery source (+ a prebuilt Cesium provider). */ @@ -603,24 +608,24 @@ export class MapSourceController { } _watchProvider(resolution, gen) { + if (!engineCapabilities(this._engine).tileErrors) return; const fallback = this._sources.get( resolution.effectiveStackId, )?.tileFailureFallback; - const errorEvent = resolution.provider?.errorEvent; + const errorEvent = this._engine.maps.errors; + const source = this._engine.maps.current().baseImagery; if (!fallback || !errorEvent?.addEventListener) return; let failures = 0; let pending = false; this._removeImageryErrorListener = errorEvent.addEventListener((error) => { if ( gen !== this._switchGen || - this._liveBaseProvider() !== resolution.provider + error.kind !== 'tile' || + error.source !== source || + this._engine.maps.current().baseImagery !== source ) return; - const retryCount = Number(error?.timesRetried); - failures = - Number.isInteger(retryCount) && retryCount >= 0 - ? Math.max(failures + 1, retryCount + 1) - : failures + 1; + failures++; if (failures < fallback.threshold || pending) return; pending = true; this._onError?.(