diff --git a/.prettierignore b/.prettierignore index 2b2cc755a..9e1a01ef8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,6 +9,8 @@ coverage pinokio/ENVIRONMENT pinokio/.installed src/data/local_data/ +# Written by scripts/record-camera-goldens.mjs and compared byte for byte. +src/render/conformance/goldens/ *.geojson *.geojsonl diff --git a/dev/conformance/index.html b/dev/conformance/index.html new file mode 100644 index 000000000..1cd9bb7c5 --- /dev/null +++ b/dev/conformance/index.html @@ -0,0 +1,113 @@ + + + + + + Renderer conformance + + + +
+

Renderer conformance

+ + + + + +
+
+
+
+
+
+ + + diff --git a/dev/conformance/main.js b/dev/conformance/main.js new file mode 100644 index 000000000..8b812a471 --- /dev/null +++ b/dev/conformance/main.js @@ -0,0 +1,215 @@ +/** + * The conformance page: runs the logic suite in the browser, and draws the + * reference scenes on a renderer to check them against the stored + * references (or record new ones). Driven by hand or through the + * chrome-devtools MCP; `window.conformance` exposes the same actions. + */ +import { + DEFAULT_TOLERANCE, + runConformance, +} from '../../src/render/conformance/index.js'; +import { + REFERENCE_SCENES, + sceneSupport, +} from '../../src/render/conformance/scenes.js'; +import { + compareImages, + drawScene, + referenceName, +} from '../../src/render/conformance/visual.js'; +import { createStubHarness } from '../../src/render/stub/harness.js'; +import { toEcef } from '../../src/render/stub/math.js'; +import { createCesiumSceneRenderer } from '../../src/render/cesium/sceneRenderer.js'; + +const REFERENCES = '/src/render/conformance/goldens/visual/'; +const SAVE = '/__conformance/references/'; +const ctx = { toEcef, assets: { model: '/models/c172.glb' } }; +const $ = (id) => document.getElementById(id); +const status = (text) => ($('status').textContent = text); + +const RENDERERS = { + cesium: () => createCesiumSceneRenderer({ container: $('stage') }), +}; + +async function runLogic() { + status('running the logic suite…'); + const report = await runConformance(createStubHarness('stub (browser)')); + $('logicResults').innerHTML = + `

${report.harness}: ${report.passed} passed, ${report.failed} failed, ${report.skipped} skipped

+ ${report.results + .filter((r) => r.status !== 'pass') + .map( + (r) => + ``, + ) + .join('')}
${r.status}${r.id}${escape(r.detail ?? '')}
`; + status(''); + return { + passed: report.passed, + failed: report.failed, + skipped: report.skipped, + failures: report.results.filter((r) => r.status === 'fail'), + }; +} + +const escape = (s) => + s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]); + +async function pixels(blob) { + const bitmap = await createImageBitmap(blob); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const g = canvas.getContext('2d'); + g.drawImage(bitmap, 0, 0); + return g.getImageData(0, 0, bitmap.width, bitmap.height); +} + +// Object URLs shown for the current run, revoked when the next one starts. +let objectUrls = []; +function objectUrl(blob) { + const url = URL.createObjectURL(blob); + objectUrls.push(url); + return url; +} + +async function toUrl(imageData) { + const canvas = new OffscreenCanvas(imageData.width, imageData.height); + canvas.getContext('2d').putImageData(imageData, 0, 0); + return objectUrl(await canvas.convertToBlob()); +} + +/** The views the stored manifest lists, or none. */ +async function storedViews() { + try { + const res = await fetch(REFERENCES + 'manifest.json', { + cache: 'no-store', + }); + return res.ok ? ((await res.json()).views ?? []) : []; + } catch { + return []; + } +} + +async function reference(name) { + const res = await fetch(REFERENCES + name, { cache: 'no-store' }); + return res.ok && res.headers.get('content-type')?.startsWith('image/') + ? res.blob() + : null; +} + +/** Draws every scene; records references or compares against them. */ +async function runScenes({ record = false, only } = {}) { + const which = $('renderer').value; + const renderer = await RENDERERS[which](); + const results = []; + $('views').innerHTML = ''; + for (const url of objectUrls) URL.revokeObjectURL(url); + objectUrls = []; + try { + for (const scene of REFERENCE_SCENES) { + if (only && !only.includes(scene.id)) continue; + const unsupported = sceneSupport(scene, renderer, ctx); + if (unsupported) { + results.push({ scene: scene.id, status: 'skip', detail: unsupported }); + continue; + } + status(`${scene.id}…`); + await drawScene(renderer, scene, ctx, async (view, capture, settle) => { + const name = referenceName(scene.id, view.name); + const figure = document.createElement('figure'); + $('views').append(figure); + if (settle.state !== 'settled') { + const detail = `${settle.state}: ${settle.blockers.join(', ')}`; + results.push({ + scene: scene.id, + view: view.name, + status: 'fail', + detail, + }); + figure.innerHTML = `
${name}: ${escape(detail)}
`; + return; + } + if (record) { + const res = await fetch(SAVE + name, { + method: 'PUT', + body: capture, + }); + results.push({ + scene: scene.id, + view: view.name, + status: res.ok ? 'recorded' : 'fail', + detail: res.ok ? undefined : `save ${res.status}`, + }); + figure.innerHTML = `
${name}: recorded
`; + return; + } + const refBlob = await reference(name); + if (!refBlob) { + results.push({ + scene: scene.id, + view: view.name, + status: 'fail', + detail: 'no reference', + }); + figure.innerHTML = `
${name}: no reference
`; + return; + } + const [actual, expected] = await Promise.all([ + pixels(capture), + pixels(refBlob), + ]); + const diff = new Uint8ClampedArray(actual.data.length); + const outcome = compareImages(actual, expected, { diff }); + results.push({ + scene: scene.id, + view: view.name, + status: outcome.match ? 'pass' : 'fail', + diffFraction: outcome.diffFraction, + solidPixels: outcome.solidPixels, + mismatchedPixels: outcome.mismatchedPixels, + ...(outcome.reason ? { detail: outcome.reason } : {}), + }); + const diffUrl = outcome.reason + ? '' + : await toUrl(new ImageData(diff, actual.width, actual.height)); + figure.innerHTML = `
+
${name}: ${outcome.solidPixels} solid, ${outcome.mismatchedPixels} unexcused, ${(outcome.diffFraction * 100).toFixed(3)}% differ ${outcome.reason ?? ''}
`; + }); + } + if (record) { + // A partial recording keeps the other scenes' entries. + const kept = only + ? (await storedViews()).filter( + (view) => !only.some((id) => view.startsWith(`${id}-`)), + ) + : []; + const recorded = results + .filter((r) => r.status === 'recorded') + .map((r) => referenceName(r.scene, r.view)); + await fetch(SAVE + 'manifest.json', { + method: 'PUT', + body: + JSON.stringify( + { + renderer: which, + size: { + width: renderer.canvas.width, + height: renderer.canvas.height, + }, + views: [...kept, ...recorded], + }, + null, + 2, + ) + '\n', + }); + } + } finally { + renderer.destroy(); + status(''); + } + return results; +} + +$('logic').onclick = () => runLogic(); +$('check').onclick = () => runScenes(); +$('record').onclick = () => runScenes({ record: true }); +window.conformance = { runLogic, runScenes }; diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index f0bb9e979..7e95f7203 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -33,12 +33,13 @@ export inventory. Use declared exports rather than reaching into internal files. | `src/layers//` | Source acquisition, records and Cesium resources with explicit controller/renderer owners | | `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance | | `src/services/` | Supplied application operations; scene construction owns caches and cancellation | +| `src/render/` | The renderer contract, the stub renderer, the conformance suite and Cesium renderer pieces | | `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters | | `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup | | `server/standalone/` | Environment, local settings writes and server composition | See [application construction](APPLICATION.md) and the -[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces. +[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces, and the [renderer contract](RENDERER-CONTRACT.md) for what a renderer provides. ## Import direction gates diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b68da8fa3..df8bf6d1e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5994,6 +5994,20 @@ easier to meet (detection is now on more often), but does not create it. above and the DISPLAY-rail entry below (was `OFF`). - Style shader starting params: CRT/NVG/FLIR pixelation `1.2` (just above the native `1.0` floor); thermal/FLIR ships an optional Ironbow "Predator" palette (`palette` uniform, default `0` = accurate grayscale). +## Renderer contract + +`src/render/contract/` types the renderer contract, version `0`: nine ports +(frame, globe, draw, camera, query, input, effects, credits and quality), +plus the renderer itself. `PORTS` lists every member at run time, and +`contract.test.mjs` keeps that list and the types in step. The application +does not yet draw through the contract: it still drives Cesium directly. +`src/render/stub/` is a renderer that draws nothing, with the default +camera's exact maths. `src/render/conformance/` is the suite: logic checks +in Node, plus camera traces recorded from Cesium's own `Camera` into +`goldens/camera.json`. It also holds the reference scenes, whose views are +checked against `goldens/visual/` on the `/dev/conformance/` page. See +[RENDERER-CONTRACT.md](RENDERER-CONTRACT.md). + ## Operational Notes - **Earthquake discs are STATIC geometry.** Every quake is a `CLAMP_TO_GROUND` diff --git a/docs/RENDERER-CONTRACT.md b/docs/RENDERER-CONTRACT.md new file mode 100644 index 000000000..fb38a01a1 --- /dev/null +++ b/docs/RENDERER-CONTRACT.md @@ -0,0 +1,158 @@ +# Renderer contract + +The renderer contract is what the application asks of a 3D renderer. A +renderer other than the default Cesium one can draw the same application if +it meets the contract. The contract is version `0`. Today the application +still drives Cesium directly; later work moves the layers onto the contract +one family at a time. + +The package exports three modules: + +| Export | What it is | +| ---------------------------------- | ------------------------------------------------------------------------------ | +| `gods-eye-view/render/contract` | The types, one `.d.ts` per port, plus the run-time member list and shape check | +| `gods-eye-view/render/stub` | A renderer that draws nothing, for tests | +| `gods-eye-view/render/conformance` | The conformance suite, reference scenes and visual comparison | + +## Ports + +A renderer is created by a `RendererPlugin` (`{ id, create(options) }`). It +exposes nine ports, each typed in its own file in `src/render/contract/`: + +| Port | File | Covers | +| --------- | -------------- | ------------------------------------------------------------------------------ | +| `frame` | `frame.d.ts` | The render-on-demand loop, frame phases, continuous holds, scene time | +| `globe` | `globe.d.ts` | Base (photoreal, terrain, vector, imagery), imagery layers, split, sky | +| `draw` | `draw.d.ts` | Draw sets: icons, points, lines, shapes, models, quads, meshes, fields, agents | +| `camera` | `camera.d.ts` | Pose, views, flights, `lookAt`, follow, user controls, events, matrices | +| `query` | `query.d.ts` | Picks, projection, rays, heights | +| `input` | `input.d.ts` | Pointer events with their pick made, leases, stock behaviours | +| `effects` | `effects.d.ts` | Screen styles, bloom, sharpen, screen modulation | +| `credits` | `credits.d.ts` | Data credits owed | +| `quality` | `quality.d.ts` | Quality knobs, device facts, frame signals, benchmark | + +The renderer itself (`renderer.d.ts`) adds capture, pixel reads, settling, +resize, context loss and `destroy`. `common.d.ts` holds the shared shapes +(ECEF positions, colours, listener semantics) and the capability list. + +The rules that hold everywhere: + +- **Core keeps its own records.** It never reads a renderer to learn its own + state, only what the renderer measures: picks, depth, heights and + readiness. +- **Capabilities.** An optional member (`?`) exists only with the capability + its documentation names first. A required member that names a capability + always exists, and without the capability it answers null or empty. + Quality support is listed by `quality.knobs()`, not by capabilities. +- **Heights are physical.** All heights are metres above the ellipsoid, + unexaggerated. Only drawing follows exaggerated terrain. +- **Listeners** run in subscription order. A listener removed during + delivery does not run; one added during delivery runs from the next event. + A listener that throws is reported to the renderer's `error` event, or to + the platform's `reportError` when nothing listens for errors. The other + listeners, and the frame, still run. +- **The frame order is fixed:** camera flight → input → camera `changed` → + follow → `preUpdate` → move detection → `preRender` → render → + `postRender`. + +`src/render/contract/index.js` lists every member of every port in `PORTS`, +along with `CAPABILITIES`, `FRAME_STEPS` and `QUALITY_KEYS`. +`checkRendererShape(renderer)` names each required member that is missing, +and each capability member present without its capability. +`contract.test.mjs` type-checks the contract under strict TypeScript, and +fails when `PORTS` and the types disagree on a single member. + +## The stub renderer + +`createStubRenderer({ capabilities, viewport, time })` implements every port +in memory and draws nothing. It has: + +- the default camera's exact maths: views, `lookAt`, flights with their + height arc and easing, `changed` thresholds and follows; +- picks against what was drawn, or a scripted table; +- scripted heights; +- a virtual clock that nothing advances on its own. + +`renderer.testing` drives it with `tick()`, `advance(ms)`, `pointer(type, x, y)`, +`scriptPicks`, `setHeights` and `cacheHeight`, and records its calls. +A listener that throws with nothing subscribed to `error` goes to +`reportError`; Node has none, so the stub rethrows it on a microtask and +`node:test` fails the running test. A test that expects a throw subscribes +to `error`. +The stub passes the whole conformance suite (`src/render/stub/stub.test.mjs`). + +## Conformance suite + +`runConformance(harness)` runs every check against a renderer through the +contract alone and reports each one as passed, failed or skipped. It runs in +Node and in a browser page. A harness tells the suite how to drive the +renderer under test: + +- `create({ viewport, capabilities })`; +- `frame(r)`: one frame, 1/60 s on a virtual clock; +- `now(r)` and `toEcef(p)`; +- `tolerance` and `flightPaths`. + +Optional members widen what is checked: `pointer` for synthetic input, +`scriptHeights`, and `assets.model`. + +There are two layers of checks. + +**Logic checks** run in CI. They cover: + +- the shape check; +- listener semantics and the frame loop; +- draw-set bookkeeping, picks and the `meta` round-trip; +- projection, rays and heights; +- leases and click picks; +- imagery order, sky, styles, credits and quality knobs; +- the camera traces. + +**Camera traces.** `cameraScripts.js` lists camera scripts in the contract's +terms. `scripts/record-camera-goldens.mjs` runs them on Cesium's own +`Camera`, headless, on a virtual clock, and writes +`src/render/conformance/goldens/camera.json`. A renderer under test must +match each recorded pose: + +- within 1 mm in position, 1e-9 in unit vectors and angles, and 1e-3 px on + screen; +- with every flight ending within one frame of the recording. + +`src/render/cesium/cameraGoldens.test.mjs` fails when the installed Cesium +no longer records the committed traces. After a Cesium upgrade or a script +change, run `node scripts/record-camera-goldens.mjs` and review the diff. + +**Visual checks** run in the browser. The reference scenes +(`src/render/conformance/scenes.js`) are built from contract calls only: + +- one scene per primitive family; +- a city scene; +- a calibration path from orbit down to the street. + +The scenes load nothing from the network. + +`npm run dev`, then open `/dev/conformance/`. The page can: + +- **Run logic suite (stub)** runs the logic checks in the browser. +- **Record references** draws every scene the chosen renderer supports. + It saves each view to `src/render/conformance/goldens/visual/`, through a + dev-server-only endpoint. +- **Check scenes** draws the scenes again and compares each capture with its + reference. Pixels are compared as pixelmatch compares them: by YIQ + difference (threshold 0.1), with antialiasing in either image excused. A + capture matches when at most 250 pixels are unexcused and at most 2 are + solid (a differing pixel whose four neighbours differ too). Between them + the two catch a dropped 4 px point, a dropped 2 px line and a 1 px shift + of the view, and pass any MSAA setting. A change nowhere 3 px thick (a + thin line gone, an item moved a pixel or two) that covers at most 250 px + passes. `visual.js` has the measurements. + The page shows the capture, the reference and the difference side by side. + +`window.conformance.runScenes()` and `runLogic()` do the same from a script. +Browser checks use the chrome-devtools MCP against a page in its own window. + +The Cesium scene renderer (`src/render/cesium/sceneRenderer.js`) draws the +icon, point, line, shape, model and city scenes, and the references were +recorded with it. It has only the members the scenes need. The quad, mesh, +field and agent scenes have no reference yet. Each family's reference is +recorded when the Cesium renderer gains that family. diff --git a/package-lock.json b/package-lock.json index dd60d3383..e6caa40f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "prettier": "3.9.6", "puppeteer": "^25.16.0", "sharp": "^0.35.4", + "typescript": "5.9.3", "vite": "^6.4.4", "vite-plugin-cesium": "^1.2.23", "ws": "^8.21.0" @@ -3545,6 +3546,20 @@ "dev": true, "license": "MIT" }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", diff --git a/package.json b/package.json index 9e2e96cfa..aecb52ce5 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "prettier": "3.9.6", "puppeteer": "^25.16.0", "sharp": "^0.35.4", + "typescript": "5.9.3", "vite": "^6.4.4", "vite-plugin-cesium": "^1.2.23", "ws": "^8.21.0" @@ -293,6 +294,9 @@ "./tools/panel": "./src/tools/panel.js", "./tools/services": "./src/tools/services.js", "./view": "./src/view/index.js", - "./agent/world-packet": "./src/agent/worldPacket.js" + "./agent/world-packet": "./src/agent/worldPacket.js", + "./render/contract": "./src/render/contract/index.js", + "./render/stub": "./src/render/stub/index.js", + "./render/conformance": "./src/render/conformance/index.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 15a0305ef..b3e77482e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1121,5 +1121,26 @@ "src/cameraGlobeRotation.test.mjs", "src/voice/globeResetConfirmation.test.mjs", "src/hudDms.test.mjs", - "server/providers/common/rateLimitKeys.test.mjs" + "server/providers/common/rateLimitKeys.test.mjs", + "src/render/contract/contract.test.mjs", + "src/render/contract/common.d.ts", + "src/render/contract/renderer.d.ts", + "src/render/contract/frame.d.ts", + "src/render/contract/globe.d.ts", + "src/render/contract/draw.d.ts", + "src/render/contract/camera.d.ts", + "src/render/contract/query.d.ts", + "src/render/contract/input.d.ts", + "src/render/contract/effects.d.ts", + "src/render/contract/credits.d.ts", + "src/render/contract/quality.d.ts", + "src/render/contract/index.d.ts", + "src/render/stub/stub.test.mjs", + "src/render/conformance/scenes.test.mjs", + "src/render/cesium/cameraGoldens.test.mjs", + "scripts/record-camera-goldens.mjs", + "server/standalone/conformance-references.js", + "dev/conformance/index.html", + "dev/conformance/main.js", + "docs/RENDERER-CONTRACT.md" ] diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index c424666a2..7baf43dc2 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -479,7 +479,8 @@ function readMarkup(file) { // Text that never reaches the page as UI: tool results and descriptions for // AI clients (MCP and the voice model). -const NOT_UI = [/^src\/tools\//]; +// The conformance suite's check titles are developer output, not UI. +const NOT_UI = [/^src\/tools\//, /^src\/render\/conformance\//]; function sourceFiles() { return trackedFiles( diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9c2c253bd..744e777b3 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -5126,5 +5126,27 @@ "exports": ["./queries/heights"], "modules": ["src/queries/heights.js"], "external": ["cesium"] + }, + "render": { + "exports": ["./render/contract", "./render/stub", "./render/conformance"], + "modules": [ + "src/render/contract/index.js", + "src/render/stub/index.js", + "src/render/conformance/index.js", + "src/render/conformance/scenes.js", + "src/render/conformance/cameraScripts.js", + "src/render/conformance/visual.js", + "src/render/conformance/cameraRunner.js", + "src/render/conformance/expect.js", + "src/render/conformance/checks/ports.js", + "src/render/stub/math.js", + "src/render/stub/motion.js", + "src/render/stub/camera.js", + "src/render/stub/geodesic.js", + "src/render/stub/viewRectangle.js", + "src/render/conformance/goldens/camera.json", + "src/render/conformance/checks/camera.js" + ], + "external": [] } } diff --git a/scripts/record-camera-goldens.mjs b/scripts/record-camera-goldens.mjs new file mode 100644 index 000000000..17f4131bc --- /dev/null +++ b/scripts/record-camera-goldens.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +// Writes the reference camera traces the conformance suite compares a +// renderer against: src/render/conformance/goldens/camera.json. Run it after +// a Cesium upgrade or a change to the camera scripts, and review the diff. +// +// node scripts/record-camera-goldens.mjs [--check] +// +// With --check it writes nothing and exits 1 when the file is out of date. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { recordCameraGoldens } from '../src/render/cesium/cameraGoldens.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const OUT = path.join(ROOT, 'src/render/conformance/goldens/camera.json'); + +const text = `${JSON.stringify(await recordCameraGoldens(), null, 2)}\n`; +if (process.argv.includes('--check')) { + const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : ''; + if (current !== text) { + console.error(`${path.relative(ROOT, OUT)} is out of date`); + process.exit(1); + } +} else { + fs.mkdirSync(path.dirname(OUT), { recursive: true }); + fs.writeFileSync(OUT, text); + console.log(`wrote ${path.relative(ROOT, OUT)}`); +} diff --git a/server/standalone/conformance-references.js b/server/standalone/conformance-references.js new file mode 100644 index 000000000..669fef616 --- /dev/null +++ b/server/standalone/conformance-references.js @@ -0,0 +1,59 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const DIR = fileURLToPath( + new URL('../../src/render/conformance/goldens/visual/', import.meta.url), +); +const NAME = /^[a-z0-9-]+\.(png|json)$/; +const MAX_BYTES = 8 * 1024 * 1024; + +/** + * Dev server only: lets `/dev/conformance/` save the visual references it + * records into `src/render/conformance/goldens/visual/`. Takes a PUT of one + * PNG or the manifest, named in the path; nothing else is written. + */ +export function conformanceReferencesPlugin() { + return { + name: 'conformance-references', + apply: 'serve', + configureServer(server) { + server.middlewares.use('/__conformance/references/', (req, res) => { + const name = decodeURIComponent((req.url ?? '').replace(/^\//, '')); + // Only the page on this machine: a loopback peer, sent from the + // dev server's own origin. + const peer = req.socket.remoteAddress ?? ''; + const loopback = /^(127\.|::1$|::ffff:127\.)/.test(peer); + let sameOrigin = false; + try { + sameOrigin = new URL(req.headers.origin).host === req.headers.host; + } catch { + // No Origin, or not a URL. + } + if (!loopback || !sameOrigin) { + res.writeHead(403).end(); + return; + } + if (req.method !== 'PUT' || !NAME.test(name)) { + res.writeHead(400).end(); + return; + } + const chunks = []; + let size = 0; + req.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_BYTES) req.destroy(); + else chunks.push(chunk); + }); + req.on('end', async () => { + try { + await mkdir(DIR, { recursive: true }); + await writeFile(DIR + name, Buffer.concat(chunks)); + res.writeHead(204).end(); + } catch (error) { + res.writeHead(500).end(String(error?.message ?? error)); + } + }); + }); + }, + }; +} diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index 76d26ba5d..f660bfbd3 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -5,6 +5,7 @@ import { createBrowserViteConfig } from '../../build/vite.js'; import { localProviderPlugins } from '../providers/local.js'; import { localMcpPlugin } from '../mcp/plugin.js'; import { apiNotFoundPlugin } from './api-not-found.js'; +import { conformanceReferencesPlugin } from './conformance-references.js'; import { googleTokenUnavailablePlugin } from './google-token-unavailable.js'; import { standaloneVoiceTools } from './voiceTools.js'; import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js'; @@ -30,6 +31,7 @@ export function standaloneConfig( }), localMcpPlugin(), googleTokenUnavailablePlugin(), + conformanceReferencesPlugin(), apiNotFoundPlugin(), unwatchedRuntimeDirsPlugin(), ], diff --git a/src/queries/heights.test.mjs b/src/queries/heights.test.mjs index 7acf03047..0cd2620be 100644 --- a/src/queries/heights.test.mjs +++ b/src/queries/heights.test.mjs @@ -193,6 +193,9 @@ test('height queries go through the service', () => { // A renderer that answers heights from a cache or asynchronously plugs in // at src/queries/heights.js; a site that asks Cesium directly bypasses it. const SERVICE = 'src/queries/heights.js'; + // The renderer contract's own height members, and the stub and suite that + // implement and call them, are not Cesium. + const CONTRACT = /^src\/render\/(contract|stub|conformance)\//; const NAMES = 'sampleHeight|clampToHeight|sampleHeightMostDetailed|clampToHeightMostDetailed|getHeight'; const direct = new RegExp( @@ -207,7 +210,8 @@ test('height queries go through the service', () => { return /\.(js|vue)$/.test(entry.name) ? [rel] : []; }); const offenders = walk('src').filter((file) => { - if (file === SERVICE) return false; + if (file === SERVICE || CONTRACT.test(file.split(path.sep).join('/'))) + return false; const code = fs .readFileSync(path.join(ROOT, file), 'utf8') .replace(/\/\*[\s\S]*?\*\//g, '') diff --git a/src/render/cesium/cameraGoldens.js b/src/render/cesium/cameraGoldens.js new file mode 100644 index 000000000..f68665464 --- /dev/null +++ b/src/render/cesium/cameraGoldens.js @@ -0,0 +1,316 @@ +/** + * @module render/cesium/cameraGoldens + * @description Records the reference camera traces: every conformance camera + * script run on Cesium's own `Camera`, headless, on a virtual clock that + * advances exactly 1/60 s per frame and restarts per script. No globe and no + * tiles, so the traces depend on the camera alone and are bit-identical run + * to run. `scripts/record-camera-goldens.mjs` writes them to + * `src/render/conformance/goldens/camera.json`. + * + * Cesium has one `changed` threshold per camera, where the contract has one + * per listener, so a script that listens at several thresholds is run once + * per threshold and each count is taken from its own run. + */ +import * as Cesium from 'cesium'; +import { + CAMERA_SCRIPTS, + SCRIPT_VIEWPORT, +} from '../conformance/cameraScripts.js'; +import { runCameraScript } from '../conformance/cameraRunner.js'; +import { updateCameraChanged } from './internals.js'; + +const FRAME_MS = 1000 / 60; + +/** The contract's easings as Cesium's. */ +const EASINGS = { + linear: Cesium.EasingFunction.LINEAR_NONE, + quadraticInOut: Cesium.EasingFunction.QUADRATIC_IN_OUT, + cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT, + cubicOut: Cesium.EasingFunction.CUBIC_OUT, + quinticInOut: Cesium.EasingFunction.QUINTIC_IN_OUT, +}; + +/** Run `fn` with `performance.now()` reading the virtual clock. */ +async function withVirtualClock(fn) { + const real = Object.getOwnPropertyDescriptor(globalThis, 'performance'); + const clock = { now: 0 }; + Object.defineProperty(globalThis, 'performance', { + configurable: true, + value: { now: () => clock.now, mark() {}, measure() {} }, + }); + try { + return await fn(clock); + } finally { + if (real) Object.defineProperty(globalThis, 'performance', real); + } +} + +function headlessCamera() { + const scene = { + canvas: { + clientWidth: SCRIPT_VIEWPORT.width, + clientHeight: SCRIPT_VIEWPORT.height, + }, + drawingBufferWidth: SCRIPT_VIEWPORT.width, + drawingBufferHeight: SCRIPT_VIEWPORT.height, + pixelRatio: 1, + mapProjection: new Cesium.GeographicProjection(), + mode: Cesium.SceneMode.SCENE3D, + tweens: new Cesium.TweenCollection(), + globe: undefined, + ellipsoid: Cesium.Ellipsoid.WGS84, + screenSpaceCameraController: { + minimumZoomDistance: 1, + maximumZoomDistance: Number.POSITIVE_INFINITY, + enableInputs: true, + }, + frameState: { mode: Cesium.SceneMode.SCENE3D }, + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + camera.frustum.aspectRatio = SCRIPT_VIEWPORT.width / SCRIPT_VIEWPORT.height; + return { scene, camera }; +} + +const cart = (p) => new Cesium.Cartesian3(p.x, p.y, p.z); +const toEcef = (p) => + 'lon' in p + ? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height ?? 0) + : cart(p); + +function destinationOf(destination) { + if (!destination) return undefined; + if ('rect' in destination) { + const r = destination.rect; + return Cesium.Rectangle.fromDegrees(r.west, r.south, r.east, r.north); + } + return toEcef(destination); +} + +function orientationOf(orientation) { + if (!orientation) return undefined; + if ('direction' in orientation) + return { direction: cart(orientation.direction), up: cart(orientation.up) }; + return { ...orientation }; +} + +/** A driver for {@link runCameraScript} over a headless Cesium camera. */ +function cesiumDriver(clock, threshold) { + const { scene, camera } = headlessCamera(); + let flying = false; + let changedCount = 0; + camera.percentageChanged = threshold ?? 0.5; + if (threshold !== undefined) + camera.changed.addEventListener(() => (changedCount += 1)); + + function flight(start) { + let resolve; + const done = new Promise((r) => (resolve = r)); + flying = true; + start( + () => { + flying = false; + resolve('complete'); + }, + () => { + flying = false; + resolve('cancelled'); + }, + ); + return { done, cancel: () => camera.cancelFlight() }; + } + + const commands = { + setView({ destination, orientation } = {}) { + camera.setView({ + destination: destinationOf(destination), + orientation: orientationOf(orientation), + }); + }, + lookAt(target, offset) { + camera.lookAt( + cart(target), + new Cesium.HeadingPitchRange( + offset.heading, + offset.pitch, + offset.range, + ), + ); + }, + release() { + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + }, + move(direction, metres) { + const name = { + forward: 'moveForward', + back: 'moveBackward', + left: 'moveLeft', + right: 'moveRight', + up: 'moveUp', + down: 'moveDown', + }[direction]; + camera[name](metres); + }, + look(direction, radians) { + const name = { + left: 'lookLeft', + right: 'lookRight', + up: 'lookUp', + down: 'lookDown', + }[direction]; + camera[name](radians); + }, + rotateAround(axis, radians) { + camera.rotate(cart(axis), radians); + }, + zoom(metres) { + camera.zoomIn(metres); + }, + flyTo(destination, options = {}) { + return flight((complete, cancel) => + camera.flyTo({ + destination: destinationOf(destination), + orientation: orientationOf(options.orientation), + duration: options.durationS, + maximumHeight: options.maxHeight, + pitchAdjustHeight: options.pitchAdjustHeight, + easingFunction: EASINGS[options.easing], + complete, + cancel, + }), + ); + }, + flyToBounds(sphere, options = {}) { + return flight((complete, cancel) => + camera.flyToBoundingSphere( + new Cesium.BoundingSphere(cart(sphere.center), sphere.radius), + { + offset: options.offset + ? new Cesium.HeadingPitchRange( + options.offset.heading, + options.offset.pitch, + options.offset.range, + ) + : undefined, + duration: options.durationS, + complete, + cancel, + }, + ), + ); + }, + cancelFlight() { + camera.cancelFlight(); + }, + }; + + const viewProjection = () => + Cesium.Matrix4.multiply( + camera.frustum.projectionMatrix, + camera.viewMatrix, + new Cesium.Matrix4(), + ); + + return { + command: (name, args) => commands[name](...args), + frame() { + clock.now += FRAME_MS; + scene.tweens.update(); + updateCameraChanged(camera); + }, + listen: () => () => changedCount, + pose() { + const c = camera.positionCartographic; + return { + position: camera.positionWC, + direction: camera.directionWC, + up: camera.upWC, + right: camera.rightWC, + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + height: c.height, + near: camera.frustum.near, + far: camera.frustum.far, + frameIsIdentity: Cesium.Matrix4.equals( + camera.transform, + Cesium.Matrix4.IDENTITY, + ), + }; + }, + viewRectangle() { + const rect = camera.computeViewRectangle(Cesium.Ellipsoid.WGS84); + if (!rect) return null; + const deg = Cesium.Math.toDegrees; + return { + west: deg(rect.west), + south: deg(rect.south), + east: deg(rect.east), + north: deg(rect.north), + }; + }, + matrices: () => ({ + view: Float64Array.from(Cesium.Matrix4.toArray(camera.viewMatrix)), + projection: Float64Array.from( + Cesium.Matrix4.toArray(camera.frustum.projectionMatrix), + ), + }), + project(position) { + const clip = Cesium.Matrix4.multiplyByVector( + viewProjection(), + new Cesium.Cartesian4(position.x, position.y, position.z, 1), + new Cesium.Cartesian4(), + ); + if (clip.w <= 0) return null; + return { + x: ((clip.x / clip.w + 1) / 2) * SCRIPT_VIEWPORT.width, + y: ((1 - clip.y / clip.w) / 2) * SCRIPT_VIEWPORT.height, + }; + }, + pickEllipsoid: (s) => + camera.pickEllipsoid(new Cesium.Cartesian2(s.x, s.y)) ?? null, + pickRay: (s) => camera.getPickRay(new Cesium.Cartesian2(s.x, s.y)), + occluded: (p) => + !new Cesium.EllipsoidalOccluder( + Cesium.Ellipsoid.WGS84, + camera.positionWC, + ).isPointVisible(p), + flying: () => flying, + toEcef: (p) => { + const e = toEcef(p); + return { x: e.x, y: e.y, z: e.z }; + }, + }; +} + +/** Run one script on Cesium, once per `changed` threshold it listens at. */ +async function recordScript(script) { + const thresholds = script.steps + .filter((step) => step.do === 'listen') + .map((step) => step.threshold); + const base = await withVirtualClock((clock) => + runCameraScript(cesiumDriver(clock, undefined), script), + ); + if (!thresholds.length) return base; + const counts = []; + for (const threshold of thresholds) { + const run = await withVirtualClock((clock) => + runCameraScript(cesiumDriver(clock, threshold), script), + ); + const changed = run.records.find((r) => r.kind === 'changed'); + counts.push({ threshold, count: changed.value[0].count }); + } + for (const r of base.records) if (r.kind === 'changed') r.value = counts; + return base; +} + +/** + * Every script's trace on the installed Cesium. + * @returns {Promise<{cesium: string, viewport: object, scripts: Record}>} + */ +export async function recordCameraGoldens() { + const scripts = {}; + for (const script of CAMERA_SCRIPTS) + scripts[script.name] = await recordScript(script); + return { cesium: Cesium.VERSION, viewport: SCRIPT_VIEWPORT, scripts }; +} diff --git a/src/render/cesium/cameraGoldens.test.mjs b/src/render/cesium/cameraGoldens.test.mjs new file mode 100644 index 000000000..774b0a011 --- /dev/null +++ b/src/render/cesium/cameraGoldens.test.mjs @@ -0,0 +1,16 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { recordCameraGoldens } from './cameraGoldens.js'; + +test('the committed camera traces are what the installed Cesium records', async () => { + const committed = readFileSync( + new URL('../conformance/goldens/camera.json', import.meta.url), + 'utf8', + ); + const recorded = `${JSON.stringify(await recordCameraGoldens(), null, 2)}\n`; + assert.ok( + recorded === committed, + 'goldens/camera.json is out of date: run node scripts/record-camera-goldens.mjs and review the diff', + ); +}); diff --git a/src/render/cesium/internals.js b/src/render/cesium/internals.js index dd4ff2d0b..c7585803a 100644 --- a/src/render/cesium/internals.js +++ b/src/render/cesium/internals.js @@ -84,3 +84,12 @@ export function webglContext(scene) { export function cameraFlightActive(camera) { return camera?._currentFlight != null; } + +/** + * Run the camera's `changed` check, as a scene does once a frame. For a + * camera driven without a scene (the reference camera traces). + * @param {object} camera Cesium Camera. + */ +export function updateCameraChanged(camera) { + camera._updateCameraChanged(); +} diff --git a/src/render/cesium/internals.test.mjs b/src/render/cesium/internals.test.mjs index 6331163c0..fb596d152 100644 --- a/src/render/cesium/internals.test.mjs +++ b/src/render/cesium/internals.test.mjs @@ -12,6 +12,7 @@ import { commandExtents, defaultView, pickOffscreenView, + updateCameraChanged, webglContext, } from './internals.js'; @@ -47,6 +48,8 @@ test('every private field Core reaches is still declared by the installed Cesium ), ); assert.deepEqual(missing, []); + const camera = fs.readFileSync(path.join(ENGINE, 'Scene/Camera.js'), 'utf8'); + assert.match(camera, /Camera\.prototype\._updateCameraChanged = function/); }); test('the helpers read and write exactly those fields', () => { @@ -76,6 +79,9 @@ test('the helpers read and write exactly those fields', () => { assert.equal(webglContext({ context: { _gl: gl } }), gl); assert.equal(cameraFlightActive({ _currentFlight: {} }), true); assert.equal(cameraFlightActive({ _currentFlight: undefined }), false); + let checked = 0; + updateCameraChanged({ _updateCameraChanged: () => (checked += 1) }); + assert.equal(checked, 1); }); test('private Cesium fields are reached only through internals.js', () => { diff --git a/src/render/cesium/sceneRenderer.js b/src/render/cesium/sceneRenderer.js new file mode 100644 index 000000000..17376eb7c --- /dev/null +++ b/src/render/cesium/sceneRenderer.js @@ -0,0 +1,656 @@ +/** + * @module render/cesium/sceneRenderer + * @description The part of the Cesium renderer the reference scenes need, + * through the contract's own members: a bare globe and sky, icons, points, + * lines, shapes and models, `setView`, picks and projection, the frame + * phases, settling and capture. The `/dev/conformance/` page draws the + * scenes with it to make and check the visual references. + * + * It is not yet the whole Cesium renderer: each later migration adds the + * members its layer family needs, until it passes the whole suite. + * Members it does not have are absent, never stubbed. + */ +import * as Cesium from 'cesium'; + +const SKY_DEFAULTS = Object.freeze({ + atmosphere: false, + stars: false, + milkyWay: 0, + sun: false, + moon: false, + fog: false, + lighting: false, +}); + +/** The contract's colour as Cesium's. */ +export function toColor(color, alpha = 1) { + if (color === undefined || color === null) + return Cesium.Color.WHITE.withAlpha(alpha); + const c = + typeof color === 'string' + ? Cesium.Color.fromCssColorString(color) + : new Cesium.Color(color[0], color[1], color[2], color[3] ?? 1); + return c.withAlpha(c.alpha * alpha); +} + +/** An image reference as something a billboard takes. */ +export function toImage(image) { + if (typeof image === 'string') return image; + if (image?.svg) + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(image.svg)}`; + if (image?.canvas) return image.canvas; + throw new Error('the scene renderer takes URL, SVG and canvas images only'); +} + +/** A contract position as ECEF. */ +export function toCartesian(pos) { + if ('x' in pos) return new Cesium.Cartesian3(pos.x, pos.y, pos.z); + return Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, pos.height ?? 0); +} + +const ANCHORS = { + center: Cesium.VerticalOrigin.CENTER, + bottom: Cesium.VerticalOrigin.BOTTOM, + top: Cesium.VerticalOrigin.TOP, +}; + +/** + * @param {{container: HTMLElement, msaa?: number}} options + * @returns {Promise} A renderer with the members listed above. + */ +export async function createCesiumSceneRenderer({ container, msaa = 4 }) { + const credits = document.createElement('div'); + const widget = new Cesium.CesiumWidget(container, { + baseLayer: false, + terrainProvider: new Cesium.EllipsoidTerrainProvider(), + skyBox: false, + skyAtmosphere: false, + requestRenderMode: true, + maximumRenderTimeChange: Infinity, + msaaSamples: msaa, + creditContainer: credits, + contextOptions: { webgl: { preserveDrawingBuffer: true } }, + }); + const scene = widget.scene; + const camera = scene.camera; + // A fixed time, so lighting is the same in every capture. + widget.clock.shouldAnimate = false; + widget.clock.currentTime = Cesium.JulianDate.fromIso8601( + '2026-06-21T18:00:00Z', + ); + scene.globe.baseColor = Cesium.Color.fromCssColorString('#1c2a36'); + scene.backgroundColor = Cesium.Color.BLACK; + scene.globe.showGroundAtmosphere = false; + scene.fog.enabled = false; + if (scene.sun) scene.sun.show = false; + if (scene.moon) scene.moon.show = false; + + const sets = new Map(); + const sky = { ...SKY_DEFAULTS }; + const capabilities = new Set(['models']); + + function applySky() { + scene.globe.enableLighting = sky.lighting; + scene.fog.enabled = sky.fog; + if (scene.sun) scene.sun.show = sky.sun; + if (scene.moon) scene.moon.show = sky.moon; + scene.globe.showGroundAtmosphere = sky.atmosphere; + scene.requestRender(); + } + + const frameListeners = { preUpdate: [], preRender: [], postRender: [] }; + let frames = 0; + const onPhase = (phase) => (_scene, time) => { + const t = { + frame: frames, + time: Cesium.JulianDate.toDate( + time ?? widget.clock.currentTime, + ).getTime(), + now: performance.now(), + dt: 1 / 60, + rendering: phase !== 'preUpdate', + }; + for (const cb of frameListeners[phase].slice()) cb(t); + }; + const removers = [ + scene.preUpdate.addEventListener(() => (frames += 1)), + scene.preUpdate.addEventListener(onPhase('preUpdate')), + scene.preRender.addEventListener(onPhase('preRender')), + scene.postRender.addEventListener(onPhase('postRender')), + ]; + + function nextPostRender() { + return new Promise((resolve) => { + const off = scene.postRender.addEventListener(() => { + off(); + resolve(); + }); + scene.requestRender(); + }); + } + + const frame = { + on(phase, cb) { + frameListeners[phase].push(cb); + return () => { + const i = frameListeners[phase].indexOf(cb); + if (i >= 0) frameListeners[phase].splice(i, 1); + }; + }, + requestRender: () => scene.requestRender(), + renderNow: () => scene.render(widget.clock.currentTime), + renderOnce: () => nextPostRender(), + time: () => Cesium.JulianDate.toDate(widget.clock.currentTime).getTime(), + setTime(ms) { + widget.clock.currentTime = Cesium.JulianDate.fromDate(new Date(ms)); + widget.clock.shouldAnimate = false; + scene.requestRender(); + }, + }; + + // --- Draw ------------------------------------------------------------------ + + function makeSet(kind, spec, build) { + if (sets.has(spec.id)) throw new Error(`a set with id ${spec.id} is live`); + const items = new Map(); + const listeners = { ready: [] }; + const record = { kind, spec, items, listeners, host: null, rebuild: null }; + const handle = { + id: spec.id, + set(list) { + items.clear(); + for (const item of list) items.set(item.id, item); + build(record); + scene.requestRender(); + }, + upsert(list) { + for (const item of list) items.set(item.id, item); + build(record); + scene.requestRender(); + }, + remove(ids) { + for (const id of ids) items.delete(id); + build(record); + scene.requestRender(); + }, + clear() { + items.clear(); + build(record); + scene.requestRender(); + }, + setOptions(options) { + Object.assign(spec, options); + if (record.host) record.host.show = spec.show !== false; + scene.requestRender(); + }, + size: () => items.size, + get show() { + return spec.show !== false; + }, + set show(value) { + spec.show = value; + if (record.host) record.host.show = value; + scene.requestRender(); + }, + on(event, cb) { + listeners[event]?.push(cb); + return () => { + const list = listeners[event]; + const i = list?.indexOf(cb) ?? -1; + if (i >= 0) list.splice(i, 1); + }; + }, + dispose() { + if (record.host && !record.host.isDestroyed?.()) + scene.primitives.remove(record.host); + record.host = null; + // A newer set under the same id is not this handle's to remove. + if (sets.get(spec.id) === record) sets.delete(spec.id); + scene.requestRender(); + }, + }; + sets.set(spec.id, record); + return handle; + } + + const pickId = (record, item) => ({ + set: record.spec.id, + id: item.id, + meta: item.meta, + }); + + function replaceHost(record, host) { + if (record.host) scene.primitives.remove(record.host); + record.host = host; + host.show = record.spec.show !== false; + scene.primitives.add(host); + } + + function buildIcons(record) { + const host = new Cesium.BillboardCollection({ scene }); + for (const item of record.items.values()) { + const position = toCartesian(item.pos); + const options = { + position, + image: toImage(item.image), + width: item.size[0] * (item.scale ?? 1), + height: item.size[1] * (item.scale ?? 1), + color: toColor(item.color, item.alpha ?? 1), + verticalOrigin: ANCHORS[item.anchor ?? 'center'], + disableDepthTestDistance: item.disableDepthTest ?? 0, + show: item.show !== false, + id: pickId(record, item), + }; + if (item.rotationMode === 'screen') options.rotation = item.rotation ?? 0; + if (item.rotationMode === 'course') { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position); + options.alignedAxis = Cesium.Matrix4.multiplyByPointAsVector( + enu, + Cesium.Cartesian3.UNIT_Y, + new Cesium.Cartesian3(), + ); + options.rotation = -Cesium.Math.toRadians(item.course ?? 0); + } + host.add(options); + } + replaceHost(record, host); + } + + function buildPoints(record) { + const host = new Cesium.PointPrimitiveCollection(); + for (const item of record.items.values()) + host.add({ + position: toCartesian(item.pos), + pixelSize: item.pixelSize, + color: toColor(item.color), + outlineColor: toColor(item.outlineColor ?? [0, 0, 0, 0]), + outlineWidth: item.outlineWidth ?? 0, + disableDepthTestDistance: item.disableDepthTest ?? 0, + show: item.show !== false, + id: pickId(record, item), + }); + replaceHost(record, host); + } + + function lineMaterial(style) { + switch (style.kind) { + case 'solid': + return Cesium.Material.fromType('Color', { + color: toColor(style.color), + }); + case 'glow': + return Cesium.Material.fromType('PolylineGlow', { + color: toColor(style.color), + glowPower: style.power, + taperPower: style.taper ?? 1, + }); + case 'dash': + return Cesium.Material.fromType('PolylineDash', { + color: toColor(style.color), + gapColor: toColor(style.gapColor ?? [0, 0, 0, 0]), + dashLength: style.length, + ...(style.pattern16 !== undefined + ? { dashPattern: style.pattern16 } + : {}), + }); + case 'arrow': + return Cesium.Material.fromType('PolylineArrow', { + color: toColor(style.color), + }); + default: + throw new Error(`the scene renderer has no ${style.kind} lines`); + } + } + + function linePositions(item) { + const flat = item.positions instanceof Float64Array; + const points = []; + const n = flat ? item.positions.length / 3 : item.positions.length; + for (let i = 0; i < n; i++) + points.push( + flat + ? new Cesium.Cartesian3( + item.positions[i * 3], + item.positions[i * 3 + 1], + item.positions[i * 3 + 2], + ) + : toCartesian(item.positions[i]), + ); + if (item.mode !== 'geodesic') return points; + const dense = []; + for (let i = 0; i + 1 < points.length; i++) { + const a = Cesium.Cartographic.fromCartesian(points[i]); + const b = Cesium.Cartographic.fromCartesian(points[i + 1]); + const geodesic = new Cesium.EllipsoidGeodesic(a, b); + for (let k = 0; k < 64; k++) { + const s = k / 64; + const at = geodesic.interpolateUsingFraction(s); + dense.push( + Cesium.Cartesian3.fromRadians( + at.longitude, + at.latitude, + a.height + (b.height - a.height) * s, + ), + ); + } + } + dense.push(points.at(-1)); + return dense; + } + + function buildLines(record) { + const host = new Cesium.PolylineCollection(); + for (const item of record.items.values()) { + if (item.mode === 'draped') + throw new Error('the scene renderer has no draped lines'); + host.add({ + positions: linePositions(item), + width: item.widthPx, + material: lineMaterial(item.style), + show: item.show !== false, + id: pickId(record, item), + }); + } + replaceHost(record, host); + } + + const heightOf = (pos) => + 'x' in pos + ? Cesium.Cartographic.fromCartesian(toCartesian(pos)).height + : (pos.height ?? 0); + + function shapeGeometry(item) { + const g = item.geometry; + const extrudedHeight = item.extrudeHeight; + switch (g.kind) { + case 'polygon': { + const [outer, ...holes] = g.rings.map((ring) => ring.map(toCartesian)); + const hierarchy = new Cesium.PolygonHierarchy( + outer, + holes.map((h) => new Cesium.PolygonHierarchy(h)), + ); + return { + fill: new Cesium.PolygonGeometry({ + polygonHierarchy: hierarchy, + height: g.perPositionHeight ? undefined : heightOf(g.rings[0][0]), + extrudedHeight, + perPositionHeight: g.perPositionHeight, + }), + ring: outer, + }; + } + case 'ellipse': + return { + fill: new Cesium.EllipseGeometry({ + center: toCartesian(g.center), + semiMajorAxis: g.semiMajor, + semiMinorAxis: g.semiMinor, + rotation: g.rotation ?? 0, + height: heightOf(g.center), + extrudedHeight, + }), + }; + case 'rectangle': + return { + fill: new Cesium.RectangleGeometry({ + rectangle: Cesium.Rectangle.fromDegrees( + g.rect.west, + g.rect.south, + g.rect.east, + g.rect.north, + ), + height: g.height ?? 0, + extrudedHeight, + }), + }; + case 'box': { + const d = g.dimensions; + return { + fill: Cesium.BoxGeometry.fromDimensions({ + dimensions: new Cesium.Cartesian3(d.x, d.y, d.z), + }), + modelMatrix: Cesium.Transforms.eastNorthUpToFixedFrame( + toCartesian(g.center), + ), + }; + } + default: + throw new Error(`the scene renderer has no ${g.kind} shapes`); + } + } + + function buildShapes(record) { + const host = new Cesium.PrimitiveCollection(); + const outlines = new Cesium.PolylineCollection(); + for (const item of record.items.values()) { + if (item.show === false) continue; + if ( + item.fill && + typeof item.fill === 'object' && + !Array.isArray(item.fill) + ) + throw new Error('the scene renderer fills shapes with colours only'); + const shape = shapeGeometry(item); + const id = pickId(record, item); + const solid = + item.extrudeHeight !== undefined || item.geometry.kind === 'box'; + if (item.fill && !item.outlineOnly) { + const color = toColor(item.fill); + host.add( + new Cesium.Primitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry: shape.fill, + modelMatrix: shape.modelMatrix, + attributes: { + color: Cesium.ColorGeometryInstanceAttribute.fromColor(color), + }, + id, + }), + appearance: new Cesium.PerInstanceColorAppearance({ + translucent: color.alpha < 1, + closed: solid, + // Flat shapes unlit: lit, their two triangles shade apart. + flat: !solid, + }), + asynchronous: false, + }), + ); + } + if (item.outline && shape.ring) + outlines.add({ + positions: [...shape.ring, shape.ring[0]], + width: item.outlineWidth ?? 1, + material: Cesium.Material.fromType('Color', { + color: toColor(item.outline), + }), + id, + }); + } + host.add(outlines); + replaceHost(record, host); + } + + function buildModels(record) { + const host = new Cesium.PrimitiveCollection(); + replaceHost(record, host); + // A model counts as loading until it is ready or fails, not when added; + // each model settles once, and a failure is kept for `whenSettled`. + host.loading = record.items.size; + host.failed = 0; + for (const item of record.items.values()) { + let settled = false; + const settle = (failed) => { + if (settled) return; + settled = true; + host.loading -= 1; + if (failed) host.failed += 1; + scene.requestRender(); + }; + const o = item.orientation; + const modelMatrix = Cesium.Transforms.headingPitchRollToFixedFrame( + toCartesian(item.pos), + new Cesium.HeadingPitchRoll(o.heading, o.pitch, o.roll), + ); + Cesium.Model.fromGltfAsync({ + url: item.url, + modelMatrix, + scale: item.scale ?? 1, + minimumPixelSize: item.minPixelSize ?? 0, + // All textures before ready, so a settled capture is the whole model. + incrementallyLoadTextures: false, + id: pickId(record, item), + }).then( + (model) => { + if (record.host !== host) return model.destroy(); + host.add(model); + model.readyEvent.addEventListener(() => { + settle(false); + for (const cb of record.listeners.ready.slice()) cb(item.id); + }); + model.errorEvent.addEventListener(() => settle(true)); + scene.requestRender(); + }, + () => settle(true), + ); + } + } + + const draw = { + icons: (spec) => makeSet('icons', { ...spec }, buildIcons), + points: (spec) => makeSet('points', { ...spec }, buildPoints), + lines: (spec) => makeSet('lines', { ...spec }, buildLines), + shapes: (spec) => makeSet('shapes', { ...spec }, buildShapes), + models: (spec) => makeSet('models', { ...spec }, buildModels), + }; + + // --- Globe, camera, query -------------------------------------------------- + + const globe = { + async setBase(base) { + if ( + base.photoreal || + (base.terrain && base.terrain.kind !== 'ellipsoid') || + base.imagery?.length + ) + throw new Error('the scene renderer draws a bare globe only'); + return { photoreal: { route: 'none' }, imagery: [], settled: true }; + }, + setGlobeVisible(visible) { + scene.globe.show = visible; + scene.requestRender(); + }, + sky: { + get: () => ({ ...sky }), + set(settings) { + Object.assign(sky, settings); + applySky(); + }, + }, + isSettled: () => scene.globe.tilesLoaded, + }; + + const cameraPort = { + setView(spec) { + const d = spec.destination; + if (d && 'rect' in d) + throw new Error('the scene renderer takes position destinations only'); + const o = spec.orientation ?? {}; + camera.setView({ + ...(d ? { destination: toCartesian(d) } : {}), + orientation: { + heading: o.heading ?? 0, + pitch: o.pitch ?? -Math.PI / 2, + roll: o.roll ?? 0, + }, + }); + scene.requestRender(); + }, + pose() { + const v = (c) => ({ x: c.x, y: c.y, z: c.z }); + return { + position: v(camera.positionWC), + direction: v(camera.directionWC), + up: v(camera.upWC), + right: v(camera.rightWC), + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + height: camera.positionCartographic.height, + fovy: camera.frustum.fovy, + aspectRatio: camera.frustum.aspectRatio, + near: camera.frustum.near, + far: camera.frustum.far, + frameIsIdentity: Cesium.Matrix4.equals( + camera.transform, + Cesium.Matrix4.IDENTITY, + ), + }; + }, + }; + + const query = { + pick(x, y) { + const picked = scene.pick(new Cesium.Cartesian2(x, y)); + const id = picked?.id; + return id && typeof id.set === 'string' + ? { ...id, surface: false } + : null; + }, + project(position) { + const s = Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + toCartesian(position), + ); + return s ? { x: s.x, y: s.y } : null; + }, + pickEllipsoid(x, y) { + const c = camera.pickEllipsoid(new Cesium.Cartesian2(x, y)); + return c ? { x: c.x, y: c.y, z: c.z } : null; + }, + }; + + // --- Renderer -------------------------------------------------------------- + + async function whenSettled({ timeoutMs = 20000 } = {}) { + const models = () => + [...sets.values()].filter((r) => r.kind === 'models' && r.host); + const end = performance.now() + timeoutMs; + let quiet = 0; + while (performance.now() < end) { + await nextPostRender(); + if (models().some((r) => r.host.failed > 0)) + return { state: 'failed', blockers: ['models'] }; + const loading = models().some((r) => r.host.loading > 0); + quiet = scene.globe.tilesLoaded && !loading ? quiet + 1 : 0; + if (quiet >= 3) return { state: 'settled', blockers: [] }; + } + const blockers = []; + if (!scene.globe.tilesLoaded) blockers.push('globe'); + if (models().some((r) => r.host.loading > 0)) blockers.push('models'); + return { state: 'timeout', blockers }; + } + + return { + id: 'cesium', + contractVersion: '0', + capabilities, + canvas: scene.canvas, + frame, + globe, + draw, + camera: cameraPort, + query, + async capture({ type = 'png' } = {}) { + scene.render(widget.clock.currentTime); + return new Promise((resolve) => + scene.canvas.toBlob(resolve, `image/${type}`), + ); + }, + whenSettled, + resize: () => widget.resize(), + destroy() { + for (const off of removers) off(); + widget.destroy(); + }, + }; +} diff --git a/src/render/conformance/cameraRunner.js b/src/render/conformance/cameraRunner.js new file mode 100644 index 000000000..ba534c675 --- /dev/null +++ b/src/render/conformance/cameraRunner.js @@ -0,0 +1,171 @@ +/** + * @module render/conformance/cameraRunner + * @description Runs a camera script ({@link module:render/conformance/cameraScripts}) + * on a driver and collects what it records, in step order. A driver is + * either a renderer through the contract ({@link contractDriver}) or the + * reference recorder's own. + */ + +/** + * @typedef {object} CameraDriver + * @property {(name: string, args: unknown[]) => import('../contract/camera').Flight|void} command + * @property {() => void|Promise} frame One frame, 1/60 s on the script's clock. + * @property {(threshold: number) => () => number} listen Counts `changed`; returns a reader. + * @property {() => object} pose + * @property {(position: object) => object|null} project + * @property {(screen: {x: number, y: number}) => object|null} pickEllipsoid + * @property {(screen: {x: number, y: number}) => object} pickRay + * @property {(position: object) => boolean} occluded + * @property {() => boolean} flying + * @property {(position: object) => object} toEcef + */ + +/** The parts of a pose a trace keeps. */ +export function tracePose(pose) { + return { + position: pick3(pose.position), + direction: pick3(pose.direction), + up: pick3(pose.up), + right: pick3(pose.right), + heading: pose.heading, + pitch: pose.pitch, + roll: pose.roll, + height: pose.height, + frameIsIdentity: pose.frameIsIdentity, + }; +} + +const pick3 = (v) => (v ? { x: v.x, y: v.y, z: v.z } : null); + +/** + * @param {CameraDriver} driver + * @param {{name: string, steps: object[]}} script + * @returns {Promise<{records: object[], flights: object[]}>} + */ +export async function runCameraScript(driver, script) { + const records = []; + const flights = []; + const listeners = []; + let frame = 0; + for (const [index, step] of script.steps.entries()) { + if (step.do === 'frames') { + for (let i = 0; i < step.count; i++) { + frame += 1; + await driver.frame(); + } + } else if (step.do === 'listen') { + listeners.push({ + threshold: step.threshold, + read: driver.listen(step.threshold), + }); + } else if (step.do) { + const args = resolveArgs(driver, step.do, step.args ?? []); + const flight = driver.command(step.do, args); + if (flight && typeof flight.done?.then === 'function') { + const entry = { + step: index, + startFrame: frame, + outcome: null, + frames: null, + }; + flights.push(entry); + flight.done.then((outcome) => { + entry.outcome = outcome; + entry.frames = frame - entry.startFrame; + }); + } + } else if (step.record) { + // Let flight outcomes that settled this frame land first. + await Promise.resolve(); + records.push({ + step: index, + kind: step.record, + value: record(driver, step), + }); + } + } + await Promise.resolve(); + return { records, flights: flights.map(({ startFrame, ...rest }) => rest) }; + + function record(d, step) { + switch (step.record) { + case 'pose': + return tracePose(d.pose()); + case 'project': + return step.points.map((p) => d.project(d.toEcef(p))); + case 'pickEllipsoid': + return step.screen.map((s) => pick3(d.pickEllipsoid(s))); + case 'pickRay': + return step.screen.map((s) => { + const ray = d.pickRay(s); + return { origin: pick3(ray.origin), direction: pick3(ray.direction) }; + }); + case 'occluded': + return step.points.map((p) => d.occluded(d.toEcef(p))); + case 'changed': + return listeners.map((l) => ({ + threshold: l.threshold, + count: l.read(), + })); + case 'flying': + return { flying: d.flying(), pose: tracePose(d.pose()) }; + case 'viewRectangle': + return d.viewRectangle(); + case 'matrices': { + const m = d.matrices(); + const { near, far } = d.pose(); + return { + view: [...m.view], + projection: [...m.projection], + near, + // JSON has no Infinity: an infinite far plane is null. Anything + // else that is not a number stays as it is, to fail the check. + far: far === Infinity ? null : far, + }; + } + default: + throw new Error(`unknown record ${step.record}`); + } + } +} + +/** Geodetic positions in arguments become ECEF where the command takes ECEF. */ +function resolveArgs(driver, name, args) { + if (name === 'lookAt') return [driver.toEcef(args[0]), args[1]]; + if (name === 'flyToBounds') + return [ + { ...args[0], center: driver.toEcef(args[0].center) }, + ...args.slice(1), + ]; + return args; +} + +/** + * A driver over a renderer, through the contract only. + * @param {import('../contract/renderer').Renderer} renderer + * @param {{frame: () => void|Promise, toEcef: (p: object) => object}} hooks + * @returns {CameraDriver} + */ +export function contractDriver(renderer, hooks) { + const camera = renderer.camera; + return { + command(name, args) { + return camera[name](...args); + }, + frame: hooks.frame, + listen(threshold) { + let count = 0; + camera.on('changed', () => (count += 1), { threshold }); + return () => count; + }, + pose: () => camera.pose(), + project: (p) => renderer.query.project(p), + pickEllipsoid: (s) => renderer.query.pickEllipsoid(s.x, s.y), + pickRay: (s) => renderer.query.pickRay(s.x, s.y), + occluded: (p) => renderer.query.isOccluded(p), + flying: () => camera.flying, + matrices: () => camera.matrices(), + viewRectangle: () => camera.viewRectangle(), + toEcef: hooks.toEcef, + }; +} diff --git a/src/render/conformance/cameraScripts.js b/src/render/conformance/cameraScripts.js new file mode 100644 index 000000000..6c1da780c --- /dev/null +++ b/src/render/conformance/cameraScripts.js @@ -0,0 +1,422 @@ +/** + * @module render/conformance/cameraScripts + * @description Camera scripts in the contract's own terms, each a list of + * steps run from a fixed start: commands, frames (1/60 s each, on a clock + * that restarts per script) and records of what the camera reports. + * + * The same scripts are run on the default renderer to make the reference + * traces (`goldens/camera.json`, by `scripts/record-camera-goldens.mjs`) and + * on a renderer under test by the conformance suite, which compares the two. + * + * Step kinds: + * - `{ do: 'setView' | 'lookAt' | 'release' | 'move' | 'look' | 'rotateAround' | 'zoom', args }` + * - `{ do: 'flyTo' | 'flyToBounds', args }`: starts a flight; its outcome and + * the frame it ended on are recorded when it ends. + * - `{ do: 'cancelFlight' }` + * - `{ do: 'listen', threshold }`: counts `changed` with that threshold. + * - `{ do: 'frames', count }` + * - `{ record: 'pose' }`, `{ record: 'project', points }`, + * `{ record: 'pickEllipsoid', screen }`, `{ record: 'pickRay', screen }`, + * `{ record: 'occluded', points }`, `{ record: 'changed' }`, + * `{ record: 'flying' }`, `{ record: 'matrices' }`, `{ record: 'viewRectangle' }`. + */ + +/** Where the scripts, scenes and checks look: downtown Austin, degrees. */ +export const AUSTIN = Object.freeze({ lon: -97.7431, lat: 30.2672 }); +const START = { + do: 'setView', + args: [ + { + destination: { ...AUSTIN, height: 15000000 }, + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }, + ], +}; + +const city = (height, heading, pitch, roll = 0) => ({ + do: 'setView', + args: [ + { + destination: { ...AUSTIN, height }, + orientation: { heading, pitch, roll }, + }, + ], +}); + +const AUSTIN_GROUND = [ + { lon: -97.7431, lat: 30.2672, height: 0 }, + { lon: -97.75, lat: 30.27, height: 150 }, + { lon: -97.735, lat: 30.26, height: 20 }, + { lon: -97.7431, lat: 30.28, height: 0 }, + { lon: -97.72, lat: 30.25, height: 300 }, +]; + +const SCREEN_GRID = [ + { x: 640, y: 360 }, + { x: 10, y: 10 }, + { x: 1270, y: 10 }, + { x: 10, y: 710 }, + { x: 1270, y: 710 }, + { x: 320, y: 540 }, +]; + +/** The viewport every script runs in, CSS pixels. */ +export const SCRIPT_VIEWPORT = Object.freeze({ width: 1280, height: 720 }); + +/** @type {ReadonlyArray<{name: string, steps: object[]}>} */ +export const CAMERA_SCRIPTS = Object.freeze([ + { + name: 'setView angles', + steps: [ + START, + { record: 'pose' }, + city(600, 0.3, -0.6), + { record: 'pose' }, + city(600, 5.9, -0.2, 0.15), + { record: 'pose' }, + city(25000, 3.1, -1.2), + { record: 'pose' }, + { + do: 'setView', + args: [ + { + destination: { lon: 12.5, lat: 89.95, height: 40000 }, + orientation: { heading: 1.0, pitch: -0.7, roll: 0 }, + }, + ], + }, + { record: 'pose' }, + { + do: 'setView', + args: [ + { + destination: { lon: 151.2, lat: -33.87, height: 2000 }, + orientation: { heading: 0.0 }, + }, + ], + }, + { record: 'pose' }, + ], + }, + { + name: 'view rectangles', + steps: [ + START, + { record: 'viewRectangle' }, + ...[ + [5000, 0, -Math.PI / 2], + [2000, 0, -0.3], + [600, 0.3, -0.6], + [2000, 0, 1.2], + [15000000, 0, Math.PI / 2], + ].flatMap(([height, heading, pitch]) => [ + city(height, heading, pitch), + { record: 'viewRectangle' }, + ]), + ...[ + { lon: 0, lat: 89.9, height: 1000000 }, + { lon: 180, lat: 10, height: 2000000 }, + ].flatMap((destination) => [ + { do: 'setView', args: [{ destination }] }, + { record: 'viewRectangle' }, + ]), + ], + }, + { + name: 'view and projection matrices', + steps: [ + START, + { record: 'matrices' }, + city(600, 0.3, -0.6, 0.1), + { record: 'matrices' }, + city(25000, 3.1, -1.2), + { record: 'matrices' }, + ], + }, + { + name: 'setView keeps the position when only orientation is given', + steps: [ + START, + city(800, 1.0, -0.5), + { + do: 'setView', + args: [{ orientation: { heading: 2.0, pitch: -0.3, roll: 0 } }], + }, + { record: 'pose' }, + ], + }, + { + name: 'lookAt sweep and release', + steps: [ + START, + ...Array.from({ length: 12 }, (_, i) => [ + { + do: 'lookAt', + args: [ + { ...AUSTIN, height: 0 }, + { heading: (i * Math.PI) / 6, pitch: -0.4, range: 2000 }, + ], + }, + { record: 'pose' }, + ]).flat(), + { do: 'release' }, + { record: 'pose' }, + ], + }, + { + name: 'move, look, rotate and zoom', + steps: [ + START, + city(600, 0.3, -0.6), + { do: 'move', args: ['forward', 100] }, + { record: 'pose' }, + { do: 'move', args: ['right', 50] }, + { do: 'move', args: ['up', 20] }, + { record: 'pose' }, + { do: 'look', args: ['left', 0.1] }, + { record: 'pose' }, + { do: 'look', args: ['up', 0.05] }, + { record: 'pose' }, + { do: 'rotateAround', args: [{ x: 0, y: 0, z: 1 }, 0.01] }, + { record: 'pose' }, + { do: 'zoom', args: [200] }, + { record: 'pose' }, + { do: 'move', args: ['back', 300] }, + { do: 'look', args: ['down', 0.2] }, + { do: 'look', args: ['right', 0.3] }, + { record: 'pose' }, + ], + }, + { + name: 'commands inside a lookAt frame', + steps: [ + START, + { + do: 'lookAt', + args: [ + { ...AUSTIN, height: 0 }, + { heading: 0.7, pitch: -0.5, range: 3000 }, + ], + }, + { do: 'move', args: ['forward', 500] }, + { record: 'pose' }, + { do: 'rotateAround', args: [{ x: 0, y: 0, z: 1 }, 0.4] }, + { record: 'pose' }, + { do: 'zoom', args: [-1000] }, + { record: 'pose' }, + { do: 'release' }, + { record: 'pose' }, + ], + }, + { + name: 'flyTo from orbit, default orientation and duration', + steps: [ + START, + { do: 'flyTo', args: [{ ...AUSTIN, height: 600 }] }, + { do: 'frames', count: 45 }, + { record: 'flying' }, + { do: 'frames', count: 60 }, + { record: 'flying' }, + { do: 'frames', count: 135 }, + { record: 'pose' }, + ], + }, + { + name: 'flyTo with duration and orientation', + steps: [ + START, + city(5000, 0, -1.0), + { + do: 'flyTo', + args: [ + { lon: -97.7, lat: 30.3, height: 1200 }, + { + durationS: 2, + orientation: { heading: 1.2, pitch: -0.35, roll: 0 }, + }, + ], + }, + { do: 'frames', count: 40 }, + { record: 'flying' }, + { do: 'frames', count: 110 }, + { record: 'pose' }, + ], + }, + { + name: 'flyTo with a height cap, pitch adjustment and named easings', + steps: [ + START, + city(3000, 0.5, -0.4), + { + do: 'flyTo', + args: [ + { lon: 2.35, lat: 48.86, height: 2000 }, + { + durationS: 3, + easing: 'linear', + pitchAdjustHeight: 500000, + // Not straight down: there heading is ill-conditioned, and the + // next flight would start from a last-bit difference in it. + orientation: { heading: 0.2, pitch: -0.6, roll: 0 }, + }, + ], + }, + { do: 'frames', count: 50 }, + { record: 'flying' }, + { do: 'frames', count: 50 }, + { record: 'flying' }, + { do: 'frames', count: 100 }, + { record: 'pose' }, + { + do: 'flyTo', + args: [ + { lon: 2.4, lat: 48.9, height: 800 }, + { durationS: 1, easing: 'cubicOut', maxHeight: 50000 }, + ], + }, + { do: 'frames', count: 20 }, + { record: 'flying' }, + { do: 'frames', count: 50 }, + { record: 'pose' }, + { + do: 'flyTo', + args: [ + { lon: 2.3, lat: 48.8, height: 1500 }, + { durationS: 1, easing: 'quadraticInOut' }, + ], + }, + { do: 'frames', count: 25 }, + { record: 'flying' }, + { do: 'frames', count: 50 }, + { record: 'pose' }, + ], + }, + { + name: 'flyTo with a duration of 0 completes inside the call', + steps: [ + START, + { do: 'flyTo', args: [{ ...AUSTIN, height: 900 }, { durationS: 0 }] }, + { record: 'flying' }, + { do: 'frames', count: 3 }, + { record: 'pose' }, + ], + }, + { + name: 'flyTo where the camera already is completes inside the call', + steps: [ + city(600, 0.3, -0.6), + { + do: 'flyTo', + args: [ + { ...AUSTIN, height: 600 }, + { orientation: { heading: 0.3, pitch: -0.6, roll: 0 } }, + ], + }, + { record: 'flying' }, + ], + }, + { + name: 'rectangle destinations', + steps: [ + START, + ...[ + { west: -98, south: 30, east: -97.5, north: 30.4 }, + { west: -10, south: 0, east: 40, north: 60 }, + { west: -60, south: -20, east: 10, north: 60 }, + { west: 170, south: -50, east: -170, north: -30 }, + { west: -30, south: -89.5, east: 30, north: 89.5 }, + ].flatMap((rect) => [ + { do: 'setView', args: [{ destination: { rect } }] }, + { record: 'pose' }, + ]), + { + do: 'setView', + args: [ + { + destination: { rect: { west: 2, south: 48, east: 3, north: 49 } }, + orientation: { heading: 0.5, pitch: -0.9, roll: 0 }, + }, + ], + }, + { record: 'pose' }, + { + do: 'flyTo', + args: [ + { rect: { west: -10, south: 0, east: 40, north: 60 } }, + { durationS: 1 }, + ], + }, + { do: 'frames', count: 70 }, + { record: 'pose' }, + ], + }, + { + name: 'flyToBounds default offset and explicit offset', + steps: [ + START, + { + do: 'flyToBounds', + args: [{ center: { ...AUSTIN, height: 0 }, radius: 500 }], + }, + { do: 'frames', count: 240 }, + { record: 'pose' }, + { + do: 'flyToBounds', + args: [ + { center: { ...AUSTIN, height: 0 }, radius: 500 }, + { offset: { heading: 1.0, pitch: -0.3, range: 3000 }, durationS: 1 }, + ], + }, + { do: 'frames', count: 90 }, + { record: 'pose' }, + ], + }, + { + name: 'a cancelled flight stops where it was', + steps: [ + START, + { do: 'flyTo', args: [{ ...AUSTIN, height: 600 }, { durationS: 3 }] }, + { do: 'frames', count: 30 }, + { do: 'cancelFlight' }, + { do: 'frames', count: 5 }, + { record: 'flying' }, + ], + }, + { + name: 'changed fires by each listener’s threshold', + steps: [ + START, + city(2000, 0, -0.5), + { do: 'listen', threshold: 0.5 }, + { do: 'listen', threshold: 0.1 }, + { do: 'frames', count: 1 }, + ...Array.from({ length: 24 }, () => [ + { do: 'look', args: ['right', 0.05] }, + { do: 'frames', count: 1 }, + ]).flat(), + { record: 'changed' }, + ], + }, + { + name: 'projection, picks and the horizon', + steps: [ + START, + city(600, 0.3, -0.6), + { record: 'project', points: AUSTIN_GROUND }, + { record: 'pickEllipsoid', screen: SCREEN_GRID }, + { record: 'pickRay', screen: SCREEN_GRID }, + START, + { + record: 'occluded', + points: [ + { lon: -97.7431, lat: 30.2672, height: 0 }, + { lon: 82.26, lat: -30.27, height: 0 }, + { lon: -97.7431, lat: -55, height: 0 }, + { lon: -10, lat: 30, height: 0 }, + { lon: -10, lat: 30, height: 2000000 }, + { lon: 170, lat: 10, height: 30000000 }, + ], + }, + ], + }, +]); diff --git a/src/render/conformance/checks/camera.js b/src/render/conformance/checks/camera.js new file mode 100644 index 000000000..622189a07 --- /dev/null +++ b/src/render/conformance/checks/camera.js @@ -0,0 +1,505 @@ +/** + * Camera checks: the reference traces, then flight, hold, event, follow and + * view rules the traces cannot show. + */ +import goldens from '../goldens/camera.json' with { type: 'json' }; +import { CAMERA_SCRIPTS, SCRIPT_VIEWPORT } from '../cameraScripts.js'; +import { contractDriver, runCameraScript } from '../cameraRunner.js'; +import { deepEqual, equal, near, nearAngle, nearVec, ok } from '../expect.js'; + +const lonLatToEcef = (h) => h.toEcef; + +function comparePose(actual, expected, tol, where) { + nearVec(actual.position, expected.position, tol.metres, `${where} position`); + nearVec(actual.direction, expected.direction, tol.unit, `${where} direction`); + nearVec(actual.up, expected.up, tol.unit, `${where} up`); + nearVec(actual.right, expected.right, tol.unit, `${where} right`); + nearAngle(actual.heading, expected.heading, tol.radians, `${where} heading`); + // Pitch is compared by its sine: straight up or down, the angle itself + // turns a last-bit difference in direction into ~1e-8 rad. + near( + Math.sin(actual.pitch), + Math.sin(expected.pitch), + tol.radians, + `${where} sin(pitch)`, + ); + nearAngle(actual.roll, expected.roll, tol.radians, `${where} roll`); + near(actual.height, expected.height, tol.metres, `${where} height`); + equal( + actual.frameIsIdentity, + expected.frameIsIdentity, + `${where} frameIsIdentity`, + ); +} + +function compareRecord(actual, expected, tol, where, flightPaths) { + equal(actual.kind, expected.kind, `${where} kind`); + const a = actual.value; + const e = expected.value; + switch (expected.kind) { + case 'pose': + comparePose(a, e, tol, where); + break; + case 'project': + e.forEach((s, i) => nearVec(a[i], s, tol.pixels, `${where} point ${i}`)); + break; + case 'pickEllipsoid': + e.forEach((p, i) => nearVec(a[i], p, tol.metres, `${where} pick ${i}`)); + break; + case 'pickRay': + e.forEach((r, i) => { + nearVec(a[i].origin, r.origin, tol.metres, `${where} ray ${i} origin`); + nearVec( + a[i].direction, + r.direction, + tol.unit, + `${where} ray ${i} direction`, + ); + }); + break; + case 'occluded': + deepEqual(a, e, `${where} occluded`); + break; + case 'changed': + e.forEach((c, i) => + near( + a[i].count, + c.count, + tol.changedCount, + `${where} changed at ${c.threshold}`, + ), + ); + break; + case 'viewRectangle': + equal(a === null, e === null, `${where} viewRectangle null`); + if (e) + for (const side of ['west', 'south', 'east', 'north']) + near( + a[side], + e[side], + (tol.radians * 180) / Math.PI, + `${where} viewRectangle ${side}`, + ); + break; + case 'matrices': { + // The view's last column is a translation in metres; the rest is unit. + e.view.forEach((v, i) => + near( + a.view[i], + v, + i >= 12 ? tol.metres : tol.unit, + `${where} view[${i}]`, + ), + ); + // Depth terms come from the renderer's own near and far planes (the + // contract fixes neither, and allows an infinite far plane). + const n = a.near; + const f = a.far; + ok(Number.isFinite(n) && n > 0, `${where} near is a positive number`); + ok( + f === null || (Number.isFinite(f) && f > n), + `${where} far is beyond near, or infinite`, + ); + const depth = + f === null + ? { 10: -1, 14: -2 * n } + : { 10: -(f + n) / (f - n), 14: (-2 * f * n) / (f - n) }; + e.projection.forEach((v, i) => + near( + a.projection[i], + depth[i] ?? v, + tol.unit * Math.max(1, Math.abs(depth[i] ?? v)), + `${where} projection[${i}]`, + ), + ); + break; + } + case 'flying': + equal(a.flying, e.flying, `${where} flying`); + if (flightPaths === 'trace') comparePose(a.pose, e.pose, tol, where); + break; + default: + throw new Error(`unknown record ${expected.kind}`); + } +} + +/** @param {import('../index.js').ConformanceHarness} h */ +async function fresh(h) { + return h.create({ viewport: SCRIPT_VIEWPORT }); +} + +export function cameraChecks() { + const traceChecks = CAMERA_SCRIPTS.map((script) => ({ + id: `camera.trace.${script.name}`, + port: 'camera', + title: `reference trace: ${script.name}`, + async run(h) { + const expected = goldens.scripts[script.name]; + ok(expected, `no reference trace for ${script.name}`); + const r = await fresh(h); + try { + const run = await runCameraScript( + contractDriver(r, { + frame: () => h.frame(r), + toEcef: lonLatToEcef(h), + }), + script, + ); + equal(run.records.length, expected.records.length, 'record count'); + run.records.forEach((record, i) => + compareRecord( + record, + expected.records[i], + h.tolerance, + `${script.name} step ${expected.records[i].step}`, + h.flightPaths, + ), + ); + equal(run.flights.length, expected.flights.length, 'flight count'); + run.flights.forEach((f, i) => { + equal(f.outcome, expected.flights[i].outcome, `flight ${i} outcome`); + near(f.frames, expected.flights[i].frames, 1, `flight ${i} frames`); + }); + } finally { + r.destroy(); + } + }, + })); + + return [ + ...traceChecks, + { + id: 'camera.flight.callbacks', + port: 'camera', + title: 'flight callbacks run synchronously; a new flight cancels the old', + async run(h) { + const r = await fresh(h); + const events = []; + const first = r.camera.flyTo( + { lon: 0, lat: 0, height: 1e6 }, + { + durationS: 1, + onCancel: () => events.push('cancel1'), + onComplete: () => events.push('done1'), + }, + ); + ok(r.camera.flying, 'flying after flyTo'); + r.camera.flyTo( + { lon: 1, lat: 1, height: 1e6 }, + { durationS: 0.5, onComplete: () => events.push('done2') }, + ); + deepEqual( + events, + ['cancel1'], + 'the first flight was cancelled inside the second flyTo', + ); + equal(await first.done, 'cancelled', 'first outcome'); + for (let i = 0; i < 40 && r.camera.flying; i++) await h.frame(r); + deepEqual(events, ['cancel1', 'done2'], 'the second completed'); + ok(!r.camera.flying, 'not flying after completion'); + const third = r.camera.flyTo( + { lon: 2, lat: 2, height: 1e6 }, + { durationS: 1, onCancel: () => events.push('cancel3') }, + ); + r.camera.cancelFlight(); + equal( + events.at(-1), + 'cancel3', + 'cancelFlight runs onCancel inside the call', + ); + equal(await third.done, 'cancelled', 'third outcome'); + r.destroy(); + }, + }, + { + id: 'camera.controls.holds', + port: 'camera', + title: + 'camera input holds are counted; a flight ending gives back only its own', + async run(h) { + const r = await fresh(h); + ok(r.camera.controls.enabled, 'enabled at start'); + const release = r.input.suspendCameraControls('drag'); + ok(!r.camera.controls.enabled, 'a hold disables input'); + r.camera.flyTo({ lon: 0, lat: 0, height: 1e6 }, { durationS: 0.1 }); + for (let i = 0; i < 20 && r.camera.flying; i++) await h.frame(r); + ok(!r.camera.flying, 'the flight ended'); + ok(!r.camera.controls.enabled, 'the drag hold survives the flight end'); + const second = r.camera.controls.suspend('cockpit'); + release(); + release(); + ok(!r.camera.controls.enabled, 'one hold still out'); + second(); + ok(r.camera.controls.enabled, 'enabled with no holds out'); + const [min, max] = r.camera.controls.pitchRange; + ok( + -Math.PI / 2 <= min && min <= max && max <= Math.PI / 2, + 'pitchRange is an ordered range of pitches', + ); + r.camera.controls.pitchRange = [-1.2, -0.1]; + deepEqual( + [...r.camera.controls.pitchRange], + [-1.2, -0.1], + 'pitchRange is settable', + ); + r.destroy(); + }, + }, + { + id: 'camera.onCommand', + port: 'camera', + title: 'every camera command is announced before it runs', + async run(h) { + const r = await fresh(h); + const seen = []; + const off = r.camera.onCommand((c) => seen.push(c.kind)); + const target = h.toEcef({ lon: 0, lat: 0, height: 0 }); + r.camera.setView({ destination: { lon: 0, lat: 0, height: 1000 } }); + r.camera.lookAt(target, { heading: 0, pitch: -0.5, range: 1000 }); + r.camera.release(); + r.camera.flyTo({ lon: 1, lat: 1, height: 1000 }, { durationS: 1 }); + r.camera.cancelFlight(); + r.camera.flyToBounds({ center: target, radius: 10 }, { durationS: 1 }); + r.camera.cancelFlight(); + r.camera.follow({ + key: 'k', + position: () => target, + frame: 'enu', + viewFrom: { x: 0, y: -100, z: 100 }, + }); + off(); + r.camera.follow(null); + deepEqual( + seen, + [ + 'setView', + 'lookAt', + 'release', + 'flyTo', + 'cancelFlight', + 'flyToBounds', + 'cancelFlight', + 'follow', + ], + 'commands', + ); + r.destroy(); + }, + }, + { + id: 'camera.moveEnd', + port: 'camera', + title: 'moveStart on movement; moveEnd 500 ms after the last change', + async run(h) { + const r = await fresh(h); + const events = []; + r.camera.on('moveStart', () => events.push(['start', h.now(r)])); + r.camera.on('moveEnd', () => events.push(['end', h.now(r)])); + await h.frame(r); + r.camera.move('forward', 1000); + await h.frame(r); + equal(events[0]?.[0], 'start', 'moveStart after the move'); + const movedAt = h.now(r); + for (let i = 0; i < 45; i++) await h.frame(r); + const end = events.find(([kind]) => kind === 'end'); + ok(end, 'moveEnd fired'); + ok( + end[1] - movedAt >= 500 - 1e-6, + `moveEnd waited 500 ms (${end[1] - movedAt})`, + ); + ok( + end[1] - movedAt < 500 + 2 * (1000 / 60) + 1e-6, + 'moveEnd came within two frames of 500 ms', + ); + r.destroy(); + }, + }, + { + id: 'camera.follow', + port: 'camera', + title: 'a follow keeps the eye at its offset from a moving target', + async run(h) { + const r = await fresh(h); + const changes = []; + r.camera.on('followChanged', () => + changes.push(r.camera.followed?.key ?? null), + ); + const base = h.toEcef({ lon: -97.74, lat: 30.27, height: 1000 }); + const start = r.frame.time(); + const target = { + key: 'test:mover', + position: (t) => ({ + x: base.x + (t - start) * 0.2, + y: base.y, + z: base.z, + }), + frame: 'enu', + viewFrom: { x: 0, y: -800, z: 400 }, + }; + r.camera.follow(target); + equal(r.camera.followed, target, 'followed'); + await h.frame(r); + ok(r.camera.followFramed, 'framed after a frame'); + const range0 = dist( + r.camera.pose().position, + target.position(r.frame.time()), + ); + near(range0, Math.hypot(800, 400), 1, 'starting range'); + for (let i = 0; i < 30; i++) { + r.frame.setTime(r.frame.time() + 1000); + await h.frame(r); + near( + dist(r.camera.pose().position, target.position(r.frame.time())), + range0, + 1e-3 * range0, + `range at frame ${i}`, + ); + } + r.camera.follow(null); + equal(r.camera.followed, null, 'cleared'); + ok(r.camera.pose().frameIsIdentity, 'back in the world frame'); + deepEqual(changes, ['test:mover', null], 'followChanged'); + r.destroy(); + }, + }, + { + id: 'camera.rect', + port: 'camera', + title: + 'a rectangle destination looks straight down and keeps the whole rectangle in view', + async run(h) { + const r = await fresh(h); + const rect = { west: -98, south: 30, east: -97.5, north: 30.4 }; + r.camera.setView({ destination: { rect } }); + await h.frame(r); + const pose = r.camera.pose(); + near(pose.pitch, -Math.PI / 2, 1e-3, 'pitch'); + for (const [lon, lat] of [ + [rect.west, rect.north], + [rect.east, rect.north], + [rect.west, rect.south], + [rect.east, rect.south], + ]) { + const s = r.query.project(h.toEcef({ lon, lat, height: 0 })); + ok( + s && + s.x >= -1 && + s.x <= SCRIPT_VIEWPORT.width + 1 && + s.y >= -1 && + s.y <= SCRIPT_VIEWPORT.height + 1, + `corner ${lon},${lat} in view (${JSON.stringify(s)})`, + ); + } + r.destroy(); + }, + }, + { + id: 'camera.viewRectangle', + port: 'camera', + title: + 'viewRectangle holds the ground in view, is the whole globe from afar, null facing away', + async run(h) { + const r = await fresh(h); + const at = async (height, pitch = -Math.PI / 2) => { + r.camera.setView({ + destination: { lon: -97.74, lat: 30.27, height }, + orientation: { heading: 0, pitch, roll: 0 }, + }); + await h.frame(r); + return r.camera.viewRectangle(); + }; + const down = await at(5000); + ok( + down && + down.west < -97.74 && + down.east > -97.74 && + down.south < 30.27 && + down.north > 30.27 && + down.east - down.west < 0.2, + `straight down holds the centre (${JSON.stringify(down)})`, + ); + const tilted = await at(2000, -0.3); + ok(tilted && tilted.north > 30.3, 'tilted: cut at the horizon'); + deepEqual( + await at(15000000), + { west: -180, south: -90, east: 180, north: 90 }, + 'from afar: the whole globe', + ); + equal(await at(15000000, Math.PI / 2), null, 'facing away: null'); + r.destroy(); + }, + }, + { + id: 'camera.followItem', + port: 'camera', + title: 'following a drawn item tracks where the renderer draws it', + async run(h) { + const r = await fresh(h); + if (!r.capabilities.has('motionSegments')) { + r.destroy(); + return { skip: 'no motionSegments' }; + } + const t0 = r.frame.time(); + const from = h.toEcef({ lon: -97.74, lat: 30.27, height: 1000 }); + const to = h.toEcef({ lon: -97.7, lat: 30.27, height: 1000 }); + const set = r.draw.icons({ + id: 'conf:follow', + motionClock: { kind: 'scene' }, + }); + set.set([ + { + id: 'm', + pos: from, + image: 'data:,', + size: [16, 16], + motion: { kind: 'lerp', from, to, t0, t1: t0 + 60000 }, + }, + ]); + r.camera.follow({ + key: 'conf:follow:m', + // Deliberately stale: the drawn item is the answer. + position: () => from, + item: { set: 'conf:follow', id: 'm' }, + frame: 'enu', + viewFrom: { x: 0, y: -500, z: 300 }, + }); + await h.frame(r); + const range0 = dist(r.camera.pose().position, from); + r.frame.setTime(t0 + 30000); + await h.frame(r); + const mid = { + x: (from.x + to.x) / 2, + y: (from.y + to.y) / 2, + z: (from.z + to.z) / 2, + }; + near( + dist(r.camera.pose().position, mid), + range0, + 1, + 'the eye keeps its range from the drawn position', + ); + set.dispose(); + r.destroy(); + }, + }, + { + id: 'camera.fov', + port: 'camera', + title: 'setFov sets the vertical field of view', + async run(h) { + const r = await fresh(h); + r.camera.setFov({ vertical: 0.5 }); + near(r.camera.pose().fovy, 0.5, 1e-12, 'fovy'); + near( + r.camera.pose().aspectRatio, + SCRIPT_VIEWPORT.width / SCRIPT_VIEWPORT.height, + 1e-9, + 'aspect', + ); + r.destroy(); + }, + }, + ]; +} + +const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); diff --git a/src/render/conformance/checks/ports.js b/src/render/conformance/checks/ports.js new file mode 100644 index 000000000..7bf3f055a --- /dev/null +++ b/src/render/conformance/checks/ports.js @@ -0,0 +1,1163 @@ +/** + * Checks for every port but the camera: shape, the frame loop, draw sets, + * queries, input, the globe, effects, credits, quality and the renderer. + * Each check reads and drives the renderer through the contract alone. + */ +import { + CAPABILITIES, + CONTRACT_VERSION, + QUALITY_KEYS, + checkPortShape, + checkRendererShape, +} from '../../contract/index.js'; +import { AUSTIN, SCRIPT_VIEWPORT } from '../cameraScripts.js'; +import { deepEqual, equal, near, nearVec, ok, throws } from '../expect.js'; + +const DOT = { + svg: '', +}; + +async function fresh(h) { + return h.create({ viewport: SCRIPT_VIEWPORT }); +} + +/** Looks straight down on Austin from `height` metres. */ +async function overAustin(h, r, height = 2000) { + r.camera.setView({ + destination: { ...AUSTIN, height }, + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + await h.frame(r); +} + +async function drawn(h, r, frames = 2) { + for (let i = 0; i < frames; i++) { + r.frame.requestRender('conformance'); + await h.frame(r); + } +} + +/** + * The listener rules on one subscription list: subscription order, one + * removed during delivery does not run, one added during delivery waits for + * the next event, and a throw goes to `error` while the rest still run. + * `subscribe(cb)` returns its Off; `fire()` raises one event. + */ +async function listenerRules(r, where, subscribe, fire) { + const errors = []; + const offError = r.on('error', (e) => errors.push(e)); + const seen = []; + const offs = []; + let offRemoved = () => {}; + let added = false; + offs.push( + subscribe(() => { + seen.push('first'); + offRemoved(); + if (!added) { + added = true; + offs.push(subscribe(() => seen.push('added'))); + } + }), + ); + offs.push( + subscribe(() => { + throw new Error('listener failure'); + }), + ); + offRemoved = subscribe(() => seen.push('removed')); + offs.push(offRemoved); + offs.push(subscribe(() => seen.push('last'))); + await fire(); + deepEqual( + seen, + ['first', 'last'], + `${where}: in order; removed during delivery does not run; added waits`, + ); + ok(errors.length >= 1, `${where}: the throw went to error`); + seen.length = 0; + await fire(); + deepEqual( + seen, + ['first', 'last', 'added'], + `${where}: one added during delivery runs from the next event`, + ); + for (const off of offs) off(); + offError(); +} + +/** A check over a fresh renderer, destroyed afterwards. */ +function check(id, title, run, extra = {}) { + return { + id, + port: id.split('.')[0], + title, + ...extra, + async run(h) { + const r = await fresh(h); + try { + if (extra.capability && !r.capabilities.has(extra.capability)) + return { skip: `no ${extra.capability}` }; + return await run(h, r); + } finally { + r.destroy(); + } + }, + }; +} + +const POINTER_TYPES = [ + 'down', + 'move', + 'up', + 'click', + 'dblclick', + 'wheel', + 'pinch', +]; + +const SET_KINDS = [ + ['icons', null], + ['points', null], + ['lines', null], + ['shapes', null], + ['models', 'models'], + ['quads', 'quads'], + ['meshes', 'meshes'], + ['agents', 'agents'], +]; + +export function portChecks() { + return [ + // --- Shape ------------------------------------------------------------- + check( + 'renderer.shape', + 'every port has exactly the members its capabilities allow', + (h, r) => { + deepEqual(checkRendererShape(r), [], 'renderer shape'); + equal(r.contractVersion, CONTRACT_VERSION, 'contract version'); + for (const c of r.capabilities) + ok(CAPABILITIES.includes(c), `known capability ${c}`); + for (const [kind, capability] of SET_KINDS) { + if (capability && !r.capabilities.has(capability)) continue; + const set = r.draw[kind]({ id: `shape:${kind}` }); + deepEqual( + checkPortShape(set, 'drawSet', r.capabilities), + [], + `${kind} set shape`, + ); + set.dispose(); + } + const layer = r.globe.imagery.add({ + kind: 'xyz', + url: 'https://example.invalid/{z}/{x}/{y}.png', + }); + deepEqual( + checkPortShape(layer, 'imageryLayer', r.capabilities), + [], + 'imagery layer shape', + ); + layer.remove(); + const flight = r.camera.flyTo( + { ...AUSTIN, height: 1e6 }, + { durationS: 1 }, + ); + deepEqual( + checkPortShape(flight, 'flight', r.capabilities), + [], + 'flight shape', + ); + r.camera.cancelFlight(); + const lease = r.input.claimPointer('conformance'); + deepEqual( + checkPortShape(lease, 'lease', r.capabilities), + [], + 'lease shape', + ); + lease.release(); + }, + ), + check( + 'renderer.listeners', + 'listener order, removal during delivery and a throwing listener', + async (h, r) => { + const errors = []; + r.on('error', (e) => errors.push(e)); + const seen = []; + let offB = () => {}; + r.frame.on('preUpdate', () => { + seen.push('a'); + offB(); + r.frame.on('preUpdate', () => seen.push('late')); + }); + offB = r.frame.on('preUpdate', () => seen.push('b')); + r.frame.on('preUpdate', () => { + throw new Error('listener failure'); + }); + r.frame.on('preUpdate', () => seen.push('c')); + await h.frame(r); + deepEqual( + seen, + ['a', 'c'], + 'removed during delivery does not run; added during delivery waits', + ); + equal(errors.length, 1, 'the throw went to error'); + // The error list keeps the rules too. While an error is delivered, + // A removes B and subscribes the same callback again: B does not run + // for that error, and runs from the next one. + const errorSeen = []; + const b = () => errorSeen.push('b'); + let offErrorB = () => {}; + let resubscribed = false; + r.on('error', () => { + errorSeen.push('a'); + if (resubscribed) return; + resubscribed = true; + offErrorB(); + offErrorB = r.on('error', b); + }); + offErrorB = r.on('error', b); + r.frame.on('preUpdate', () => { + throw new Error('another failure'); + }); + seen.length = 0; + await h.frame(r); + ok( + seen.includes('late'), + 'a listener added during delivery runs from the next frame', + ); + deepEqual( + errorSeen, + ['a', 'a', 'b'], + 'an error listener removed during delivery does not run; subscribed again, it waits', + ); + }, + ), + + check( + 'renderer.listenerRules', + 'every subscription list follows the listener rules', + async (h, r) => { + await overAustin(h, r); + const credits = []; + const lists = [ + [ + 'frame.on(postRender)', + (cb) => r.frame.on('postRender', cb), + () => drawn(h, r, 1), + ], + [ + 'camera.on(flightEnd)', + (cb) => r.camera.on('flightEnd', cb), + () => r.camera.flyTo({ ...AUSTIN, height: 2000 }, { durationS: 0 }), + ], + [ + 'camera.onCommand', + (cb) => r.camera.onCommand(cb), + () => + r.camera.setView({ destination: { ...AUSTIN, height: 2000 } }), + ], + [ + 'credits.on(change)', + (cb) => r.credits.on('change', cb), + () => { + credits.push(r.credits.addStatic({ html: 'Conformance data' })); + }, + ], + [ + 'quality.signals.on(frame)', + (cb) => r.quality.signals.on('frame', cb), + () => drawn(h, r, 1), + ], + ]; + // A move between two far-apart views raises one of each camera event. + let flip = false; + const moveCamera = async () => { + await h.frame(r); + flip = !flip; + r.camera.setView({ + destination: { ...AUSTIN, height: flip ? 400000 : 2000 }, + }); + for (let i = 0; i < 40; i++) await h.frame(r); + }; + for (const event of ['changed', 'moveStart', 'moveEnd']) + lists.push([ + `camera.on(${event})`, + (cb) => r.camera.on(event, cb), + moveCamera, + ]); + if (h.pointer) + lists.push([ + 'input.on(move)', + (cb) => r.input.on('move', cb), + () => h.pointer(r, 'move', 10, 10), + ]); + for (const [where, subscribe, fire] of lists) + await listenerRules(r, where, subscribe, fire); + for (const off of credits) off(); + }, + ), + + // --- Frame ------------------------------------------------------------- + check( + 'frame.order', + 'phases run preUpdate → preRender → postRender in a drawing frame', + async (h, r) => { + const seen = []; + r.frame.on('postRender', () => seen.push('postRender')); + r.frame.on('preRender', () => seen.push('preRender')); + r.frame.on('preUpdate', () => seen.push('preUpdate')); + r.frame.requestRender('order'); + await h.frame(r); + deepEqual( + seen, + ['preUpdate', 'preRender', 'postRender'], + 'phase order', + ); + }, + ), + check( + 'frame.demand', + 'on demand: preUpdate every tick, drawing only when asked or held', + async (h, r) => { + if (r.frame.mode() === 'continuous') + return { skip: 'continuous engine' }; + await drawn(h, r, 3); + const seen = []; + r.frame.on('preUpdate', (t) => seen.push(['u', t.rendering])); + r.frame.on('postRender', () => seen.push(['r'])); + await h.frame(r); + deepEqual( + seen, + [['u', false]], + 'an idle tick runs preUpdate and draws nothing', + ); + seen.length = 0; + r.frame.requestRender('one'); + ok( + r.frame.diagnostics().pendingReasons.includes('one'), + 'the reason is pending', + ); + await h.frame(r); + deepEqual(seen, [['u', true], ['r']], 'a request draws one frame'); + seen.length = 0; + await h.frame(r); + deepEqual(seen, [['u', false]], 'and only one'); + const a = r.frame.holdContinuous('a'); + const b = r.frame.holdContinuous('b'); + deepEqual( + [...r.frame.diagnostics().holds].sort(), + ['a', 'b'], + 'holds listed', + ); + seen.length = 0; + await h.frame(r); + await h.frame(r); + equal( + seen.filter(([k]) => k === 'r').length, + 2, + 'a hold draws every frame', + ); + a(); + a(); + seen.length = 0; + await h.frame(r); + equal(seen.filter(([k]) => k === 'r').length, 1, 'one hold still out'); + b(); + seen.length = 0; + await h.frame(r); + await h.frame(r); + equal(seen.filter(([k]) => k === 'r').length, 0, 'no holds, no frames'); + }, + ), + check( + 'frame.time', + 'scene time is set and reported in FrameTime', + async (h, r) => { + const t0 = Date.UTC(2026, 5, 21, 12); + r.frame.setTime(t0); + equal(r.frame.time(), t0, 'time()'); + let seen = null; + r.frame.on('preUpdate', (t) => (seen = t)); + await h.frame(r); + equal(seen.time, t0, 'FrameTime.time'); + ok(seen.dt > 0, 'dt is positive'); + ok(Number.isInteger(seen.frame), 'frame is a count'); + }, + ), + check( + 'frame.renderOnce', + 'renderOnce resolves after a drawn frame; renderNow draws now', + async (h, r) => { + let drawnFrames = 0; + r.frame.on('postRender', () => (drawnFrames += 1)); + const once = r.frame.renderOnce(); + await h.frame(r); + await once; + ok(drawnFrames >= 1, 'renderOnce drew a frame'); + const before = drawnFrames; + r.frame.renderNow(); + equal(drawnFrames, before + 1, 'renderNow drew synchronously'); + }, + ), + check('frame.suspend', 'a suspended loop does not tick', async (h, r) => { + let ticks = 0; + r.frame.on('preUpdate', () => (ticks += 1)); + r.frame.suspend(true); + ok(r.frame.diagnostics().suspended, 'suspended'); + await h.frame(r); + equal(ticks, 0, 'no ticks while hidden'); + r.frame.suspend(false); + await h.frame(r); + equal(ticks, 1, 'ticking again'); + }), + + // --- Draw -------------------------------------------------------------- + check('draw.sets', 'set, upsert, remove, clear and size', (h, r) => { + const set = r.draw.points({ id: 'conf:points' }); + const p = (id, lon) => ({ + id, + pos: { lon, lat: AUSTIN.lat, height: 0 }, + pixelSize: 8, + color: '#ff0000', + }); + set.set([p('a', -97.74), p('b', -97.73)]); + equal(set.size(), 2, 'set'); + set.upsert([p('b', -97.72), p('c', -97.71)]); + equal(set.size(), 3, 'upsert adds and replaces'); + set.remove(['a', 'missing']); + equal(set.size(), 2, 'remove ignores unknown ids'); + set.set([p('d', -97.7)]); + equal(set.size(), 1, 'set replaces every item'); + set.clear(); + equal(set.size(), 0, 'clear'); + set.dispose(); + }), + check( + 'draw.ids', + 'set ids are unique among live sets and free again once disposed', + (h, r) => { + const a = r.draw.icons({ id: 'conf:dup' }); + throws( + () => r.draw.lines({ id: 'conf:dup' }), + 'a second live set with the id', + ); + a.dispose(); + const b = r.draw.lines({ id: 'conf:dup' }); + equal(b.id, 'conf:dup', 'the id is free after dispose'); + a.dispose(); + throws( + () => r.draw.points({ id: 'conf:dup' }), + 'disposing the old handle again leaves the newer set live', + ); + b.dispose(); + }, + ), + check( + 'draw.pick', + 'a drawn icon is picked under its projection with its set, id and meta', + async (h, r) => { + await overAustin(h, r); + const set = r.draw.icons({ id: 'conf:pick' }); + const pos = h.toEcef({ ...AUSTIN, height: 0 }); + set.set([ + { id: 'one', pos, image: DOT, size: [24, 24], meta: { n: 1 } }, + ]); + await drawn(h, r); + const s = r.query.project(pos); + ok(s, 'the icon projects'); + const hit = r.query.pick(s.x, s.y); + ok(hit, 'a hit'); + equal(hit.set, 'conf:pick', 'Hit.set'); + equal(hit.id, 'one', 'Hit.id'); + deepEqual(hit.meta, { n: 1 }, 'Hit.meta'); + equal(hit.surface, false, 'not the surface'); + for (const purpose of ['hover', 'select']) + equal( + r.query.pick(s.x, s.y, { purpose })?.id, + 'one', + `a ${purpose} pick finds an icon too`, + ); + set.setOptions({ pickable: false }); + await drawn(h, r); + const after = r.query.pick(s.x, s.y); + ok( + !after || after.set !== 'conf:pick', + 'an unpickable set is not picked', + ); + set.setOptions({ pickable: true }); + set.show = false; + await drawn(h, r); + const hidden = r.query.pick(s.x, s.y); + ok(!hidden || hidden.set !== 'conf:pick', 'a hidden set is not picked'); + set.dispose(); + }, + ), + check( + 'draw.drillPick', + 'drillPick returns every item under the pointer', + async (h, r) => { + await overAustin(h, r); + const set = r.draw.points({ id: 'conf:drill' }); + const pos = h.toEcef({ ...AUSTIN, height: 0 }); + set.set([ + { id: 'a', pos, pixelSize: 20, color: '#f00' }, + { id: 'b', pos, pixelSize: 20, color: '#0f0' }, + ]); + await drawn(h, r); + const s = r.query.project(pos); + const ids = r.query + .drillPick(s.x, s.y) + .filter((x) => x.set === 'conf:drill') + .map((x) => x.id) + .sort(); + deepEqual(ids, ['a', 'b'], 'both hits'); + equal(r.query.drillPick(s.x, s.y, { limit: 1 }).length, 1, 'limit'); + set.dispose(); + }, + ), + check( + 'draw.motion', + 'icons move by their segments on the set\u2019s motion clock', + async (h, r) => { + await overAustin(h, r, 8000); + const t0 = r.frame.time(); + const from = h.toEcef({ + lon: AUSTIN.lon - 0.02, + lat: AUSTIN.lat, + height: 0, + }); + const to = h.toEcef({ + lon: AUSTIN.lon + 0.02, + lat: AUSTIN.lat, + height: 0, + }); + const set = r.draw.icons({ + id: 'conf:motion', + motionClock: { kind: 'scene' }, + }); + set.set([ + { + id: 'mover', + pos: from, + image: DOT, + size: [16, 16], + motion: { kind: 'lerp', from, to, t0, t1: t0 + 10000 }, + }, + ]); + const pickAt = (p) => { + const s = r.query.project(p); + return r.query + .drillPick(s.x, s.y) + .some((x) => x.set === 'conf:motion'); + }; + r.frame.setTime(t0 + 5000); + await drawn(h, r); + const mid = { + x: (from.x + to.x) / 2, + y: (from.y + to.y) / 2, + z: (from.z + to.z) / 2, + }; + ok(pickAt(mid), 'picked halfway at half time'); + ok(!pickAt(from), 'not still at the start'); + r.frame.setTime(t0 + 60000); + await drawn(h, r); + ok(pickAt(to), 'holds at the end'); + set.dispose(); + }, + { capability: 'motionSegments' }, + ), + check( + 'draw.motion.arc', + 'an arc turns in the fix\u2019s tangent plane and coasts no longer than maxDtS', + async (h, r) => { + await overAustin(h, r, 8000); + const t0 = r.frame.time(); + const fixAt = { ...AUSTIN, height: 0 }; + const fix = h.toEcef(fixAt); + const segment = { + kind: 'arc', + fix, + t0, + speedMps: 50, + trackDeg: 90, + turnRateDps: 3, + minDtS: 0, + maxDtS: 20, + }; + const set = r.draw.icons({ + id: 'conf:arc', + motionClock: { kind: 'scene' }, + }); + set.set([ + { + id: 'turner', + pos: fix, + image: DOT, + size: [16, 16], + motion: segment, + }, + ]); + // The contract's formula, on the fix's own east and north axes. + const lon = (fixAt.lon * Math.PI) / 180; + const lat = (fixAt.lat * Math.PI) / 180; + const east = { x: -Math.sin(lon), y: Math.cos(lon), z: 0 }; + const north = { + x: -Math.sin(lat) * Math.cos(lon), + y: -Math.sin(lat) * Math.sin(lon), + z: Math.cos(lat), + }; + const at = (dtS) => { + const track = (segment.trackDeg * Math.PI) / 180; + const w = (segment.turnRateDps * Math.PI) / 180; + const v = segment.speedMps; + const e = (v / w) * (Math.cos(track) - Math.cos(track + w * dtS)); + const n = (v / w) * (Math.sin(track + w * dtS) - Math.sin(track)); + return { + x: fix.x + east.x * e + north.x * n, + y: fix.y + east.y * e + north.y * n, + z: fix.z + east.z * e + north.z * n, + }; + }; + const pickAt = (p) => { + const s = r.query.project(p); + return r.query.drillPick(s.x, s.y).some((x) => x.set === 'conf:arc'); + }; + r.frame.setTime(t0 + 20000); + await drawn(h, r); + ok(pickAt(at(20)), 'on the arc at 20 s'); + const straight = { + x: fix.x + east.x * 1000, + y: fix.y + east.y * 1000, + z: fix.z + east.z * 1000, + }; + ok(!pickAt(straight), 'not where a straight track would be'); + r.frame.setTime(t0 + 60000); + await drawn(h, r); + ok(pickAt(at(20)), 'coasts no further than maxDtS'); + set.dispose(); + }, + { capability: 'motionSegments' }, + ), + check( + 'draw.motion.live', + 'a live motion clock runs delayMs behind wall time', + async (h, r) => { + if (!h.wallTime) return { skip: 'no wall time in this harness' }; + await overAustin(h, r, 8000); + const w = h.wallTime(r); + const from = h.toEcef({ ...AUSTIN, lon: AUSTIN.lon - 0.02, height: 0 }); + const to = h.toEcef({ ...AUSTIN, lon: AUSTIN.lon + 0.02, height: 0 }); + const along = (f) => ({ + x: from.x + (to.x - from.x) * f, + y: from.y + (to.y - from.y) * f, + z: from.z + (to.z - from.z) * f, + }); + const set = r.draw.icons({ + id: 'conf:live', + motionClock: { kind: 'live', delayMs: 2000 }, + }); + // Halfway at wall time minus the delay; 70% at wall time itself. + set.set([ + { + id: 'mover', + pos: from, + image: DOT, + size: [16, 16], + motion: { kind: 'lerp', from, to, t0: w - 7000, t1: w + 3000 }, + }, + ]); + // Scene time an hour away: a live set must not read it. + r.frame.setTime(w - 3600000); + await drawn(h, r, 1); + const pickAt = (p) => { + const s = r.query.project(p); + return r.query.drillPick(s.x, s.y).some((x) => x.set === 'conf:live'); + }; + ok(pickAt(along(0.5)), 'drawn delayMs behind wall time'); + ok(!pickAt(along(0.7)), 'not at wall time'); + ok(!pickAt(from), 'not held at the start by the scene clock'); + // A second of frames later it has moved on by the wall time passed. + await drawn(h, r, 60); + const passed = h.wallTime(r) - w; + ok(passed > 0, 'wall time passed'); + // The segment holds at its end, however much wall time passed. + ok( + pickAt(along(Math.min(1, 0.5 + passed / 10000))), + 'moves on with wall time', + ); + set.dispose(); + }, + { capability: 'motionSegments' }, + ), + check( + 'draw.columns', + 'column updates are rows: sized, picked, removed', + async (h, r) => { + await overAustin(h, r); + const set = r.draw.icons({ id: 'conf:columns' }); + const a = h.toEcef({ + lon: AUSTIN.lon - 0.003, + lat: AUSTIN.lat, + height: 0, + }); + const b = h.toEcef({ + lon: AUSTIN.lon + 0.003, + lat: AUSTIN.lat, + height: 0, + }); + set.set([ + { id: 'a', pos: a, image: DOT, size: [16, 16] }, + { id: 'b', pos: b, image: DOT, size: [16, 16] }, + ]); + set.setColumns({ + ids: ['a', 'b'], + positions: new Float64Array([b.x, b.y, b.z, a.x, a.y, a.z]), + meta: [{ k: 'a' }, { k: 'b' }], + }); + await drawn(h, r); + equal(set.size(), 2, 'size'); + const sa = r.query.project(a); + const hit = r.query + .drillPick(sa.x, sa.y) + .find((x) => x.set === 'conf:columns'); + equal(hit?.id, 'b', 'b moved to a’s place'); + deepEqual(hit?.meta, { k: 'b' }, 'meta from the columns'); + set.remove(['a']); + equal(set.size(), 1, 'removed'); + set.dispose(); + }, + { capability: 'columns' }, + ), + check( + 'draw.models', + 'a model set says when a model is ready', + async (h, r) => { + if (!h.assets?.model) return { skip: 'no model asset in this harness' }; + const set = r.draw.models({ id: 'conf:models' }); + const ready = []; + set.on('ready', (id) => ready.push(id)); + set.set([ + { + id: 'm', + pos: { ...AUSTIN, height: 0 }, + url: h.assets.model, + orientation: { heading: 0, pitch: 0, roll: 0 }, + }, + ]); + for (let i = 0; i < 600 && !ready.length; i++) await drawn(h, r, 1); + deepEqual(ready, ['m'], 'ready'); + set.dispose(); + }, + { capability: 'models' }, + ), + + // --- Query ------------------------------------------------------------- + check( + 'query.projectMany', + 'projectMany agrees with project; NaN behind the eye', + async (h, r) => { + await overAustin(h, r, 5000); + const points = [ + h.toEcef({ ...AUSTIN, height: 0 }), + h.toEcef({ lon: AUSTIN.lon + 0.01, lat: AUSTIN.lat, height: 0 }), + h.toEcef({ ...AUSTIN, height: 1e6 }), + ]; + const source = new Float64Array(points.flatMap((p) => [p.x, p.y, p.z])); + const out = new Float32Array(points.length * 2); + r.query.projectMany(source, out); + for (let i = 0; i < 2; i++) { + const s = r.query.project(points[i]); + near(out[i * 2], s.x, 0.01, `x ${i}`); + near(out[i * 2 + 1], s.y, 0.01, `y ${i}`); + } + equal(r.query.project(points[2]), null, 'project behind the eye'); + ok( + Number.isNaN(out[4]) && Number.isNaN(out[5]), + 'projectMany behind the eye', + ); + }, + ), + check( + 'query.rays', + 'pickRay starts at the eye; pickEllipsoid lies on its ray; pickGlobe agrees', + async (h, r) => { + await overAustin(h, r, 5000); + const eye = r.camera.pose().position; + const ray = r.query.pickRay(640, 360); + nearVec(ray.origin, eye, 1e-3, 'ray origin'); + near( + Math.hypot(ray.direction.x, ray.direction.y, ray.direction.z), + 1, + 1e-9, + 'unit direction', + ); + const ground = r.query.pickEllipsoid(640, 360); + ok(ground, 'center of the view meets the ellipsoid'); + const t = + (ground.x - eye.x) * ray.direction.x + + (ground.y - eye.y) * ray.direction.y + + (ground.z - eye.z) * ray.direction.z; + nearVec( + ground, + { + x: eye.x + ray.direction.x * t, + y: eye.y + ray.direction.y * t, + z: eye.z + ray.direction.z * t, + }, + 0.01, + 'on the ray', + ); + const s = r.query.project(ground); + nearVec(s, { x: 640, y: 360 }, 0.5, 'projects back to the pixel'); + // A bare globe: the globe surface is the ellipsoid. + nearVec( + r.query.pickGlobe(ray), + ground, + 0.5, + 'pickGlobe meets the globe there', + ); + const up = { origin: eye, direction: ray.direction }; + up.direction = { + x: -ray.direction.x, + y: -ray.direction.y, + z: -ray.direction.z, + }; + equal(r.query.pickGlobe(up), null, 'a ray away from the globe misses'); + }, + ), + check( + 'query.heights', + 'heights: a cached height now, async samples per point, misses as null', + async (h, r) => { + if (!h.scriptHeights) + return { skip: 'no scripted heights in this harness' }; + h.scriptHeights(r, (point) => (point.lon > 0 ? null : 123.5)); + const [hit, miss] = await r.query.sampleHeight([ + { ...AUSTIN }, + { lon: 10, lat: 10 }, + ]); + near(hit.height, 123.5, 0.01, 'sampled height'); + ok(hit.confidence > 0 && hit.confidence <= 1, 'confidence in (0, 1]'); + ok(['tiles', 'terrain', 'ellipsoid'].includes(hit.source), 'source'); + ok(['visible', 'finest'].includes(hit.lod), 'lod'); + equal(miss, null, 'a miss is null'); + h.scriptHeights(r, (point, against) => + against === 'tiles' ? 300 : 123.5, + ); + const there = { lon: AUSTIN.lon + 0.5, lat: AUSTIN.lat }; + const [terrain] = await r.query.sampleHeight([there], { + against: 'terrain', + }); + const [tiles] = await r.query.sampleHeight([there], { + against: 'tiles', + }); + near(terrain.height, 123.5, 0.01, 'terrain'); + near(tiles.height, 300, 0.01, 'tiles, not the terrain answer'); + h.scriptHeights(r, (point) => (point.lon > 0 ? null : 123.5)); + const out = new Float64Array(2); + r.query.sampleHeightNowMany( + new Float64Array([AUSTIN.lon, AUSTIN.lat, 10, 10]), + out, + ); + near(out[0], 123.5, 0.01, 'a batched cached height'); + ok(Number.isNaN(out[1]), 'a batched miss is NaN'); + const [plain] = await r.query.sampleHeight([{ ...AUSTIN }], { + mostDetailed: true, + }); + equal( + plain.lod === 'finest', + r.capabilities.has('finestHeights'), + 'finest only with finestHeights', + ); + const [clamped] = await r.query.clampToSurface([ + h.toEcef({ ...AUSTIN, height: 5000 }), + ]); + nearVec( + clamped, + h.toEcef({ ...AUSTIN, height: 123.5 }), + 0.05, + 'clamped onto the surface', + ); + }, + { capability: 'sampleHeight' }, + ), + check( + 'query.withoutCapability', + 'without sampleHeight the height members answer null', + async (h) => { + const r = await h.create({ + viewport: SCRIPT_VIEWPORT, + capabilities: CAPABILITIES.filter( + (c) => !['sampleHeight', 'pickPosition', 'rayCast'].includes(c), + ), + }); + if (!r) return { skip: 'this harness cannot drop capabilities' }; + try { + equal( + r.query.sampleHeightNow({ ...AUSTIN }), + null, + 'sampleHeightNow', + ); + deepEqual( + await r.query.sampleHeight([{ ...AUSTIN }]), + [null], + 'sampleHeight', + ); + equal(r.query.pickPosition(640, 360), null, 'pickPosition'); + equal( + await r.query.rayCast(r.query.pickRay(640, 360)), + null, + 'rayCast', + ); + } finally { + r.destroy(); + } + }, + ), + + // --- Input ------------------------------------------------------------- + check( + 'input.lease', + 'a lease leaves ambient subscribers only move and up; a new claim ends the old', + async (h, r) => { + if (!h.pointer) return { skip: 'no pointer in this harness' }; + const ambient = []; + for (const type of POINTER_TYPES) + r.input.on(type, () => ambient.push(type)); + const seen = []; + r.input.on('click', () => seen.push('ambient')); + const lease = r.input.claimPointer('tool'); + for (const type of POINTER_TYPES) await h.pointer(r, type, 640, 360); + deepEqual(ambient, ['move', 'up'], 'ambient under a lease'); + seen.length = 0; + r.input.on('click', () => seen.push('tool'), { lease }); + ok(!r.input.isPointerFree(), 'taken'); + await h.pointer(r, 'click', 640, 360); + deepEqual(seen, ['tool'], 'only the lease holder hears the click'); + const next = r.input.claimPointer('other'); + ok(!lease.current, 'the old lease ended'); + ok(next.current, 'the new one is current'); + seen.length = 0; + await h.pointer(r, 'click', 640, 360); + deepEqual(seen, [], 'neither the ambient nor the ended lease'); + next.release(); + ok(r.input.isPointerFree(), 'free again'); + await h.pointer(r, 'click', 640, 360); + deepEqual(seen, ['ambient'], 'ambient again'); + }, + ), + check( + 'input.clickPick', + 'a click carries one pick shared by every subscriber', + async (h, r) => { + if (!h.pointer) return { skip: 'no pointer in this harness' }; + await overAustin(h, r); + const set = r.draw.icons({ id: 'conf:click' }); + const pos = h.toEcef({ ...AUSTIN, height: 0 }); + set.set([{ id: 'x', pos, image: DOT, size: [24, 24] }]); + await drawn(h, r); + const s = r.query.project(pos); + const hits = []; + r.input.on('click', (ev) => hits.push(ev.pick())); + r.input.on('click', (ev) => hits.push(ev.pick())); + await h.pointer(r, 'click', s.x, s.y); + equal(hits.length, 2, 'both subscribers'); + equal(hits[0]?.id, 'x', 'the hit'); + equal(hits[0], hits[1], 'the same pick object'); + set.dispose(); + }, + ), + check( + 'input.behaviours', + 'stock behaviours are listed and suppression is counted', + (h, r) => { + for (const name of ['select', 'dblclickFollow']) { + const b = r.input.behaviours[name]; + ok(b && typeof b.suppress === 'function', `${name} listed`); + if (!b.enabled) continue; + const one = b.suppress(); + const two = b.suppress(); + ok(!b.enabled, `${name} suppressed`); + one(); + one(); + ok(!b.enabled, `${name} still suppressed`); + two(); + ok(b.enabled, `${name} back`); + } + }, + ), + + // --- Globe ------------------------------------------------------------- + check( + 'globe.imagery', + 'imagery layers keep order, raise and lower, and leave on remove', + (h, r) => { + const a = r.globe.imagery.add({ + kind: 'xyz', + url: 'https://a.invalid/{z}/{x}/{y}.png', + }); + const b = r.globe.imagery.add({ + kind: 'xyz', + url: 'https://b.invalid/{z}/{x}/{y}.png', + }); + const ours = () => + r.globe.imagery + .list() + .filter((l) => l === a || l === b) + .map((l) => (l === a ? 'a' : 'b')); + deepEqual(ours(), ['a', 'b'], 'added in order'); + a.raise(); + deepEqual(ours(), ['b', 'a'], 'raised'); + a.lower(); + deepEqual(ours(), ['a', 'b'], 'lowered'); + a.alpha = 0.5; + equal(a.alpha, 0.5, 'alpha'); + a.remove(); + deepEqual(ours(), ['b'], 'the removed layer is the one gone'); + b.remove(); + deepEqual(ours(), [], 'both removed'); + ok(['globe', 'tiles', 'both'].includes(r.globe.imagery.host()), 'host'); + }, + ), + check( + 'globe.split', + 'the split position round-trips', + (h, r) => { + r.globe.imagery.setSplit(0.3); + near(r.globe.imagery.getSplit(), 0.3, 1e-9, 'split'); + }, + { capability: 'splitSwipe' }, + ), + check( + 'globe.sky', + 'sky settings round-trip and keep unchanged keys', + (h, r) => { + const before = r.globe.sky.get(); + r.globe.sky.set({ stars: !before.stars, milkyWay: 0.25 }); + const after = r.globe.sky.get(); + equal(after.stars, !before.stars, 'stars'); + near(after.milkyWay, 0.25, 1e-9, 'milkyWay'); + equal(after.atmosphere, before.atmosphere, 'atmosphere unchanged'); + }, + ), + + // --- Effects, credits, quality --------------------------------------- + check( + 'effects.styles', + 'a style is set, tuned and cleared; diagnostics follow', + (h, r) => { + const styles = r.effects.styles(); + ok(styles.length > 0, 'styles listed'); + const changes = []; + r.effects.on('change', (d) => changes.push(d)); + r.effects.setStyle(styles[0].id); + equal(r.effects.diagnostics().style, styles[0].id, 'style active'); + r.effects.setStyleIntensity(styles[0].id, 0.5); + r.effects.setStyle(null); + equal(r.effects.diagnostics().style, null, 'cleared'); + ok(changes.length >= 2, 'change events'); + }, + { capability: 'styles' }, + ), + check( + 'effects.withoutStyles', + 'without styles, styles() is empty', + async (h) => { + const r = await h.create({ + viewport: SCRIPT_VIEWPORT, + capabilities: CAPABILITIES.filter((c) => c !== 'styles'), + }); + if (!r) return { skip: 'this harness cannot drop capabilities' }; + try { + deepEqual(r.effects.styles(), [], 'styles()'); + } finally { + r.destroy(); + } + }, + ), + check( + 'credits', + 'a static credit is listed until its Off, with change events', + (h, r) => { + const events = []; + r.credits.on('change', (list) => events.push(list.length)); + const base = r.credits.list().length; + const off = r.credits.addStatic({ html: 'Conformance data' }); + ok( + r.credits.list().some((c) => c.html === 'Conformance data'), + 'listed', + ); + off(); + equal(r.credits.list().length, base, 'removed'); + deepEqual(events, [base + 1, base], 'change events'); + }, + ), + check( + 'quality', + 'knobs name real keys; set applies supported keys and returns the rest', + async (h, r) => { + const knobs = r.quality.knobs(); + for (const k of knobs) + ok(QUALITY_KEYS.includes(k.key), `known key ${k.key}`); + const supported = new Set(knobs.map((k) => k.key)); + // A value each knob accepts and that differs from its default. + const valueFor = (k) => { + if (k.kind === 'boolean') return !k.default; + if (k.kind === 'enum') + return k.options.find((o) => o !== k.default) ?? k.default; + if (k.kind === 'number') return k.default === k.min ? k.max : k.min; + return k.default; + }; + const wanted = Object.fromEntries( + knobs.map((k) => [k.key, valueFor(k)]), + ); + const patch = { + ...Object.fromEntries(QUALITY_KEYS.map((k) => [k, 0.5])), + ...wanted, + }; + const ignored = r.quality.set(patch); + await drawn(h, r, 1); + const applied = r.quality.get(); + for (const [key, value] of Object.entries(wanted)) + deepEqual(applied[key], value, `${key} applied`); + deepEqual( + [...ignored].sort(), + QUALITY_KEYS.filter((k) => !supported.has(k)).sort(), + 'ignored keys', + ); + ok( + ['none', 'frame', 'perPass'].includes(r.quality.signals.gpuTiming), + 'gpuTiming', + ); + const facts = r.quality.device(); + ok(facts && typeof facts === 'object', 'device facts'); + }, + ), + check( + 'renderer.whenSettled', + 'whenSettled names what is still loading', + async (h, r) => { + const result = await r.whenSettled({ timeoutMs: 5000 }); + ok(['settled', 'timeout', 'failed'].includes(result.state), 'state'); + ok(Array.isArray(result.blockers), 'blockers'); + equal( + result.state === 'settled', + result.blockers.length === 0, + 'settled exactly when nothing blocks', + ); + }, + ), + check( + 'quality.signals', + 'frame signals arrive for drawn frames', + async (h, r) => { + const signals = []; + r.quality.signals.on('frame', (s) => signals.push(s)); + await drawn(h, r, 2); + ok(signals.length >= 1, 'signals'); + ok(signals[0].width > 0 && signals[0].height > 0, 'size'); + }, + ), + ]; +} diff --git a/src/render/conformance/expect.js b/src/render/conformance/expect.js new file mode 100644 index 000000000..2f29be2e3 --- /dev/null +++ b/src/render/conformance/expect.js @@ -0,0 +1,93 @@ +/** + * @module render/conformance/expect + * @description Assertions for the conformance suite. Plain functions that + * throw, so the suite runs in Node and in a browser page alike. + */ + +export class ConformanceError extends Error { + constructor(message) { + super(message); + this.name = 'ConformanceError'; + } +} + +export function ok(value, message) { + if (!value) throw new ConformanceError(message); +} + +export function equal(actual, expected, message) { + if (!Object.is(actual, expected)) + throw new ConformanceError( + `${message}: expected ${show(expected)}, got ${show(actual)}`, + ); +} + +export function deepEqual(actual, expected, message) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) + throw new ConformanceError( + `${message}: expected ${show(expected)}, got ${show(actual)}`, + ); +} + +export function near(actual, expected, tolerance, message) { + if ( + !(Number.isFinite(actual) && Number.isFinite(expected)) || + Math.abs(actual - expected) > tolerance + ) + throw new ConformanceError( + `${message}: expected ${expected} ± ${tolerance}, got ${actual}`, + ); +} + +/** Angles in radians, compared around the circle. */ +export function nearAngle(actual, expected, tolerance, message) { + const tau = Math.PI * 2; + let d = Math.abs(actual - expected) % tau; + if (d > Math.PI) d = tau - d; + if (!(d <= tolerance)) + throw new ConformanceError( + `${message}: expected ${expected} ± ${tolerance} rad, got ${actual}`, + ); +} + +export function nearVec(actual, expected, tolerance, message) { + if (actual === null || expected === null) { + equal(actual, expected, message); + return; + } + const d = Math.hypot( + actual.x - expected.x, + actual.y - expected.y, + (actual.z ?? 0) - (expected.z ?? 0), + ); + if (!(d <= tolerance)) + throw new ConformanceError( + `${message}: ${show(actual)} is ${d} from ${show(expected)} (± ${tolerance})`, + ); +} + +export async function rejects(promiseOrFn, message) { + try { + await (typeof promiseOrFn === 'function' ? promiseOrFn() : promiseOrFn); + } catch { + return; + } + throw new ConformanceError(`${message}: did not throw`); +} + +export function throws(fn, message) { + try { + fn(); + } catch { + return; + } + throw new ConformanceError(`${message}: did not throw`); +} + +function show(value) { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/src/render/conformance/goldens/camera.json b/src/render/conformance/goldens/camera.json new file mode 100644 index 000000000..f5f12ffab --- /dev/null +++ b/src/render/conformance/goldens/camera.json @@ -0,0 +1,2290 @@ +{ + "cesium": "1.138.0", + "viewport": { + "width": 1280, + "height": 720 + }, + "scripts": { + "setView angles": { + "records": [ + { + "step": 1, + "kind": "pose", + "value": { + "position": { + "x": -2488310.822590764, + "y": -18300255.464286, + "z": 10756490.228710467 + }, + "direction": { + "x": 0.11622572281662877, + "y": 0.8547808415875731, + "z": -0.5058075663832962 + }, + "up": { + "x": 0.06814826350853613, + "y": 0.5011956787437056, + "z": 0.8626463387677521 + }, + "right": { + "x": 0.9908821299915159, + "y": -0.13473160157690067, + "z": -1.5959455978986628e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 15000000.000000002, + "frameIsIdentity": true + } + }, + { + "step": 3, + "kind": "pose", + "value": { + "position": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": 0.36092901926672916, + "y": 0.8441580339915824, + "z": 0.39639305833808886 + }, + "up": { + "x": 0.1059330423425059, + "y": -0.45940299877841734, + "z": 0.8818883575903803 + }, + "right": { + "x": 0.9265573018390272, + "y": -0.2763079773746383, + "z": -0.25523610255588536 + }, + "heading": 0.2999999999999998, + "pitch": -0.6000000000000001, + "roll": 6.283185307179586, + "height": 600.0000000002616, + "frameIsIdentity": true + } + }, + { + "step": 5, + "kind": "pose", + "value": { + "position": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": -0.2782358887254662, + "y": 0.6733761961755564, + "z": 0.6849447340109189 + }, + "up": { + "x": -0.0320358403425136, + "y": -0.7192122583491108, + "z": 0.6940514623382916 + }, + "right": { + "x": 0.9599783826517654, + "y": 0.17116724530268476, + "z": 0.22168283419514367 + }, + "heading": 5.9, + "pitch": -0.19999999999999973, + "roll": 0.15000000000000036, + "height": 600.0000000002616, + "frameIsIdentity": true + } + }, + { + "step": 7, + "kind": "pose", + "value": { + "position": { + "x": -745736.5579576448, + "y": -5484511.5794148045, + "z": 3208591.937820149 + }, + "direction": { + "x": 0.09879959240734887, + "y": 0.6147902506052381, + "z": -0.7824778516360001 + }, + "up": { + "x": -0.06700477886630624, + "y": -0.780430421521237, + "z": -0.621641952230753 + }, + "right": { + "x": -0.9928489311819897, + "y": 0.11384772692035962, + "z": -0.03591232275762872 + }, + "heading": 3.1, + "pitch": -1.1999999999999997, + "roll": 6.283185307179586, + "height": 24999.99999999683, + "frameIsIdentity": true + } + }, + { + "step": 9, + "kind": "pose", + "value": { + "position": { + "x": 5486.397762395267, + "y": 1216.305101059199, + "z": 6396749.862229852 + }, + "direction": { + "x": -0.5432980145993753, + "y": 0.5387723520199353, + "z": -0.6438568319364827 + }, + "up": { + "x": -0.45649972377785625, + "y": 0.45404863330428463, + "z": 0.7651456337098529 + }, + "right": { + "x": 0.7045816272960277, + "y": 0.7096225696054338, + "z": -0.0007342911295312447 + }, + "heading": 1, + "pitch": -0.6999999999999997, + "roll": 6.283185307179586, + "height": 40000.000000676286, + "frameIsIdentity": true + } + }, + { + "step": 11, + "kind": "pose", + "value": { + "position": { + "x": -4647026.984185624, + "y": 2554724.7034213874, + "z": -3535597.5264087743 + }, + "direction": { + "x": 0.72760064521333, + "y": -0.40000183964449, + "z": 0.5573112499906759 + }, + "up": { + "x": -0.48837557123042497, + "y": 0.26848674230122765, + "z": 0.8303036617008445 + }, + "right": { + "x": -0.4817536741017157, + "y": -0.8763066800438634, + "z": -3.3306690738754696e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 1999.999999998896, + "frameIsIdentity": true + } + } + ], + "flights": [] + }, + "view rectangles": { + "records": [ + { + "step": 1, + "kind": "viewRectangle", + "value": { + "west": -180, + "south": -90, + "east": 180, + "north": 90 + } + }, + { + "step": 3, + "kind": "viewRectangle", + "value": { + "west": -97.77310906593979, + "south": 30.2525462275233, + "east": -97.71309093404976, + "north": 30.28184663602834 + } + }, + { + "step": 5, + "kind": "viewRectangle", + "value": { + "west": -99.42790849855417, + "south": 30.292803841160342, + "east": -96.05829150144581, + "north": 31.693665830637645 + } + }, + { + "step": 7, + "kind": "viewRectangle", + "value": { + "west": -97.74843343187867, + "south": 30.270077376377223, + "east": -97.72522294195059, + "north": 30.287912721763323 + } + }, + { + "step": 9, + "kind": "viewRectangle", + "value": { + "west": -180, + "south": -90, + "east": 180, + "north": 90 + } + }, + { + "step": 11, + "kind": "viewRectangle", + "value": null + }, + { + "step": 13, + "kind": "viewRectangle", + "value": { + "west": -180, + "south": 83.78827933471062, + "east": 180, + "north": 90 + } + }, + { + "step": 15, + "kind": "viewRectangle", + "value": { + "west": 168.24140167102513, + "south": 3.4321936523484062, + "east": -168.24140167102513, + "north": 16.15401098698419 + } + } + ], + "flights": [] + }, + "view and projection matrices": { + "records": [ + { + "step": 1, + "kind": "matrices", + "value": { + "view": [ + 0.9908821299915158, + 0.06814826350853613, + -0.11622572281662873, + 0, + -0.1347316015769006, + 0.5011956787437056, + -0.8547808415875731, + 0, + -1.804112415015879e-16, + 0.8626463387677522, + 0.5058075663832962, + 0, + 7.794660713039725e-11, + 62536.106446899474, + -21372627.636387765, + 1 + ], + "projection": [ + 1.7320508075688774, + 0, + 0, + 0, + 0, + 3.079201435678004, + 0, + 0, + 0, + 0, + -1.000000004, + -1, + 0, + 0, + -2.000000004, + 0 + ], + "near": 1, + "far": 500000000 + } + }, + { + "step": 3, + "kind": "matrices", + "value": { + "view": [ + 0.9113527171457554, + 0.1979051995330235, + -0.3609290192667292, + 0, + -0.22906381740146353, + -0.48469266675382583, + -0.8441580339915825, + 0, + -0.34200291301174285, + 0.8520014969428511, + -0.39639305833808874, + 0, + 518663.2069340985, + -5224404.956541276, + -3613311.623930813, + 1 + ], + "projection": [ + 1.7320508075688774, + 0, + 0, + 0, + 0, + 3.079201435678004, + 0, + 0, + 0, + 0, + -1.000000004, + -1, + 0, + 0, + -2.000000004, + 0 + ], + "near": 1, + "far": 500000000 + } + }, + { + "step": 5, + "kind": "matrices", + "value": { + "view": [ + -0.9928489311819897, + -0.06700477886630624, + -0.09879959240734885, + 0, + 0.1138477269203596, + -0.780430421521237, + -0.6147902506052382, + 0, + -0.03591232275762869, + -0.621641952230753, + 0.7824778516360003, + 0, + -776.5786582942674, + -2335652.2407809016, + -5956154.842607334, + 1 + ], + "projection": [ + 1.7320508075688774, + 0, + 0, + 0, + 0, + 3.079201435678004, + 0, + 0, + 0, + 0, + -1.000000004, + -1, + 0, + 0, + -2.000000004, + 0 + ], + "near": 1, + "far": 500000000 + } + } + ], + "flights": [] + }, + "setView keeps the position when only orientation is given": { + "records": [ + { + "step": 3, + "kind": "pose", + "value": { + "position": { + "x": -742920.5113997735, + "y": -5463800.994973143, + "z": 3196394.3325687605 + }, + "direction": { + "x": 0.8681548011485627, + "y": -0.06268709561301371, + "z": -0.49231856484019 + }, + "up": { + "x": 0.14674593985286488, + "y": -0.9152109275028256, + "z": 0.37530599158035977 + }, + "right": { + "x": -0.4741021729325852, + "y": -0.3980694489947987, + "z": -0.7853456840128291 + }, + "heading": 2, + "pitch": -0.30000000000000004, + "roll": 6.283185307179586, + "height": 799.999999999007, + "frameIsIdentity": true + } + } + ], + "flights": [] + }, + "lookAt sweep and release": { + "records": [ + { + "step": 2, + "kind": "pose", + "value": { + "position": { + "x": -743043.1457683308, + "y": -5464702.908131629, + "z": 3194792.6538335546 + }, + "direction": { + "x": 0.10786340837563654, + "y": 0.7932802890225545, + "z": 0.5992260576626178 + }, + "up": { + "x": -0.08073468645549653, + "y": -0.5937623923631535, + "z": 0.800579872228947 + }, + "right": { + "x": 0.9908821299915159, + "y": -0.13473160157690062, + "z": -1.5254822736301715e-16 + }, + "heading": 6.283185307179586, + "pitch": -0.400289630126315, + "roll": 6.283185307179586, + "height": 779.1037811773191, + "frameIsIdentity": false + } + }, + { + "step": 4, + "kind": "pose", + "value": { + "position": { + "x": -743939.048821212, + "y": -5464455.552141584, + "z": 3195005.808986324 + }, + "direction": { + "x": 0.5558149348159637, + "y": 0.6696022939993188, + "z": 0.49264848127886873 + }, + "up": { + "x": 0.10865618124688217, + "y": -0.6460526099661097, + "z": 0.7555195956643497 + }, + "right": { + "x": 0.8241744915443396, + "y": -0.36639977214352587, + "z": -0.43184211751611457 + }, + "heading": 0.5235148029189771, + "pitch": -0.40028931540080226, + "roll": 6.283184819462324, + "height": 779.1034471738486, + "frameIsIdentity": false + } + }, + { + "step": 6, + "kind": "pose", + "value": { + "position": { + "x": -744561.3757211583, + "y": -5464027.955054963, + "z": 3195588.159693578 + }, + "direction": { + "x": 0.8669783847892886, + "y": 0.4558037506896082, + "z": 0.20147312765091477 + }, + "up": { + "x": 0.24021397781477516, + "y": -0.7364451842535525, + "z": 0.6324126306867643 + }, + "right": { + "x": 0.4366299636655266, + "y": -0.4998914196573456, + "z": -0.7479724883860398 + }, + "heading": 1.0470518895126428, + "pitch": -0.4002887210284125, + "roll": 6.283184961711057, + "height": 779.1027791380377, + "frameIsIdentity": false + } + }, + { + "step": 8, + "kind": "pose", + "value": { + "position": { + "x": -744743.374477902, + "y": -5463534.691165816, + "z": 3196383.665553603 + }, + "direction": { + "x": 0.9579777631611771, + "y": 0.20917180611621072, + "z": -0.1962798023607882 + }, + "up": { + "x": 0.2786878978997977, + "y": -0.8407194979433913, + "z": 0.4642453891445818 + }, + "right": { + "x": -0.06790921036869467, + "y": -0.49943756497073244, + "z": -0.8636842350322286 + }, + "heading": 1.5706277892919154, + "pitch": -0.4002885372172571, + "roll": 3.8862982343346175e-7, + "height": 779.1024450503498, + "frameIsIdentity": false + } + }, + { + "step": 10, + "kind": "pose", + "value": { + "position": { + "x": -744436.2786715502, + "y": -5463107.930134895, + "z": 3197179.171413625 + }, + "direction": { + "x": 0.8044298599851474, + "y": -0.004208709344714679, + "z": -0.5940327323724914 + }, + "up": { + "x": 0.2137688856855085, + "y": -0.9309353328911241, + "z": 0.29607814760239926 + }, + "right": { + "x": -0.5542521663259894, + "y": -0.3651598180804452, + "z": -0.7479724883860396 + }, + "heading": 2.094248848550098, + "pitch": -0.4002890786917592, + "roll": 0.0000010185947569851805, + "height": 779.1027789155619, + "frameIsIdentity": false + } + }, + { + "step": 12, + "kind": "pose", + "value": { + "position": { + "x": -743722.3743754129, + "y": -5462862.022235794, + "z": 3197761.522120881 + }, + "direction": { + "x": 0.44747771191670926, + "y": -0.1271626588948764, + "z": -0.8852080860004453 + }, + "up": { + "x": 0.06285193806815277, + "y": -0.9829194289780064, + "z": 0.1729711826248142 + }, + "right": { + "x": -0.8920837019130344, + "y": -0.13303779282720718, + "z": -0.4318421175161143 + }, + "heading": 2.6179093131524076, + "pitch": -0.4002899348910174, + "roll": 8.763464425243228e-7, + "height": 779.103446782392, + "frameIsIdentity": false + } + }, + { + "step": 14, + "kind": "pose", + "value": { + "position": { + "x": -742792.9516691145, + "y": -5462862.858291493, + "z": 3197974.67727365 + }, + "direction": { + "x": -0.017233641232645276, + "y": -0.1267446310460917, + "z": -0.9917856623841941 + }, + "up": { + "x": -0.13362487071402948, + "y": -0.9827426896382966, + "z": 0.1279109060602167 + }, + "right": { + "x": -0.9908821299915159, + "y": 0.1347316015769004, + "z": -1.2010529177165146e-16 + }, + "heading": 3.141592653589793, + "pitch": -0.40029034545149567, + "roll": 6.283185307179586, + "height": 779.1037807281175, + "frameIsIdentity": false + } + }, + { + "step": 16, + "kind": "pose", + "value": { + "position": { + "x": -741897.0486162337, + "y": -5463110.214281538, + "z": 3197761.5221208814 + }, + "direction": { + "x": -0.46518516767297236, + "y": -0.003066636022856062, + "z": -0.8852080860004454 + }, + "up": { + "x": -0.3230157384164078, + "y": -0.9304524720353406, + "z": 0.1729711826248138 + }, + "right": { + "x": -0.8241744915443401, + "y": 0.3663997721435252, + "z": 0.43184211751611423 + }, + "heading": 3.6652759940271786, + "pitch": -0.4002899348910174, + "roll": 6.283184430833143, + "height": 779.1034467820881, + "frameIsIdentity": false + } + }, + { + "step": 18, + "kind": "pose", + "value": { + "position": { + "x": -741274.7217162868, + "y": -5463537.811368157, + "z": 3197179.1714136247 + }, + "direction": { + "x": -0.7763486176462973, + "y": 0.2107319072868545, + "z": -0.5940327323724914 + }, + "up": { + "x": -0.4545735349843011, + "y": -0.8400598977478978, + "z": 0.29607814760239926 + }, + "right": { + "x": -0.4366299636655271, + "y": 0.49989141965734557, + "z": 0.7479724883860396 + }, + "heading": 4.188936458629488, + "pitch": -0.40028907869175945, + "roll": 6.283184288584828, + "height": 779.1027789133159, + "frameIsIdentity": false + } + }, + { + "step": 20, + "kind": "pose", + "value": { + "position": { + "x": -741092.7229595436, + "y": -5464031.075257304, + "z": 3196383.665553603 + }, + "direction": { + "x": -0.8673479960181861, + "y": 0.4573638518602519, + "z": -0.19627980236078835 + }, + "up": { + "x": -0.49304745506932357, + "y": -0.7357855840580592, + "z": 0.4642453891445817 + }, + "right": { + "x": 0.06790921036869442, + "y": 0.4994375649707325, + "z": 0.8636842350322286 + }, + "heading": 4.712557517887671, + "pitch": -0.4002885372172571, + "roll": 6.283184918549763, + "height": 779.1024450525605, + "frameIsIdentity": false + } + }, + { + "step": 22, + "kind": "pose", + "value": { + "position": { + "x": -741399.8187658956, + "y": -5464457.836288226, + "z": 3195588.159693578 + }, + "direction": { + "x": -0.7138000928421564, + "y": 0.6707443673211778, + "z": 0.20147312765091507 + }, + "up": { + "x": -0.42812844285503454, + "y": -0.645569749110326, + "z": 0.6324126306867646 + }, + "right": { + "x": 0.5542521663259896, + "y": 0.365159818080445, + "z": 0.7479724883860396 + }, + "heading": 5.236133417666943, + "pitch": -0.4002887210284125, + "roll": 3.454685293391435e-7, + "height": 779.1027791374372, + "frameIsIdentity": false + } + }, + { + "step": 24, + "kind": "pose", + "value": { + "position": { + "x": -742113.723062032, + "y": -5464703.744187326, + "z": 3195005.8089863225 + }, + "direction": { + "x": -0.35684794477371834, + "y": 0.7936983168713397, + "z": 0.4926484812788687 + }, + "up": { + "x": -0.2772114952376788, + "y": -0.5935856530234439, + "z": 0.7555195956643495 + }, + "right": { + "x": 0.8920837019130344, + "y": 0.133037792827207, + "z": 0.4318421175161145 + }, + "heading": 5.759670504260609, + "pitch": -0.4002893154008027, + "roll": 4.877172621320369e-7, + "height": 779.1034471727313, + "frameIsIdentity": false + } + }, + { + "step": 26, + "kind": "pose", + "value": { + "position": { + "x": -742113.723062032, + "y": -5464703.744187326, + "z": 3195005.8089863225 + }, + "direction": { + "x": -0.3568479447737182, + "y": 0.7936983168713396, + "z": 0.4926484812788689 + }, + "up": { + "x": -0.27721149523767874, + "y": -0.5935856530234442, + "z": 0.7555195956643492 + }, + "right": { + "x": 0.8920837019130345, + "y": 0.13303779282720674, + "z": 0.43184211751611445 + }, + "heading": 5.759670504260609, + "pitch": -0.4002893154008027, + "roll": 4.877172621320369e-7, + "height": 779.1034471727313, + "frameIsIdentity": true + } + } + ], + "flights": [] + }, + "move, look, rotate and zoom": { + "records": [ + { + "step": 3, + "kind": "pose", + "value": { + "position": { + "x": -742861.1453857983, + "y": -5463545.417314854, + "z": 3196333.165219624 + }, + "direction": { + "x": 0.36092901926672927, + "y": 0.8441580339915824, + "z": 0.39639305833808874 + }, + "up": { + "x": 0.10593304234250588, + "y": -0.45940299877841756, + "z": 0.8818883575903802 + }, + "right": { + "x": 0.9265573018390272, + "y": -0.2763079773746384, + "z": -0.25523610255588547 + }, + "heading": 0.3000022223061638, + "pitch": -0.599987037510501, + "roll": 1.2940786220383416e-8, + "height": 543.5362668615833, + "frameIsIdentity": true + } + }, + { + "step": 6, + "kind": "pose", + "value": { + "position": { + "x": -742812.6988598595, + "y": -5463568.420773699, + "z": 3196338.041181648 + }, + "direction": { + "x": 0.3609290192667293, + "y": 0.8441580339915825, + "z": 0.3963930583380886 + }, + "up": { + "x": 0.1059330423425058, + "y": -0.45940299877841795, + "z": 0.88188835759038 + }, + "right": { + "x": 0.926557301839027, + "y": -0.2763079773746384, + "z": -0.2552361025558856 + }, + "heading": 0.3000122509964944, + "pitch": -0.5999852643816217, + "roll": 6.28317583242914, + "height": 560.0433317967465, + "frameIsIdentity": true + } + }, + { + "step": 8, + "kind": "pose", + "value": { + "position": { + "x": -742812.6988598595, + "y": -5463568.420773699, + "z": 3196338.041181648 + }, + "direction": { + "x": 0.266624496378452, + "y": 0.8675255294026185, + "z": 0.41989383630346794 + }, + "up": { + "x": 0.10593304234250578, + "y": -0.45940299877841795, + "z": 0.88188835759038 + }, + "right": { + "x": 0.9579611518589873, + "y": -0.19065240766406277, + "z": -0.2143877118234586 + }, + "heading": 0.1790386764969023, + "pitch": -0.5965724613208501, + "roll": 0.06818227538084543, + "height": 560.0433317967465, + "frameIsIdentity": true + } + }, + { + "step": 10, + "kind": "pose", + "value": { + "position": { + "x": -742812.6988598595, + "y": -5463568.420773699, + "z": 3196338.041181648 + }, + "direction": { + "x": 0.27158573064024977, + "y": 0.8434807681507424, + "z": 0.46344512584818004 + }, + "up": { + "x": 0.09247498278780061, + "y": -0.5021870699368215, + "z": 0.8598002816623556 + }, + "right": { + "x": 0.9579611518589873, + "y": -0.19065240766406283, + "z": -0.21438771182345862 + }, + "heading": 0.18302469055574022, + "pitch": -0.546684916077532, + "roll": 0.06602515864794256, + "height": 560.0433317967465, + "frameIsIdentity": true + } + }, + { + "step": 12, + "kind": "pose", + "value": { + "position": { + "x": -797410.332151974, + "y": -5455867.241442038, + "z": 3196338.041181648 + }, + "direction": { + "x": 0.28000681856896037, + "y": 0.8407227824214436, + "z": 0.46344512584818004 + }, + "up": { + "x": 0.08744857207525077, + "y": -0.5030866952080262, + "z": 0.8598002816623558 + }, + "right": { + "x": 0.9560067618991454, + "y": -0.20022232698231404, + "z": -0.21438771182345862 + }, + "heading": 0.18302469055574022, + "pitch": -0.546684916077532, + "roll": 0.06602515864794256, + "height": 560.0433317972171, + "frameIsIdentity": true + } + }, + { + "step": 14, + "kind": "pose", + "value": { + "position": { + "x": -797354.3307882602, + "y": -5455699.096885554, + "z": 3196430.730206818 + }, + "direction": { + "x": 0.28000681856896037, + "y": 0.8407227824214436, + "z": 0.46344512584818004 + }, + "up": { + "x": 0.0874485720752508, + "y": -0.503086695208026, + "z": 0.859800281662356 + }, + "right": { + "x": 0.9560067618991454, + "y": -0.20022232698231407, + "z": -0.2143877118234586 + }, + "heading": 0.18302752393315913, + "pitch": -0.5466580707844679, + "roll": 0.06602517665331131, + "height": 456.0739515034903, + "frameIsIdentity": true + } + }, + { + "step": 18, + "kind": "pose", + "value": { + "position": { + "x": -797438.3328338308, + "y": -5455951.31372028, + "z": 3196291.6966690635 + }, + "direction": { + "x": 0.5280904473124226, + "y": 0.8234772914284811, + "z": 0.2073779881254939 + }, + "up": { + "x": 0.14133419003404374, + "y": -0.3260326231621596, + "z": 0.9347338526882517 + }, + "right": { + "x": 0.8373440906728764, + "y": -0.46431441840159676, + "z": -0.28856020979961994 + }, + "heading": 0.5708170714247824, + "pitch": -0.7277421048485122, + "roll": 6.085332686616436, + "height": 612.0297448891034, + "frameIsIdentity": true + } + } + ], + "flights": [] + }, + "commands inside a lookAt frame": { + "records": [ + { + "step": 3, + "kind": "pose", + "value": { + "position": { + "x": -744481.3426810504, + "y": -5464789.733339174, + "z": 3195145.9335938715 + }, + "direction": { + "x": 0.6615694917883854, + "y": 0.6693543142368296, + "z": 0.33806894200355225 + }, + "up": { + "x": 0.22881921132251026, + "y": -0.6095195427272045, + "z": 0.7590307606173572 + }, + "right": { + "x": 0.7141201411979654, + "y": -0.4247949258714883, + "z": -0.5564006603961168 + }, + "heading": 0.6998711329857512, + "pitch": -0.5003442130772937, + "roll": 6.283184741449499, + "height": 1198.941902759257, + "frameIsIdentity": false + } + }, + { + "step": 5, + "kind": "pose", + "value": { + "position": { + "x": -744971.9139187913, + "y": -5464375.678277303, + "z": 3195735.7092693974 + }, + "direction": { + "x": 0.8577979868850266, + "y": 0.50373228948861, + "z": 0.10215867179324013 + }, + "up": { + "x": 0.33601932677210566, + "y": -0.6999992672201266, + "z": 0.6301523926217426 + }, + "right": { + "x": 0.38893910285752537, + "y": -0.5062161657018166, + "z": -0.7697217470297525 + }, + "heading": 1.099821436536688, + "pitch": -0.5003437292588502, + "roll": 6.283185070682873, + "height": 1198.9411841126202, + "frameIsIdentity": false + } + }, + { + "step": 7, + "kind": "pose", + "value": { + "position": { + "x": -745829.7119056764, + "y": -5464879.410566793, + "z": 3195633.5505976044 + }, + "direction": { + "x": 0.8577979868850265, + "y": 0.5037322894886102, + "z": 0.10215867179324012 + }, + "up": { + "x": 0.33601932677210533, + "y": -0.6999992672201268, + "z": 0.6301523926217425 + }, + "right": { + "x": 0.3889391028575255, + "y": -0.5062161657018166, + "z": -0.7697217470297526 + }, + "heading": 1.0997500524431754, + "pitch": -0.5004811848386681, + "roll": 6.283184976084162, + "height": 1678.7289113419563, + "frameIsIdentity": false + } + }, + { + "step": 9, + "kind": "pose", + "value": { + "position": { + "x": -745829.7119056764, + "y": -5464879.410566793, + "z": 3195633.5505976044 + }, + "direction": { + "x": 0.8577979868850266, + "y": 0.5037322894886103, + "z": 0.1021586717932402 + }, + "up": { + "x": 0.33601932677210533, + "y": -0.6999992672201268, + "z": 0.6301523926217425 + }, + "right": { + "x": 0.3889391028575255, + "y": -0.5062161657018165, + "z": -0.7697217470297525 + }, + "heading": 1.0997500524431754, + "pitch": -0.5004811848386681, + "roll": 6.283184976084162, + "height": 1678.7289113419563, + "frameIsIdentity": true + } + } + ], + "flights": [] + }, + "flyTo from orbit, default orientation and duration": { + "records": [ + { + "step": 3, + "kind": "flying", + "value": { + "flying": true, + "pose": { + "position": { + "x": -1495728.3550772713, + "y": -11000318.26996234, + "z": 6457154.663984799 + }, + "direction": { + "x": 0.11626523053581833, + "y": 0.8550714006879107, + "z": -0.5053071302624452 + }, + "up": { + "x": 0.06808083894848728, + "y": 0.5006998055343523, + "z": 0.8629395715262639 + }, + "right": { + "x": 0.9908821299915158, + "y": -0.13473160157690117, + "z": -1.942890293094024e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 6470135.446913586, + "frameIsIdentity": true + } + } + }, + { + "step": 5, + "kind": "flying", + "value": { + "flying": true, + "pose": { + "position": { + "x": -874274.9258019626, + "y": -6429845.637828038, + "z": 3765351.294742126 + }, + "direction": { + "x": 0.11633560127018917, + "y": 0.8555889415049588, + "z": -0.50441410671345 + }, + "up": { + "x": 0.06796052045548531, + "y": 0.4998149244579911, + "z": 0.8634618746351643 + }, + "right": { + "x": 0.9908821299915158, + "y": -0.13473160157690134, + "z": -2.0122792321330965e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 1129608.3358012843, + "frameIsIdentity": true + } + } + }, + { + "step": 7, + "kind": "pose", + "value": { + "position": { + "x": -742897.2382877291, + "y": -5463629.833118254, + "z": 3196293.5259137903 + }, + "direction": { + "x": 0.11636554151780582, + "y": 0.8558091367374329, + "z": -0.5040335129969017 + }, + "up": { + "x": 0.0679092424545046, + "y": 0.49943780094547585, + "z": 0.8636840960536455 + }, + "right": { + "x": 0.9908821299915158, + "y": -0.13473160157690134, + "z": -1.942890293094024e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 600.0000000013205, + "frameIsIdentity": true + } + } + ], + "flights": [ + { + "step": 1, + "outcome": "complete", + "frames": 182 + } + ] + }, + "flyTo with duration and orientation": { + "records": [ + { + "step": 4, + "kind": "flying", + "value": { + "flying": true, + "pose": { + "position": { + "x": -743130.6682365285, + "y": -5467133.7250257, + "z": 3198582.482610983 + }, + "direction": { + "x": 0.17356312231607446, + "y": 0.9816549804370923, + "z": 0.07892618041529287 + }, + "up": { + "x": 0.045628675693830216, + "y": -0.08807209610567275, + "z": 0.995068505100016 + }, + "right": { + "x": 0.9837651480543123, + "y": -0.16910589957362532, + "z": -0.060077684734619274 + }, + "heading": 0.06961743749999982, + "pitch": -0.9622905546874998, + "roll": 6.283185307179586, + "height": 4779.5447812491775, + "frameIsIdentity": true + } + } + }, + { + "step": 6, + "kind": "pose", + "value": { + "position": { + "x": -738610.7909727877, + "y": -5462883.227080723, + "z": 3199736.4715567394 + }, + "direction": { + "x": 0.9303152730880313, + "y": 0.3462649620085369, + "z": 0.12088866259649206 + }, + "up": { + "x": 0.2164423402660448, + "y": -0.7844348714175958, + "z": 0.5812182428694213 + }, + "right": { + "x": 0.2960847952855731, + "y": -0.5145507832947943, + "z": -0.8047181403518961 + }, + "heading": 1.2000000000000002, + "pitch": -0.3500000000000001, + "roll": 6.283185307179586, + "height": 1200.0000000007087, + "frameIsIdentity": true + } + } + ], + "flights": [ + { + "step": 2, + "outcome": "complete", + "frames": 121 + } + ] + }, + "flyTo with a height cap, pitch adjustment and named easings": { + "records": [ + { + "step": 4, + "kind": "flying", + "value": { + "flying": true, + "pose": { + "position": { + "x": 1984882.523799276, + "y": -5603747.12397889, + "z": 4188952.45647366 + }, + "direction": { + "x": -0.27187148869536876, + "y": 0.7701919208321957, + "z": -0.5769664623870064 + }, + "up": { + "x": 0.20699684818048317, + "y": 0.6323184154918451, + "z": 0.7465425147124768 + }, + "right": { + "x": 0.9398075326777873, + "y": 0.08353338562931166, + "z": -0.33133664905560134 + }, + "heading": 0.418333333333333, + "pitch": -1.5686260059206063, + "roll": 0, + "height": 901464.598149477, + "frameIsIdentity": true + } + } + }, + { + "step": 6, + "kind": "flying", + "value": { + "flying": true, + "pose": { + "position": { + "x": 4075000.9440966845, + "y": -3759235.4881838616, + "z": 4706246.105463546 + }, + "direction": { + "x": -0.558765941586562, + "y": 0.5154680462166173, + "z": -0.6496716985158782 + }, + "up": { + "x": -0.22804678090932132, + "y": 0.6576743833361776, + "z": 0.7179547835485706 + }, + "right": { + "x": 0.7973551832400532, + "y": 0.5493242201405304, + "z": -0.24993521746088385 + }, + "heading": 0.33499999999999996, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 903133.1780701573, + "frameIsIdentity": true + } + } + }, + { + "step": 8, + "kind": "pose", + "value": { + "position": { + "x": 4201951.609403296, + "y": 172440.74987437026, + "z": 4781836.313879029 + }, + "direction": { + "x": -0.9865515437306981, + "y": 0.12362053826970447, + "z": 0.10692994940841039 + }, + "up": { + "x": 0.12152411800090618, + "y": 0.11725870089489987, + "z": 0.9856380095189827 + }, + "right": { + "x": 0.10930663432142917, + "y": 0.9853772676403435, + "z": -0.13070462926374277 + }, + "heading": 0.20000000000000018, + "pitch": -0.6000000000000005, + "roll": 6.283185307179586, + "height": 2000.000000000638, + "frameIsIdentity": true + } + }, + { + "step": 11, + "kind": "flying", + "value": { + "flying": true, + "pose": { + "position": { + "x": 4231093.123870897, + "y": 176155.14556551861, + "z": 4819982.018419662 + }, + "direction": { + "x": -0.8555001712159346, + "y": -0.016160829519683742, + "z": -0.5175502725714116 + }, + "up": { + "x": -0.5177618038998317, + "y": 0.0392336788205274, + "z": 0.8546247321886958 + }, + "right": { + "x": 0.006493956567336286, + "y": 0.9990993674482251, + "z": -0.041931879193021285 + }, + "heading": 0.06381574074074159, + "pitch": -1.2610358932808605, + "roll": 6.283185307179586, + "height": 49985.22417669813, + "frameIsIdentity": true + } + } + }, + { + "step": 13, + "kind": "pose", + "value": { + "position": { + "x": 4197662.204280583, + "y": 175934.17309982784, + "z": 4783858.325902303 + }, + "direction": { + "x": -0.6567983013747672, + "y": -0.027528004975699405, + "z": -0.7535636670204306 + }, + "up": { + "x": -0.7529026636531263, + "y": -0.031555971183053186, + "z": 0.6573749308778986 + }, + "right": { + "x": -0.04187565372919943, + "y": 0.9991228300988584, + "z": -1.5959455978986623e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 799.9999999980523, + "frameIsIdentity": true + } + }, + { + "step": 16, + "kind": "flying", + "value": { + "flying": true, + "pose": { + "position": { + "x": 4202886.42370558, + "y": 173801.72952123257, + "z": 4784322.846250303 + }, + "direction": { + "x": -0.6572325177486237, + "y": -0.027178499908544478, + "z": -0.7531976810615699 + }, + "up": { + "x": -0.752554496975918, + "y": -0.03112034443654461, + "z": 0.6577942332093477 + }, + "right": { + "x": -0.04131763177056407, + "y": 0.9991460620474226, + "z": -1.3530843112619095e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 4524.029413470576, + "frameIsIdentity": true + } + } + }, + { + "step": 18, + "kind": "pose", + "value": { + "position": { + "x": 4206791.645386254, + "y": 168962.2063192315, + "z": 4777066.326463452 + }, + "direction": { + "x": -0.6581582273617292, + "y": -0.026434365087739404, + "z": -0.75241542521301 + }, + "up": { + "x": -0.7518092756704922, + "y": -0.030195779742946006, + "z": 0.6586888703337301 + }, + "right": { + "x": -0.04013179253255957, + "y": 0.9991943951144461, + "z": -1.491862189340054e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 1499.9999999976308, + "frameIsIdentity": true + } + } + ], + "flights": [ + { + "step": 2, + "outcome": "complete", + "frames": 182 + }, + { + "step": 9, + "outcome": "complete", + "frames": 62 + }, + { + "step": 14, + "outcome": "complete", + "frames": 61 + } + ] + }, + "flyTo with a duration of 0 completes inside the call": { + "records": [ + { + "step": 2, + "kind": "flying", + "value": { + "flying": false, + "pose": { + "position": { + "x": -742932.1479557978, + "y": -5463886.575900588, + "z": 3196444.7358962456 + }, + "direction": { + "x": 0.11636553215672032, + "y": 0.8558090678914342, + "z": -0.5040336320531377 + }, + "up": { + "x": 0.06790925849514162, + "y": 0.49943791891617306, + "z": 0.8636840265741415 + }, + "right": { + "x": 0.9908821299915159, + "y": -0.1347316015769006, + "z": -1.9428902930940242e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 900.0000000004128, + "frameIsIdentity": true + } + } + }, + { + "step": 4, + "kind": "pose", + "value": { + "position": { + "x": -742932.1479557978, + "y": -5463886.575900588, + "z": 3196444.7358962456 + }, + "direction": { + "x": 0.11636553215672032, + "y": 0.8558090678914342, + "z": -0.5040336320531377 + }, + "up": { + "x": 0.06790925849514162, + "y": 0.49943791891617306, + "z": 0.8636840265741415 + }, + "right": { + "x": 0.9908821299915159, + "y": -0.1347316015769006, + "z": -1.9428902930940242e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 900.0000000004128, + "frameIsIdentity": true + } + } + ], + "flights": [ + { + "step": 1, + "outcome": "complete", + "frames": 0 + } + ] + }, + "flyTo where the camera already is completes inside the call": { + "records": [ + { + "step": 2, + "kind": "flying", + "value": { + "flying": false, + "pose": { + "position": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": 0.36092901926672916, + "y": 0.8441580339915824, + "z": 0.39639305833808886 + }, + "up": { + "x": 0.1059330423425059, + "y": -0.4594029987784171, + "z": 0.8818883575903804 + }, + "right": { + "x": 0.9265573018390272, + "y": -0.27630797737463836, + "z": -0.2552361025558853 + }, + "heading": 0.2999999999999998, + "pitch": -0.6000000000000001, + "roll": 6.283185307179586, + "height": 600.0000000002616, + "frameIsIdentity": true + } + } + } + ], + "flights": [ + { + "step": 1, + "outcome": "complete", + "frames": 0 + } + ] + }, + "rectangle destinations": { + "records": [ + { + "step": 2, + "kind": "pose", + "value": { + "position": { + "x": -751947.4785351472, + "y": -5525205.585116517, + "z": 3223894.5692276806 + }, + "direction": { + "x": 0.11654615446887955, + "y": 0.8563649483202656, + "z": -0.5030467862604284 + }, + "up": { + "x": 0.06783632709842566, + "y": 0.4984519052955266, + "z": 0.8642591803579843 + }, + "right": { + "x": 0.9908658973868824, + "y": -0.1348509302737231, + "z": -1.5959455978986625e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 68266.53028034384, + "frameIsIdentity": true + } + }, + { + "step": 4, + "kind": "pose", + "value": { + "position": { + "x": 12974702.828049012, + "y": 3476561.144809536, + "z": 7753327.086205596 + }, + "direction": { + "x": -0.8351566399388897, + "y": -0.22377954722511528, + "z": -0.5024301951611848 + }, + "up": { + "x": -0.48531030141364523, + "y": -0.1300385033422907, + "z": 0.8646177762400528 + }, + "right": { + "x": -0.2588190451025206, + "y": 0.9659258262890683, + "z": -1.9428902930940242e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 9136675.502083447, + "frameIsIdentity": true + } + }, + { + "step": 6, + "kind": "pose", + "value": { + "position": { + "x": 14926443.071414784, + "y": -6960314.7132153325, + "z": 6004715.867808007 + }, + "direction": { + "x": -0.850804588245522, + "y": 0.39673669509229725, + "z": -0.34457444389799047 + }, + "up": { + "x": -0.31229050171857203, + "y": 0.14562345252043715, + "z": 0.9387589960220838 + }, + "right": { + "x": 0.42261826174069955, + "y": 0.90630778703665, + "z": -1.942890293094024e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 11154385.120979086, + "frameIsIdentity": true + } + }, + { + "step": 8, + "kind": "pose", + "value": { + "position": { + "x": -7481674.14122793, + "y": 9.162408289318306e-10, + "z": -6252288.005905731 + }, + "direction": { + "x": 0.7652100415869902, + "y": -9.371120281049209e-17, + "z": 0.6437807019897667 + }, + "up": { + "x": -0.6437807019897667, + "y": -1.432042073205708e-16, + "z": 0.7652100415869902 + }, + "right": { + "x": 2.0483351716941613e-17, + "y": -1, + "z": -1.6991076136884995e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 3380878.2052681325, + "frameIsIdentity": true + } + }, + { + "step": 10, + "kind": "pose", + "value": { + "position": { + "x": 19628816.78887241, + "y": 0, + "z": 0 + }, + "direction": { + "x": -1, + "y": 0, + "z": 2.220446049250313e-16 + }, + "up": { + "x": 2.220446049250313e-16, + "y": 2.220446049250313e-16, + "z": 1 + }, + "right": { + "x": -4.930380657631324e-32, + "y": 1, + "z": -2.220446049250313e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 13250679.78887241, + "frameIsIdentity": true + } + }, + { + "step": 12, + "kind": "pose", + "value": { + "position": { + "x": 4343553.675273656, + "y": 189643.64903618084, + "z": 4882136.422023611 + }, + "direction": { + "x": -0.9397106512905629, + "y": 0.2572709558733447, + "z": -0.22528991791707398 + }, + "up": { + "x": -0.11933020821145057, + "y": 0.37069463412744436, + "z": 0.9210568872970508 + }, + "right": { + "x": 0.32047494950351146, + "y": 0.892410860250555, + "z": -0.3176454993346925 + }, + "heading": 0.5, + "pitch": -0.9000000000000004, + "roll": 6.283185307179586, + "height": 171221.26219356808, + "frameIsIdentity": true + } + }, + { + "step": 15, + "kind": "pose", + "value": { + "position": { + "x": 12974702.828049012, + "y": 3476561.144809536, + "z": 7753327.086205596 + }, + "direction": { + "x": -0.8351566399388897, + "y": -0.22377954722511528, + "z": -0.5024301951611848 + }, + "up": { + "x": -0.48531030141364523, + "y": -0.1300385033422907, + "z": 0.8646177762400528 + }, + "right": { + "x": -0.2588190451025206, + "y": 0.9659258262890683, + "z": -1.9428902930940242e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 9136675.502083447, + "frameIsIdentity": true + } + } + ], + "flights": [ + { + "step": 13, + "outcome": "complete", + "frames": 62 + } + ] + }, + "flyToBounds default offset and explicit offset": { + "records": [ + { + "step": 3, + "kind": "pose", + "value": { + "position": { + "x": -743028.0319117856, + "y": -5464591.753434517, + "z": 3195599.567576459 + }, + "direction": { + "x": 0.13030193990081027, + "y": 0.9583042303352324, + "z": 0.2543116328044856 + }, + "up": { + "x": -0.03426381358742819, + "y": -0.25199285239492364, + "z": 0.9671223259858683 + }, + "right": { + "x": 0.99088212999152, + "y": -0.13473160157686984, + "z": 5.315192730392937e-14 + }, + "heading": 6.283185307179525, + "pitch": -0.785569032641992, + "roll": 6.283185307179586, + "height": 1088.7553895953624, + "frameIsIdentity": true + } + }, + { + "step": 6, + "kind": "pose", + "value": { + "position": { + "x": -745425.4168415141, + "y": -5464323.531851885, + "z": 3195100.5370105966 + }, + "direction": { + "x": 0.8659992966445589, + "y": 0.4023947661006701, + "z": 0.29685631276076374 + }, + "up": { + "x": 0.1460791380408637, + "y": -0.7713444408323176, + "z": 0.619426056140939 + }, + "right": { + "x": 0.47823226955148856, + "y": -0.49305801465128474, + "z": -0.7267652238156184 + }, + "heading": 0.9997798452485549, + "pitch": -0.30044934326360306, + "roll": 6.283184591910284, + "height": 887.2048464748486, + "frameIsIdentity": true + } + } + ], + "flights": [ + { + "step": 1, + "outcome": "complete", + "frames": 182 + }, + { + "step": 4, + "outcome": "complete", + "frames": 61 + } + ] + }, + "a cancelled flight stops where it was": { + "records": [ + { + "step": 5, + "kind": "flying", + "value": { + "flying": false, + "pose": { + "position": { + "x": -1773311.06638954, + "y": -13041797.366289483, + "z": 7659494.280044535 + }, + "direction": { + "x": 0.11624972857822315, + "y": 0.8549573917057475, + "z": -0.5055035696938199 + }, + "up": { + "x": 0.06810730554768926, + "y": 0.5008944538565265, + "z": 0.862824513459606 + }, + "right": { + "x": 0.9908821299915159, + "y": -0.13473160157690126, + "z": -2.636779683484747e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 8855572.433024691, + "frameIsIdentity": true + } + } + } + ], + "flights": [ + { + "step": 1, + "outcome": "cancelled", + "frames": 31 + } + ] + }, + "changed fires by each listener’s threshold": { + "records": [ + { + "step": 53, + "kind": "changed", + "value": [ + { + "threshold": 0.5, + "count": 6 + }, + { + "threshold": 0.1, + "count": 28 + } + ] + } + ], + "flights": [] + }, + "projection, picks and the horizon": { + "records": [ + { + "step": 2, + "kind": "project", + "value": [ + { + "x": 639.9998400284944, + "y": 1980.307337277763 + }, + { + "x": -1748.6843471897712, + "y": 1395.6087575296795 + }, + null, + { + "x": 321.114707167823, + "y": 154.60524754964482 + }, + null + ] + }, + { + "step": 3, + "kind": "pickEllipsoid", + "value": [ + { + "x": -742513.6695927457, + "y": -5462732.724329275, + "z": 3196714.783169287 + }, + { + "x": -743158.3181329333, + "y": -5461939.22456592, + "z": 3197912.564935943 + }, + { + "x": -741078.6232824821, + "y": -5462559.4110169215, + "z": 3197339.6752386307 + }, + { + "x": -743042.0104366634, + "y": -5462796.394165715, + "z": 3196484.7582323574 + }, + { + "x": -742276.2388011032, + "y": -5463024.754391748, + "z": 3196273.8132951404 + }, + { + "x": -742831.7488226165, + "y": -5462772.267162364, + "z": 3196574.256440562 + } + ] + }, + { + "step": 4, + "kind": "pickRay", + "value": [ + { + "origin": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": 0.36092901932484533, + "y": 0.844158033937051, + "z": 0.3963930584013019 + } + }, + { + "origin": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": -0.11084611858274519, + "y": 0.7177781031673482, + "z": 0.687391978865497 + } + }, + { + "origin": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": 0.7721265691989996, + "y": 0.45446746139954164, + "z": 0.44416200610367235 + } + }, + { + "origin": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": -0.16692946595145694, + "y": 0.9609964366113153, + "z": 0.22050034516416442 + } + }, + { + "origin": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": 0.7160432216003363, + "y": 0.697685795209533, + "z": -0.02272962742882348 + } + }, + { + "origin": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": 0.07238646382351538, + "y": 0.947880196294632, + "z": 0.3102955580210207 + } + } + ] + }, + { + "step": 6, + "kind": "occluded", + "value": [ + false, + true, + true, + true, + false, + false + ] + } + ], + "flights": [] + } + } +} diff --git a/src/render/conformance/goldens/visual/city-city.png b/src/render/conformance/goldens/visual/city-city.png new file mode 100644 index 000000000..9505968f1 Binary files /dev/null and b/src/render/conformance/goldens/visual/city-city.png differ diff --git a/src/render/conformance/goldens/visual/city-orbit.png b/src/render/conformance/goldens/visual/city-orbit.png new file mode 100644 index 000000000..5c412b506 Binary files /dev/null and b/src/render/conformance/goldens/visual/city-orbit.png differ diff --git a/src/render/conformance/goldens/visual/city-street.png b/src/render/conformance/goldens/visual/city-street.png new file mode 100644 index 000000000..93697140b Binary files /dev/null and b/src/render/conformance/goldens/visual/city-street.png differ diff --git a/src/render/conformance/goldens/visual/icons-tilted.png b/src/render/conformance/goldens/visual/icons-tilted.png new file mode 100644 index 000000000..f99080f9b Binary files /dev/null and b/src/render/conformance/goldens/visual/icons-tilted.png differ diff --git a/src/render/conformance/goldens/visual/icons-top.png b/src/render/conformance/goldens/visual/icons-top.png new file mode 100644 index 000000000..daed90a61 Binary files /dev/null and b/src/render/conformance/goldens/visual/icons-top.png differ diff --git a/src/render/conformance/goldens/visual/lines-tilted.png b/src/render/conformance/goldens/visual/lines-tilted.png new file mode 100644 index 000000000..b6a54860b Binary files /dev/null and b/src/render/conformance/goldens/visual/lines-tilted.png differ diff --git a/src/render/conformance/goldens/visual/lines-top.png b/src/render/conformance/goldens/visual/lines-top.png new file mode 100644 index 000000000..d6b72e7db Binary files /dev/null and b/src/render/conformance/goldens/visual/lines-top.png differ diff --git a/src/render/conformance/goldens/visual/manifest.json b/src/render/conformance/goldens/visual/manifest.json new file mode 100644 index 000000000..0adb87527 --- /dev/null +++ b/src/render/conformance/goldens/visual/manifest.json @@ -0,0 +1,20 @@ +{ + "renderer": "cesium", + "size": { + "width": 640, + "height": 360 + }, + "views": [ + "icons-top.png", + "icons-tilted.png", + "points-top.png", + "lines-top.png", + "lines-tilted.png", + "shapes-top.png", + "shapes-tilted.png", + "models-oblique.png", + "city-orbit.png", + "city-city.png", + "city-street.png" + ] +} diff --git a/src/render/conformance/goldens/visual/models-oblique.png b/src/render/conformance/goldens/visual/models-oblique.png new file mode 100644 index 000000000..cc316838c Binary files /dev/null and b/src/render/conformance/goldens/visual/models-oblique.png differ diff --git a/src/render/conformance/goldens/visual/points-top.png b/src/render/conformance/goldens/visual/points-top.png new file mode 100644 index 000000000..65bf97ff2 Binary files /dev/null and b/src/render/conformance/goldens/visual/points-top.png differ diff --git a/src/render/conformance/goldens/visual/shapes-tilted.png b/src/render/conformance/goldens/visual/shapes-tilted.png new file mode 100644 index 000000000..819e40fd0 Binary files /dev/null and b/src/render/conformance/goldens/visual/shapes-tilted.png differ diff --git a/src/render/conformance/goldens/visual/shapes-top.png b/src/render/conformance/goldens/visual/shapes-top.png new file mode 100644 index 000000000..064e14be6 Binary files /dev/null and b/src/render/conformance/goldens/visual/shapes-top.png differ diff --git a/src/render/conformance/index.js b/src/render/conformance/index.js new file mode 100644 index 000000000..8ee55ee3c --- /dev/null +++ b/src/render/conformance/index.js @@ -0,0 +1,123 @@ +/** + * @module render/conformance + * @description The renderer conformance suite. It runs every check against + * a renderer through the contract alone, in Node or in a browser page, and + * reports each as passed, failed or skipped. + * + * A harness says how to make the renderer under test and drive its clock: + * + * ```js + * const report = await runConformance({ + * name: 'stub', + * create: ({ viewport, capabilities }) => makeRenderer(...), + * frame: (r) => advanceOneFrame(r), // 1/60 s on a virtual clock + * now: (r) => clockMs(r), + * toEcef: ({ lon, lat, height }) => ecef, + * tolerance: DEFAULT_TOLERANCE, + * flightPaths: 'trace', // or 'endpoints' + * }); + * ``` + * + * Optional harness members widen what is checked: `pointer(r, type, x, y)` + * (synthetic input), `scriptHeights(r, fn)` (heights from a function), + * `wallTime(r)` (the wall clock a `live` motion clock reads), + * `assets.model` (a glTF URL). `create` may return null when it cannot + * make a renderer with fewer capabilities; those checks are then skipped. + */ +import { cameraChecks } from './checks/camera.js'; +import { portChecks } from './checks/ports.js'; + +/** + * @typedef {object} ConformanceTolerance + * @property {number} metres Positions and heights. + * @property {number} unit Unit vectors (direction, up, right). + * @property {number} radians Heading, pitch and roll. + * @property {number} pixels Screen positions. + * @property {number} changedCount `changed` events per script. + */ + +/** + * @typedef {object} ConformanceHarness + * @property {string} name + * @property {(options: {viewport: {width: number, height: number}, capabilities?: string[]}) => Promise} create + * @property {(r: import('../contract/renderer').Renderer) => void|Promise} frame + * @property {(r: import('../contract/renderer').Renderer) => number} now + * @property {(p: {lon: number, lat: number, height?: number}) => {x: number, y: number, z: number}} toEcef + * @property {ConformanceTolerance} tolerance + * @property {'trace'|'endpoints'} flightPaths Whether flight paths are compared frame by frame, or only where flights end. + * @property {(r: object, type: string, x: number, y: number, extra?: object) => void|Promise} [pointer] + * @property {(r: object, fn: (point: {lon: number, lat: number}, against?: string) => number|null) => void} [scriptHeights] + * @property {(r: object) => number} [wallTime] Epoch ms the renderer's `live` motion clock reads. + * @property {{model?: string}} [assets] + */ + +/** + * Tolerances a renderer that reproduces the reference camera meets: under a + * millimetre at city scale, well under a pixel on screen. + * @type {Readonly} + */ +export const DEFAULT_TOLERANCE = Object.freeze({ + metres: 1e-3, + unit: 1e-9, + radians: 1e-9, + pixels: 1e-3, + changedCount: 0, +}); + +/** Every check, in run order. */ +export function conformanceChecks() { + return [...portChecks(), ...cameraChecks()]; +} + +/** + * @param {ConformanceHarness} harness + * @param {{only?: (id: string) => boolean}} [options] + * @returns {Promise<{harness: string, passed: number, failed: number, skipped: number, + * results: Array<{id: string, port: string, title: string, status: 'pass'|'fail'|'skip', detail?: string, ms: number}>}>} + */ +export async function runConformance(harness, options = {}) { + const results = []; + for (const c of conformanceChecks()) { + if (options.only && !options.only(c.id)) continue; + const start = Date.now(); + let status = 'pass'; + let detail; + try { + const outcome = await c.run(harness); + if (outcome?.skip) { + status = 'skip'; + detail = outcome.skip; + } + } catch (error) { + status = 'fail'; + detail = error?.message ?? String(error); + } + results.push({ + id: c.id, + port: c.port, + title: c.title, + status, + ...(detail ? { detail } : {}), + ms: Date.now() - start, + }); + } + const count = (s) => results.filter((r) => r.status === s).length; + return { + harness: harness.name, + passed: count('pass'), + failed: count('fail'), + skipped: count('skip'), + results, + }; +} + +export { CAMERA_SCRIPTS, SCRIPT_VIEWPORT } from './cameraScripts.js'; +export { CALIBRATION_PATH, REFERENCE_SCENES, sceneSupport } from './scenes.js'; +export { + VISUAL_TOLERANCE, + compareImages, + drawScene, + referenceName, +} from './visual.js'; +export { contractDriver, runCameraScript, tracePose } from './cameraRunner.js'; +export * as expect from './expect.js'; diff --git a/src/render/conformance/scenes.js b/src/render/conformance/scenes.js new file mode 100644 index 000000000..aab5b0666 --- /dev/null +++ b/src/render/conformance/scenes.js @@ -0,0 +1,558 @@ +/** + * @module render/conformance/scenes + * @description The reference scenes: one per primitive family, a city scene + * that mixes them, and a calibration path. Each is built from contract calls + * only, so any renderer can draw it, and each has fixed views to capture. + * + * The scenes load nothing from the network: the globe is bare (no imagery, + * no photoreal tiles, no atmosphere), so what a capture shows depends on the + * renderer alone. Images are inline SVG. + * + * A scene is `{ id, family, capability, build(renderer, ctx), views }`: + * `build` returns a disposer; `ctx` has `toEcef` and optional `assets` + * (`model`: a glTF URL). A scene whose capability the renderer lacks, or + * whose asset the context lacks, is not drawn. + */ +import { AUSTIN } from './cameraScripts.js'; + +/** Looking down at Austin from `height`, tilted by `pitch`. */ +const over = (height, pitch = -Math.PI / 2, heading = 0, at = AUSTIN) => ({ + destination: { ...at, height }, + orientation: { heading, pitch, roll: 0 }, +}); + +/** A point `east`, `north` metres from `center`, approximately. */ +function offsetFrom(center, east, north, height = 0) { + return { + lon: center.lon + east / (111320 * Math.cos((center.lat * Math.PI) / 180)), + lat: center.lat + north / 110540, + height, + }; +} + +/** A point `east`, `north` metres from Austin, approximately. */ +const offset = (east, north, height = 0) => + offsetFrom(AUSTIN, east, north, height); + +const svg = (body, size = 32) => ({ + svg: `${body}`, +}); +const ICONS = { + dot: svg(''), + arrow: svg(''), + square: svg( + '', + ), +}; + +const BG = { + sky: { + atmosphere: false, + stars: false, + milkyWay: 0, + sun: false, + moon: false, + fog: false, + lighting: false, + }, +}; + +/** Bare globe, empty sky: nothing in a capture but the scene. */ +async function bare(r) { + await r.globe.setBase({ + photoreal: null, + terrain: { kind: 'ellipsoid' }, + imagery: [], + }); + r.globe.setGlobeVisible(true); + r.globe.sky.set(BG.sky); +} + +/** Disposes every set it was given. */ +const disposeAll = + (...handles) => + () => + handles.forEach((h) => h.dispose()); + +const ring = (ctx, center, radius, count, height = 0) => + Array.from({ length: count + 1 }, (_, i) => { + const a = (i / count) * Math.PI * 2; + return ctx.toEcef( + offsetFrom(center, Math.cos(a) * radius, Math.sin(a) * radius, height), + ); + }); + +/** @type {ReadonlyArray} */ +export const REFERENCE_SCENES = Object.freeze([ + { + id: 'icons', + family: 'icons', + capability: null, + async build(r, ctx) { + await bare(r); + const set = r.draw.icons({ id: 'ref:icons' }); + const items = []; + const colors = ['#ff3b30', '#34c759', '#0a84ff', '#ffd60a']; + for (let row = 0; row < 4; row++) + for (let col = 0; col < 6; col++) { + const id = `i${row}${col}`; + const pos = ctx.toEcef(offset((col - 2.5) * 250, (1.5 - row) * 250)); + // Drawn over the globe: at 0 m a tilted view would hide their lower half. + const base = { + id, + pos, + size: [24 + col * 4, 24 + col * 4], + color: colors[row], + disableDepthTest: Infinity, + }; + if (row === 0) items.push({ ...base, image: ICONS.dot }); + if (row === 1) + items.push({ + ...base, + image: ICONS.arrow, + rotationMode: 'screen', + rotation: (col * Math.PI) / 6, + }); + if (row === 2) + items.push({ + ...base, + image: ICONS.arrow, + rotationMode: 'course', + course: col * 60, + }); + if (row === 3) + items.push({ + ...base, + image: ICONS.square, + anchor: col % 2 ? 'bottom' : 'center', + alpha: 0.4 + col * 0.1, + }); + } + set.set(items); + return disposeAll(set); + }, + views: [ + { name: 'top', view: over(3000) }, + { name: 'tilted', view: over(1500, -0.6, 0, offset(0, -2300)) }, + ], + }, + { + id: 'points', + family: 'points', + capability: null, + async build(r, ctx) { + await bare(r); + const set = r.draw.points({ id: 'ref:points' }); + const items = []; + for (let i = 0; i < 8; i++) + items.push({ + id: `p${i}`, + pos: ctx.toEcef(offset((i - 3.5) * 180, 150)), + pixelSize: 4 + i * 3, + color: [1, i / 7, 0, 1], + }); + for (let i = 0; i < 8; i++) + items.push({ + id: `q${i}`, + pos: ctx.toEcef(offset((i - 3.5) * 180, -150)), + pixelSize: 18, + color: '#0a84ff', + outlineColor: '#ffffff', + outlineWidth: i, + }); + set.set(items); + return disposeAll(set); + }, + views: [{ name: 'top', view: over(2500) }], + }, + { + id: 'lines', + family: 'lines', + capability: null, + async build(r, ctx) { + await bare(r); + const set = r.draw.lines({ id: 'ref:lines' }); + const row = (north, height = 0) => [ + ctx.toEcef(offset(-1200, north, height)), + ctx.toEcef(offset(1200, north, height)), + ]; + set.set([ + { + id: 'solid', + positions: row(600), + widthPx: 2, + mode: '3d', + style: { kind: 'solid', color: '#ffffff' }, + }, + { + id: 'wide', + positions: row(400), + widthPx: 8, + mode: '3d', + style: { kind: 'solid', color: '#ff3b30' }, + }, + { + id: 'glow', + positions: row(200), + widthPx: 12, + mode: '3d', + style: { kind: 'glow', color: '#34c759', power: 0.25 }, + }, + { + id: 'dash', + positions: row(0), + widthPx: 4, + mode: '3d', + style: { kind: 'dash', color: '#ffd60a', length: 16 }, + }, + { + id: 'arrow', + positions: row(-200), + widthPx: 14, + mode: '3d', + style: { kind: 'arrow', color: '#0a84ff' }, + }, + { + id: 'raised', + positions: row(-400, 300), + widthPx: 3, + mode: '3d', + style: { kind: 'solid', color: '#bf5af2' }, + }, + { + id: 'geodesic', + positions: row(-600), + widthPx: 3, + mode: 'geodesic', + style: { kind: 'solid', color: '#64d2ff' }, + }, + { + id: 'loop', + positions: ring(ctx, offset(0, 0), 900, 48), + widthPx: 2, + mode: '3d', + style: { kind: 'solid', color: '#ff9f0a' }, + }, + ]); + return disposeAll(set); + }, + views: [ + { name: 'top', view: over(4500) }, + { name: 'tilted', view: over(2500, -0.45, 0, offset(0, -3500)) }, + ], + }, + { + id: 'shapes', + family: 'shapes', + capability: null, + async build(r, ctx) { + await bare(r); + const set = r.draw.shapes({ id: 'ref:shapes' }); + // Flat shapes sit 5 m up: at 0 m they share depth with the globe. + const lift = 5; + const square = (east, north, half) => [ + offset(east - half, north - half, lift), + offset(east + half, north - half, lift), + offset(east + half, north + half, lift), + offset(east - half, north + half, lift), + ]; + set.set([ + { + id: 'polygon', + geometry: { kind: 'polygon', rings: [square(-700, 400, 250)] }, + fill: [1, 0.23, 0.19, 0.8], + outline: '#ffffff', + }, + { + id: 'holed', + geometry: { + kind: 'polygon', + rings: [square(0, 400, 280), square(0, 400, 120)], + }, + fill: '#34c759', + }, + { + id: 'extruded', + geometry: { kind: 'polygon', rings: [square(700, 400, 200)] }, + fill: '#0a84ff', + extrudeHeight: 400, + }, + { + id: 'ellipse', + geometry: { + kind: 'ellipse', + center: offset(-700, -400, 5), + semiMajor: 300, + semiMinor: 160, + rotation: 0.5, + }, + fill: [1, 0.84, 0.04, 0.7], + }, + { + id: 'rectangle', + geometry: { + kind: 'rectangle', + rect: { + west: offset(-250, 0).lon, + south: offset(0, -650).lat, + east: offset(250, 0).lon, + north: offset(0, -150).lat, + }, + height: 5, + }, + fill: '#bf5af2', + }, + { + id: 'box', + geometry: { + kind: 'box', + center: offset(700, -400, 150), + dimensions: { x: 300, y: 300, z: 300 }, + }, + fill: '#ff9f0a', + }, + { + id: 'outline', + geometry: { kind: 'polygon', rings: [square(0, -1000, 200)] }, + outline: '#ffffff', + outlineOnly: true, + }, + ]); + return disposeAll(set); + }, + views: [ + { name: 'top', view: over(5000) }, + { name: 'tilted', view: over(2500, -0.5, 0.3, offset(0, -3600)) }, + ], + }, + { + id: 'models', + family: 'models', + capability: 'models', + needs: ['model'], + async build(r, ctx) { + await bare(r); + r.globe.sky.set({ lighting: true }); + // No wait on `ready` here: a model that never loads would hang the + // run, and `whenSettled` before each view waits for loading models. + const set = r.draw.models({ id: 'ref:models' }); + set.set([ + { + id: 'plane', + pos: offset(0, 0, 300), + url: ctx.assets.model, + orientation: { heading: 0.6, pitch: 0, roll: 0 }, + scale: 4, + minPixelSize: 64, + }, + ]); + return disposeAll(set); + }, + views: [{ name: 'oblique', view: over(330, -0.2, 0, offset(0, -140)) }], + }, + { + id: 'quads', + family: 'quads', + capability: 'quads', + async build(r, ctx) { + await bare(r); + const set = r.draw.quads({ id: 'ref:quads' }); + set.set([ + { + id: 'card', + center: offset(0, 0, 200), + orientation: { x: 0, y: 0, z: 0, w: 1 }, + size: [400, 250], + source: { image: ICONS.square }, + outline: { color: '#ffffff', widthPx: 2 }, + }, + ]); + return disposeAll(set); + }, + views: [{ name: 'oblique', view: over(1500, -0.5, 0, offset(0, -1600)) }], + }, + { + id: 'meshes', + family: 'meshes', + capability: 'meshes', + async build(r, ctx) { + await bare(r); + const set = r.draw.meshes({ id: 'ref:meshes' }); + // A square pyramid, 400 m across and 300 m tall. + const positions = new Float32Array([ + -200, -200, 0, 200, -200, 0, 200, 200, 0, -200, 200, 0, 0, 0, 300, + ]); + const indices = new Uint16Array([0, 1, 4, 1, 2, 4, 2, 3, 4, 3, 0, 4]); + set.set([ + { + id: 'pyramid', + origin: ctx.toEcef(offset(0, 0)), + positions, + indices, + color: '#ff9f0a', + flat: true, + }, + ]); + return disposeAll(set); + }, + views: [{ name: 'oblique', view: over(1200, -0.5, 0, offset(0, -1400)) }], + }, + { + id: 'fields', + family: 'fields', + capability: 'fields', + async build(r) { + await bare(r); + const width = 32; + const height = 16; + const values = new Float32Array(width * height).map( + (_, i) => (i % width) / width, + ); + const field = r.draw.field({ + kind: 'scalarRaster', + id: 'ref:scalar', + values, + width, + height, + rectangle: { west: -100, south: 28, east: -95, north: 32 }, + ramp: [ + { value: 0, color: '#0a84ff' }, + { value: 0.5, color: '#34c759' }, + { value: 1, color: '#ff3b30' }, + ], + alpha: 0.8, + }); + return () => field.dispose(); + }, + views: [ + { + name: 'regional', + view: over(900000, -Math.PI / 2, 0, { lon: -97.5, lat: 30 }), + }, + ], + }, + { + id: 'agents', + family: 'agents', + capability: 'agents', + async build(r, ctx) { + await bare(r); + const set = r.draw.agents({ id: 'ref:agents' }); + set.set([ + { + id: 'road', + positions: [ctx.toEcef(offset(-800, 0)), ctx.toEcef(offset(800, 0))], + count: 20, + speedMps: 0, + color: '#ffd60a', + pixelSize: 6, + }, + ]); + return disposeAll(set); + }, + views: [{ name: 'top', view: over(2500) }], + }, + { + id: 'city', + family: 'city', + capability: null, + async build(r, ctx) { + await bare(r); + const shapes = r.draw.shapes({ id: 'ref:city:blocks' }); + const blocks = []; + for (let i = 0; i < 5; i++) + for (let j = 0; j < 5; j++) { + const e = (i - 2) * 220; + const n = (j - 2) * 220; + blocks.push({ + id: `b${i}${j}`, + geometry: { + kind: 'polygon', + rings: [ + [ + offset(e - 80, n - 80), + offset(e + 80, n - 80), + offset(e + 80, n + 80), + offset(e - 80, n + 80), + ], + ], + }, + fill: [0.55, 0.6, 0.7, 1], + extrudeHeight: 40 + ((i * 7 + j * 13) % 9) * 25, + }); + } + shapes.set(blocks); + const roads = r.draw.lines({ id: 'ref:city:roads' }); + const road = (id, a, b) => ({ + id, + positions: [ctx.toEcef(a), ctx.toEcef(b)], + widthPx: 3, + mode: '3d', + style: { kind: 'solid', color: '#ffd60a' }, + }); + roads.set([ + ...[-330, -110, 110, 330].map((n, k) => + road(`ew${k}`, offset(-600, n, 2), offset(600, n, 2)), + ), + ...[-330, -110, 110, 330].map((e, k) => + road(`ns${k}`, offset(e, -600, 2), offset(e, 600, 2)), + ), + ]); + const vehicles = r.draw.icons({ id: 'ref:city:vehicles' }); + vehicles.set( + Array.from({ length: 12 }, (_, k) => ({ + id: `v${k}`, + pos: ctx.toEcef(offset(-500 + k * 90, -110, 5)), + image: ICONS.arrow, + size: [20, 20], + color: '#ff3b30', + rotationMode: 'course', + course: 90, + })), + ); + const sensors = r.draw.points({ id: 'ref:city:sensors' }); + sensors.set( + Array.from({ length: 10 }, (_, k) => ({ + id: `s${k}`, + pos: ctx.toEcef(offset(110, -550 + k * 110, 5)), + pixelSize: 8, + color: '#34c759', + outlineColor: '#000000', + outlineWidth: 1, + })), + ); + return disposeAll(shapes, roads, vehicles, sensors); + }, + views: [ + { name: 'orbit', view: over(15000000) }, + { name: 'city', view: over(1800, -0.6, 0.4, offset(-1200, -2600)) }, + { name: 'street', view: over(60, -0.08, 0, offset(-150, -700)) }, + ], + }, +]); + +/** + * The calibration path: the city scene seen from orbit down to the street, + * the poses a quality calibration (and the performance budget) samples. + */ +export const CALIBRATION_PATH = Object.freeze({ + scene: 'city', + poses: Object.freeze([ + over(15000000), + over(1500000), + over(60000, -1.2), + over(6000, -0.8, 0.3, offset(-800, -3000)), + over(1200, -0.5, 0.5, offset(-600, -1200)), + over(250, -0.25, 0.2, offset(-250, -800)), + over(60, -0.08, 0, offset(-150, -700)), + ]), +}); + +/** Whether a renderer and context can draw a scene; the reason when not. */ +export function sceneSupport(scene, renderer, ctx = {}) { + if (scene.capability && !renderer.capabilities.has(scene.capability)) + return `no ${scene.capability}`; + for (const need of scene.needs ?? []) + if (!ctx.assets?.[need]) return `no ${need} asset`; + return null; +} diff --git a/src/render/conformance/scenes.test.mjs b/src/render/conformance/scenes.test.mjs new file mode 100644 index 000000000..b3d301e88 --- /dev/null +++ b/src/render/conformance/scenes.test.mjs @@ -0,0 +1,229 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PORTS } from '../contract/index.js'; +import { createStubRenderer } from '../stub/index.js'; +import { toEcef } from '../stub/math.js'; +import { CALIBRATION_PATH, REFERENCE_SCENES, sceneSupport } from './scenes.js'; +import { compareImages } from './visual.js'; + +/** + * The stub behind proxies that throw on any member the contract does not + * list, so a scene that reaches past the contract fails here. + */ +const MEMBERS = { ...PORTS, handle: { dispose: null, update: null } }; + +function contractOnly(renderer) { + const guard = (target, port, prefix = '') => + new Proxy(target, { + get(obj, key, receiver) { + if (typeof key === 'symbol' || key === 'then') + return Reflect.get(obj, key, receiver); + const member = prefix + key; + if (!(member in MEMBERS[port])) + throw new Error(`${port}.${member} is not in the contract`); + const value = Reflect.get(obj, key, receiver); + if (port === 'draw' && typeof value === 'function') + return (...args) => + guard( + value.apply(obj, args), + key === 'field' || key === 'celestial' ? 'handle' : 'drawSet', + ); + const nested = Object.keys(MEMBERS[port]).some((m) => + m.startsWith(`${member}.`), + ); + if (value && typeof value === 'object' && nested) + return guard(value, port, `${member}.`); + return typeof value === 'function' ? value.bind(obj) : value; + }, + }); + const wrapped = {}; + for (const port of Object.keys(PORTS.renderer)) { + const value = renderer[port]; + wrapped[port] = + MEMBERS[port] && typeof value === 'object' ? guard(value, port) : value; + } + return wrapped; +} + +const ctx = { toEcef, assets: { model: '/models/c172.glb' } }; + +test('every reference scene builds and disposes on the stub through the contract alone', async () => { + const ids = new Set(); + for (const scene of REFERENCE_SCENES) { + assert.ok(!ids.has(scene.id), `${scene.id} is unique`); + ids.add(scene.id); + const stub = createStubRenderer(); + // Models report ready once their asset loads; the stub has none to load. + if (scene.capability === 'models') continue; + assert.equal(sceneSupport(scene, stub, ctx), null, scene.id); + const renderer = contractOnly(stub); + const dispose = await scene.build(renderer, ctx); + assert.ok( + stub.testing.sets().length > 0 || scene.family === 'fields', + `${scene.id} drew something`, + ); + for (const view of scene.views) renderer.camera.setView(view.view); + dispose(); + assert.deepEqual( + stub.testing.sets(), + [], + `${scene.id} left nothing behind`, + ); + assert.ok(scene.views.length > 0, `${scene.id} has views`); + } + for (const family of [ + 'icons', + 'points', + 'lines', + 'shapes', + 'models', + 'quads', + 'meshes', + 'fields', + 'agents', + 'city', + ]) + assert.ok(ids.has(family), `a ${family} scene`); +}); + +test('a scene a renderer cannot draw says why', () => { + const stub = createStubRenderer({ capabilities: [] }); + const models = REFERENCE_SCENES.find((s) => s.id === 'models'); + assert.equal(sceneSupport(models, stub, ctx), 'no models'); + assert.equal( + sceneSupport(models, createStubRenderer(), {}), + 'no model asset', + ); +}); + +test('the calibration path runs from orbit to street over the city scene', () => { + assert.ok(REFERENCE_SCENES.some((s) => s.id === CALIBRATION_PATH.scene)); + const heights = CALIBRATION_PATH.poses.map((p) => p.destination.height); + assert.deepEqual( + heights, + [...heights].sort((a, b) => b - a), + ); + assert.ok(heights[0] >= 1e7 && heights.at(-1) <= 100); +}); + +const image = (w, h, fill) => { + const data = new Uint8ClampedArray(w * h * 4); + for (let p = 0; p < data.length; p += 4) data.set(fill(p / 4), p); + return { width: w, height: h, data }; +}; + +test('the image comparison passes rounding and fails a moved item', () => { + const base = image(100, 100, (i) => + i % 100 > 40 && i % 100 < 60 && i < 5000 + ? [255, 0, 0, 255] + : [20, 30, 40, 255], + ); + const rounded = image(100, 100, (i) => + [...base.data.slice(i * 4, i * 4 + 3)] + .map((c) => Math.min(255, c + 2)) + .concat(255), + ); + assert.equal(compareImages(rounded, base).match, true); + const moved = image(100, 100, (i) => + i % 100 > 10 && i % 100 < 30 && i < 5000 + ? [255, 0, 0, 255] + : [20, 30, 40, 255], + ); + const result = compareImages(moved, base); + assert.equal(result.match, false); + assert.ok(result.diffFraction > 0.1); + assert.ok(result.solidPixels > 100); + assert.equal( + compareImages( + image(10, 10, () => [0, 0, 0, 255]), + base, + ).match, + false, + ); +}); + +test('the image comparison drops one-pixel fringes and catches a small item', () => { + const blank = image(100, 100, () => [20, 30, 40, 255]); + // Antialiasing: a one-pixel ring and a one-pixel line differ, nothing solid. + const fringe = image(100, 100, (i) => { + const x = i % 100; + const y = (i - x) / 100; + const ring = Math.abs(Math.hypot(x - 50, y - 50) - 20) < 0.5; + return ring || x === 80 ? [200, 200, 200, 255] : [20, 30, 40, 255]; + }); + const fringed = compareImages(fringe, blank); + assert.ok(fringed.diffPixels > 100, 'many pixels differ'); + assert.equal(fringed.solidPixels, 0); + assert.equal(fringed.match, true); + // The smallest reference item: a 4 px point. + const point = image(100, 100, (i) => { + const x = i % 100; + const y = (i - x) / 100; + return Math.hypot(x - 30.5, y - 30.5) <= 2.2 + ? [255, 0, 0, 255] + : [20, 30, 40, 255]; + }); + const dropped = compareImages(blank, point); + assert.ok(dropped.solidPixels > 2, `solid ${dropped.solidPixels}`); + assert.equal(dropped.match, false); +}); + +test('the image comparison catches a dropped thin line and a one-pixel shift', () => { + const at = (fill) => + image(200, 200, (i) => { + const x = i % 200; + return fill(x, (i - x) / 200) ? [255, 255, 255, 255] : [20, 30, 40, 255]; + }); + const blank = at(() => false); + // These 160 px lines, 2 and 3 px wide, cover 320 and 480 px: over the + // 250 px limit. A thin change covering less passes (see visual.js). + for (const width of [2, 3]) { + const line = at( + (x, y) => x >= 20 && x < 180 && y >= 100 && y < 100 + width, + ); + assert.equal( + compareImages(blank, line).match, + false, + `a dropped ${width} px line`, + ); + } + const box = (dx, dy) => + at((x, y) => x >= 40 + dx && x < 160 + dx && y >= 50 + dy && y < 150 + dy); + // A 120×100 box moved leaves strips on the sides it moves across: caught + // when they cover more than 250 px. Moved 1 px along one axis, it covers 200 + // and passes; these cover more. + for (const [dx, dy] of [ + [1, 1], + [2, 0], + [2, 2], + ]) + assert.equal( + compareImages(box(dx, dy), box(0, 0)).match, + false, + `a box moved by (${dx}, ${dy})`, + ); +}); + +test('the image comparison takes pixel data that does not start on a word', () => { + const base = image(4, 4, () => [20, 30, 40, 255]); + const bytes = new Uint8ClampedArray(base.data.length + 1); + bytes.set(base.data, 1); + const sliced = { width: 4, height: 4, data: bytes.subarray(1) }; + assert.equal(compareImages(sliced, base).match, true); +}); + +test('a thin change under 250 px passes, as documented', () => { + const at = (fill) => + image(200, 200, (i) => { + const x = i % 200; + return fill(x, (i - x) / 200) ? [255, 255, 255, 255] : [20, 30, 40, 255]; + }); + const line = at((x, y) => x >= 20 && x < 100 && y >= 100 && y < 102); + const result = compareImages( + at(() => false), + line, + ); + assert.equal(result.solidPixels, 0); + assert.equal(result.mismatchedPixels, 160); + assert.equal(result.match, true); +}); diff --git a/src/render/conformance/visual.js b/src/render/conformance/visual.js new file mode 100644 index 000000000..8c9556d4c --- /dev/null +++ b/src/render/conformance/visual.js @@ -0,0 +1,227 @@ +/** + * @module render/conformance/visual + * @description The visual checks: draw a reference scene from one of its + * views, capture it, and compare the capture with the reference image under + * a perceptual tolerance. + * + * Pixels are compared as pixelmatch compares them (its algorithm, ported; + * pixelmatch is ISC-licensed, copyright Mapbox): by YIQ difference, which + * weighs brightness over hue as the eye does, and excusing a differing pixel + * that is antialiasing in either image, one on an edge between two flat + * colours. A dropped or moved item differs inside its body and along hard + * edges, which are not excused. A differing pixel whose four neighbours + * differ too is solid: antialiasing differs along one-pixel fringes and has + * none. Two images match when at most `maxSolidPixels` pixels are solid and + * at most `maxMismatchedPixels` are unexcused. + * + * The two measures cover each other. Measured on the default renderer at + * 640×360, against references drawn with 4× MSAA: + * + * | Change | Solid | Unexcused | + * | ------------------------------ | ----: | --------: | + * | Drawn with 1×, 2× or 8× MSAA | 0 | ≤ 192 | + * | The smallest point (4 px) gone | 7 | 8 | + * | An arrow line reversed | 24 | 88 | + * | An arrow icon turned | 64 | 91 | + * | A 2 px line gone | ≥ 267 | ≥ 536 | + * | The whole view moved 1 px | ≥ 36 | ≥ 480 | + * | An icon gone | ~ 300 | ~ 300 | + * + * The limit: a solid pixel needs a change at least 3 px thick, so a change + * that is nowhere that thick (a thin line gone, an item moved a pixel or two) + * passes when it covers at most 250 px: a missing 2 px line up to 125 px + * long, say, or a 20 px box moved 1 or 2 px. A blob up to about 3×4 px is + * under both limits too (2 solid pixels). Larger thin changes, and any + * change whose 3 px core is more than two pixels, are caught; the smallest + * item in the scenes, the 4 px point, leaves 7. + */ + +/** Tolerances the default renderer meets against its own references with any antialiasing. */ +export const VISUAL_TOLERANCE = Object.freeze({ + threshold: 0.1, + maxSolidPixels: 2, + maxMismatchedPixels: 250, +}); + +/** The largest YIQ difference two RGB colours can have. */ +const MAX_YIQ_DELTA = 35215; + +/** Composites a channel over black, so transparent and black read the same. */ +const over = (c, a) => (c * a) / 255; + +const rgb = (d, p) => [ + over(d[p], d[p + 3]), + over(d[p + 1], d[p + 3]), + over(d[p + 2], d[p + 3]), +]; +const brightness = ([r, g, b]) => + r * 0.29889531 + g * 0.58662247 + b * 0.11448223; + +function yiqDelta([r1, g1, b1], [r2, g2, b2]) { + const i = (r, g, b) => r * 0.59597799 - g * 0.2741761 - b * 0.32180189; + const q = (r, g, b) => r * 0.21147017 - g * 0.52261711 + b * 0.31114694; + const dy = brightness([r1, g1, b1]) - brightness([r2, g2, b2]); + const di = i(r1, g1, b1) - i(r2, g2, b2); + const dq = q(r1, g1, b1) - q(r2, g2, b2); + return 0.5053 * dy * dy + 0.299 * di * di + 0.1957 * dq * dq; +} + +/** Whether (x, y) has more than two neighbours of exactly its own colour. */ +function hasManySiblings(px, x1, y1, w, h) { + const val = px[y1 * w + x1]; + let zeroes = x1 === 0 || y1 === 0 || x1 === w - 1 || y1 === h - 1 ? 1 : 0; + for (let x = Math.max(x1 - 1, 0); x <= Math.min(x1 + 1, w - 1); x++) + for (let y = Math.max(y1 - 1, 0); y <= Math.min(y1 + 1, h - 1); y++) { + if (x === x1 && y === y1) continue; + if (px[y * w + x] === val && ++zeroes > 2) return true; + } + return false; +} + +/** + * Whether (x1, y1) of `img` is antialiasing: a pixel between its darkest and + * brightest neighbours, one of which is flat (has siblings) in both images. + */ +function antialiased(img, px, otherPx, x1, y1, w, h) { + const here = brightness(rgb(img, (y1 * w + x1) * 4)); + let zeroes = x1 === 0 || y1 === 0 || x1 === w - 1 || y1 === h - 1 ? 1 : 0; + let min = 0; + let max = 0; + let minX; + let minY; + let maxX; + let maxY; + for (let x = Math.max(x1 - 1, 0); x <= Math.min(x1 + 1, w - 1); x++) + for (let y = Math.max(y1 - 1, 0); y <= Math.min(y1 + 1, h - 1); y++) { + if (x === x1 && y === y1) continue; + const delta = here - brightness(rgb(img, (y * w + x) * 4)); + if (delta === 0) { + if (++zeroes > 2) return false; + } else if (delta < min) { + min = delta; + minX = x; + minY = y; + } else if (delta > max) { + max = delta; + maxX = x; + maxY = y; + } + } + if (min === 0 || max === 0) return false; + return ( + (hasManySiblings(px, minX, minY, w, h) && + hasManySiblings(otherPx, minX, minY, w, h)) || + (hasManySiblings(px, maxX, maxY, w, h) && + hasManySiblings(otherPx, maxX, maxY, w, h)) + ); +} + +/** + * @param {{width: number, height: number, data: Uint8ClampedArray}} actual + * @param {{width: number, height: number, data: Uint8ClampedArray}} expected + * @param {{threshold?: number, maxSolidPixels?: number, maxMismatchedPixels?: number, diff?: Uint8ClampedArray}} [options] + * `diff`, when given, is filled with an RGBA image of the differences: + * mismatches red, excused antialiasing yellow. + * @returns {{match: boolean, diffPixels: number, diffFraction: number, solidPixels: number, mismatchedPixels: number, reason?: string}} + */ +export function compareImages(actual, expected, options = {}) { + const { threshold, maxSolidPixels, maxMismatchedPixels } = { + ...VISUAL_TOLERANCE, + ...options, + }; + if (actual.width !== expected.width || actual.height !== expected.height) + return { + match: false, + diffPixels: NaN, + diffFraction: 1, + solidPixels: NaN, + mismatchedPixels: NaN, + reason: `size ${actual.width}×${actual.height}, reference ${expected.width}×${expected.height}`, + }; + const { width: w, height: h } = actual; + // Whole pixels compare as 32-bit words; data that does not start on a + // word (a sliced Node buffer) is copied first. + const aligned = (d) => + d.byteOffset % 4 === 0 ? d : new Uint8ClampedArray(d); + const a = aligned(actual.data); + const b = aligned(expected.data); + const aPx = new Uint32Array(a.buffer, a.byteOffset, w * h); + const bPx = new Uint32Array(b.buffer, b.byteOffset, w * h); + const limit = MAX_YIQ_DELTA * threshold * threshold; + const diff = options.diff; + const differing = new Uint8Array(w * h); + let diffPixels = 0; + let mismatchedPixels = 0; + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const k = y * w + x; + const p = k * 4; + const differs = + aPx[k] !== bPx[k] && yiqDelta(rgb(a, p), rgb(b, p)) > limit; + let mismatch = false; + if (differs) { + differing[k] = 1; + diffPixels += 1; + mismatch = + !antialiased(a, aPx, bPx, x, y, w, h) && + !antialiased(b, bPx, aPx, x, y, w, h); + if (mismatch) mismatchedPixels += 1; + } + if (diff) { + // Differences over a faded copy of the reference. + const faded = 40 + over(b[p + 1], b[p + 3]) * 0.25; + diff[p] = differs ? 255 : faded; + diff[p + 1] = differs ? (mismatch ? 0 : 220) : faded; + diff[p + 2] = differs ? 0 : faded; + diff[p + 3] = 255; + } + } + // Solid: a differing pixel whose four neighbours differ too. + let solidPixels = 0; + for (let y = 1; y < h - 1; y++) + for (let x = 1; x < w - 1; x++) { + const k = y * w + x; + if ( + differing[k] && + differing[k - 1] && + differing[k + 1] && + differing[k - w] && + differing[k + w] + ) + solidPixels += 1; + } + return { + match: + solidPixels <= maxSolidPixels && mismatchedPixels <= maxMismatchedPixels, + diffPixels, + diffFraction: diffPixels / (w * h), + solidPixels, + mismatchedPixels, + }; +} + +/** The file a view's reference image is stored under. */ +export const referenceName = (sceneId, viewName) => + `${sceneId}-${viewName}.png`; + +/** + * Draws a scene, then each of its views once the renderer has settled. + * @param {object} renderer + * @param {object} scene One of `REFERENCE_SCENES`. + * @param {{toEcef: Function, assets?: object}} ctx + * @param {(view: {name: string}, capture: Blob, settle: {state: string, blockers: string[]}) => void|Promise} onView + * `settle` is what `whenSettled` answered; a capture that did not settle + * is no reference and no pass. + */ +export async function drawScene(renderer, scene, ctx, onView) { + const dispose = await scene.build(renderer, ctx); + try { + for (const view of scene.views) { + renderer.camera.setView(view.view); + const settle = await renderer.whenSettled({ timeoutMs: 20000 }); + await onView(view, await renderer.capture({ type: 'png' }), settle); + } + } finally { + dispose(); + } +} diff --git a/src/render/contract/camera.d.ts b/src/render/contract/camera.d.ts new file mode 100644 index 000000000..9656320f1 --- /dev/null +++ b/src/render/contract/camera.d.ts @@ -0,0 +1,247 @@ +/** + * The camera. It belongs to the renderer: Core asks for poses, flights and + * follows, and every renderer reproduces the behaviour below exactly. + * A renderer whose engine has no such camera of its own builds this port + * on the shared GEV camera controller (the stub's camera is its seed), so + * flights, `lookAt` and follows are written once, not per engine. + * + * Behaviour every renderer keeps (Core depends on each one): + * - Camera input is suspended during a flight, by a hold: a flight ending + * gives back only its own hold, never another owner's. + * - `lookAt` orbits the target (the user can still rotate and zoom about it); + * `release` returns to the world frame. + * - `changed` fires when the view has moved by more than the listener's own + * `threshold` (default 0.5) since it last fired, measured per listener: + * heading and roll each by their change over π since they last fired; the + * view by the larger of the direction's turn over half the vertical field + * of view and the eye's travel over its height, since the view last + * fired. A frame can fire it twice, once for each measure. + * - `moveEnd` fires 500 ms after the last change, and never while following. + * - A flight's `onCancel` and `onComplete` run synchronously, inside the call + * or frame that ends it. + * - Axes follow the usual convention: heading clockwise from north, pitch + * negative looking down, roll about the view direction; local frames are + * east-north-up. + */ +import type { + Ecef, + GeoRect, + HeadingPitchRange, + HeadingPitchRoll, + Off, + Position, + Release, + Sphere, + Vec3, +} from './common'; + +export interface CameraPose { + lon: number; + lat: number; + /** Metres above the ellipsoid. */ + height: number; + heading: number; + pitch: number; + roll: number; + position: Ecef; + direction: Vec3; + up: Vec3; + right: Vec3; + /** Vertical field of view, radians. */ + fovy: number; + aspectRatio: number; + near: number; + far: number; + /** False while `lookAt` has the camera in a target's frame. */ + frameIsIdentity: boolean; +} + +export type Orientation = + Partial | { direction: Vec3; up: Vec3 }; + +/** + * A position, or a rectangle. For a rectangle the eye stands on the surface + * normal at the geodesic midpoint of its central meridian (latitude 0 when it + * spans nearly pole to pole), at the least distance that keeps on screen its + * corners, its edge centres and, when it crosses the equator, its equator + * points. The orientation is then resolved as for any position. + */ +export type Destination = Position | { rect: GeoRect }; + +/** + * Omitted orientation angles are heading 0, pitch −π/2 (straight down) and + * roll 0, not the current ones; an omitted destination keeps the position. + */ +export interface ViewSpec { + destination?: Destination; + orientation?: Orientation; +} + +export type Easing = + 'linear' | 'quadraticInOut' | 'cubicInOut' | 'cubicOut' | 'quinticInOut'; + +/** + * A flight's omitted orientation is the same as `setView`'s. Its omitted + * duration is `min(3, ceil(distance / 1000 km) + 2)` seconds, the distance + * measured from the eye's position in its current frame (the `lookAt` frame + * when one is active). A flight with a duration of 0 or less, or one whose + * eye (its position in its current frame, as above) already stands at the + * destination with the destination's angles (to 1e-10, relative), + * completes inside the call: it places the eye, runs `onComplete`, raises + * `flightEnd` and resolves `done`, and no frame is needed. Starting a + * flight cancels the one in progress. + * + * The path, at eased time `s` (0..1): longitude and latitude linear in `s` + * the short way round; heading, pitch and roll linear in `s` the short way + * round; height on an arc to `maxHeight` when both ends are below it, + * otherwise linear. The reference traces (`conformance/goldens/camera.json`) + * pin the arc and the omitted `maxHeight`. + */ +export interface FlightOptions { + orientation?: Orientation; + durationS?: number; + /** + * Omitted: `cubicOut` when descending from above 11.5 km, otherwise + * `quinticInOut`. + */ + easing?: Easing; + /** Top of the height arc, metres. Omitted: a fifth of what fits both ends on screen. */ + maxHeight?: number; + /** When the arc tops this height, pitch turns straight down mid-flight. */ + pitchAdjustHeight?: number; + /** End in the world frame even when a target frame is active. */ + endFrame?: 'world'; + onComplete?: () => void; + onCancel?: () => void; +} + +export interface Flight { + done: Promise<'complete' | 'cancelled'>; + cancel(): void; +} + +/** Something the camera follows; Core owns it and the renderer reads it each frame. */ +export interface FollowTarget { + /** Stable key, e.g. `'flights:a1b2c3'`. */ + key: string; + /** Where the target is at scene time `t` (epoch ms), or null when unknown. */ + position(t: number): Ecef | null; + /** Where it is drawn, when that differs from `position` (a lifted icon). */ + visualPosition?(t: number): Ecef | null; + /** + * The drawn item this target is, when it is one. A renderer that moves + * items itself (`motionSegments`) follows where it draws the item, so the + * eye and the icon never part; `position` stays the answer elsewhere. + */ + item?: { set: string; id: string }; + /** + * `enu`: offset in the target's east-north-up frame; + * `autodetect`: an orbit frame from its motion, north-up when still + * (`followAutodetect`). + */ + frame: 'enu' | 'autodetect'; + /** Offset of the eye from the target, metres in `frame`. */ + viewFrom: Vec3 | ((t: number) => Vec3); + minRange?: number; + boundingRadius?: number; +} + +/** + * A camera command, as an ownership hook sees it before it runs. An owner + * that issues commands itself (a ground guard) recognises its own by + * issuing them inside its own call; any other command is someone else's. + */ +export interface CameraCommand { + kind: + | 'setView' + | 'flyTo' + | 'flyToBounds' + | 'lookAt' + | 'release' + | 'follow' + | 'cancelFlight'; +} + +export interface CameraControls { + /** False while any hold is out. */ + readonly enabled: boolean; + /** Suspend user camera input until released. Holds are counted. */ + suspend(reason: string): Release; + enableTilt: boolean; + enableRotate: boolean; + enableZoom: boolean; + minDistance: number; + maxDistance: number; + inertiaSpin: number; + inertiaTranslate: number; + inertiaZoom: number; + /** What the eye cannot pass below. */ + collision: 'none' | 'terrain' | 'tiles' | 'both'; + zoomFactor: number; + /** Pitch the user may tilt between, radians (−π/2 straight down). */ + pitchRange: [min: number, max: number]; + /** Input bindings that zoom. */ + zoomInputs: Array<'wheel' | 'pinch' | 'rightDrag' | 'ctrlWheel'>; +} + +export type CameraEvent = + 'changed' | 'moveStart' | 'moveEnd' | 'followChanged' | 'flightEnd'; + +export interface CameraPort { + pose(): CameraPose; + setView(view: ViewSpec): void; + setFov(fov: { vertical: number }): void; + /** + * The geodetic rectangle in view: the bounds of the canvas corners on the + * ellipsoid, a corner that misses it taken where the horizon is, and the + * full longitude span to the pole when the view goes over one. The whole + * globe when fewer than two corners land; null only when the globe is + * wholly outside the view. + */ + viewRectangle(): GeoRect | null; + flyTo(destination: Destination, options?: FlightOptions): Flight; + /** + * Fly to look at a sphere from `offset` in its east-north-up frame + * (default heading 0, pitch −π/4; a range of 0 fits the sphere in view). + */ + flyToBounds( + sphere: Sphere, + options?: { offset?: HeadingPitchRange; durationS?: number } & Pick< + FlightOptions, + 'onComplete' | 'onCancel' + >, + ): Flight; + cancelFlight(): void; + readonly flying: boolean; + /** Orbit `target` at `offset`; user input orbits it too. */ + lookAt(target: Ecef, offset: HeadingPitchRange): void; + /** Leave `lookAt` for the world frame. */ + release(): void; + move( + direction: 'forward' | 'back' | 'left' | 'right' | 'up' | 'down', + metres: number, + ): void; + look(direction: 'left' | 'right' | 'up' | 'down', radians: number): void; + rotateAround(axis: Vec3, radians: number): void; + zoom(metres: number): void; + follow(target: FollowTarget | null): void; + readonly followed: FollowTarget | null; + /** Whether the follow has placed its first frame. */ + readonly followFramed: boolean; + /** The eye's offset from the followed target, in its frame. */ + readonly followOffset: { + get(): Vec3 | null; + set(offset: Vec3): void; + }; + readonly controls: CameraControls; + on(event: CameraEvent, cb: () => void, options?: { threshold?: number }): Off; + /** Runs before each camera command, so an owner can yield to another. */ + onCommand(cb: (command: CameraCommand) => void): Off; + /** + * Column-major, right-handed: `view` takes ECEF to eye space (absolute, + * not eye-relative); `projection` takes eye space to clip space with depth + * in [-1, 1]. A renderer with an infinite far plane returns the + * infinite-far form, and `pose().far` is `Infinity`. + */ + matrices(): { view: Float64Array; projection: Float64Array }; +} diff --git a/src/render/contract/common.d.ts b/src/render/contract/common.d.ts new file mode 100644 index 000000000..93bf8ed34 --- /dev/null +++ b/src/render/contract/common.d.ts @@ -0,0 +1,200 @@ +/** + * Shared value types of the renderer contract. + * + * Only plain numbers, strings and typed arrays cross the contract: no engine + * object ever does. Positions are f64 Earth-centred, Earth-fixed metres + * (`Ecef`) or geodetic degrees and metres (`LonLatH`). Angles are radians + * unless a name says degrees. Times are epoch milliseconds. Screen positions + * are CSS pixels from the top left of the renderer's canvas. + */ + +/** Earth-centred, Earth-fixed position in metres (WGS84). */ +export interface Ecef { + x: number; + y: number; + z: number; +} + +/** A direction or offset in metres; the frame is stated where it is used. */ +export interface Vec3 { + x: number; + y: number; + z: number; +} + +/** Geodetic position: degrees, and metres above the WGS84 ellipsoid. */ +export interface LonLatH { + lon: number; + lat: number; + height?: number; +} + +/** Geodetic position without a height. */ +export interface LonLat { + lon: number; + lat: number; +} + +/** Either position form. A value with `lon` and `lat` is geodetic. */ +export type Position = Ecef | LonLatH; + +/** CSS pixels from the canvas's top left. */ +export interface ScreenXY { + x: number; + y: number; +} + +/** A screen rectangle in CSS pixels. */ +export interface ScreenRect { + x: number; + y: number; + width: number; + height: number; +} + +/** A geodetic rectangle in degrees. */ +export interface GeoRect { + west: number; + south: number; + east: number; + north: number; +} + +/** A ray in ECEF metres; `direction` is a unit vector. */ +export interface Ray { + origin: Ecef; + direction: Vec3; +} + +/** A bounding sphere. */ +export interface Sphere { + center: Ecef; + radius: number; +} + +export interface HeadingPitchRoll { + heading: number; + pitch: number; + roll: number; +} + +export interface HeadingPitchRange { + heading: number; + pitch: number; + range: number; +} + +/** A quaternion `(x, y, z, w)`, unit length. */ +export interface Quaternion { + x: number; + y: number; + z: number; + w: number; +} + +/** A CSS colour string, or linear `[r, g, b, a]` in 0..1 (`a` defaults to 1). */ +export type Color = string | readonly [number, number, number, number?]; + +/** + * A distance curve: `nearValue` at `near` metres from the eye, `farValue` + * at `far`, linear in between and clamped outside. + */ +export interface NearFar { + near: number; + nearValue: number; + far: number; + farValue: number; +} + +/** + * Unsubscribes. Calling it twice is harmless. + * + * Every `on(...)` in the contract keeps these rules: listeners run in the + * order they subscribed; one removed while an event is being delivered does + * not run for it if it has not yet; one added during delivery first runs for + * the next event; a listener that throws is reported (`Renderer` `error`, + * or the platform's `reportError` when nothing listens for errors) and the + * others, and the frame, still run. + */ +export type Off = () => void; + +/** Gives back a hold. Calling it twice is harmless. */ +export type Release = () => void; + +/** A handle on something the renderer draws. */ +export interface Handle { + show: boolean; + dispose(): void; +} + +/** Opaque data Core attaches to a drawn item and gets back on a pick. */ +export type Meta = unknown; + +/** + * Optional features. A renderer lists the ones it has in + * `Renderer.capabilities`. A member's documentation names the capability + * it depends on, in backticks, first: + * - on an optional member (`?`), the member exists only with the capability; + * - on a required member, the member always exists, and without the + * capability it answers null, an empty list or a status saying so (a + * query asked for depth on a renderer without `pickPosition` returns null). + * Options guarded by a capability are ignored without it. Quality settings + * are not capabilities: `QualityPort.knobs()` lists the ones a renderer has. + */ +export type Capability = + /** Google photorealistic 3D tiles as the base. */ + | 'photoreal' + /** A vector basemap (`BaseSpec.vector`), with its own labels. */ + | 'vectorBase' + /** Heights from the finest data available, not only what is drawn. */ + | 'finestHeights' + /** Height terrain under the globe. */ + | 'terrain' + /** Atmosphere scattering applied to drawn models. */ + | 'modelAtmosphere' + /** Imagery draped onto 3D tiles as well as the globe. */ + | 'imageryOnTiles' + /** A screen-split comparison between imagery layers. */ + | 'splitSwipe' + /** Timed imagery frames with hidden prefetch. */ + | 'imageryFrames' + /** Wind-relief and similar shading of the globe surface. */ + | 'surfaceShading' + /** A screen-space sector modulation pass (a sweeping sonar band). */ + | 'screenModulation' + /** GPU frame timing through timer queries. */ + | 'gpuTiming' + /** Depth picks against tiles and terrain. */ + | 'pickPosition' + /** Height sampling against tiles and terrain. */ + | 'sampleHeight' + /** Ray casts against tiles and terrain. */ + | 'rayCast' + /** Columnar draw-set updates with dirty ranges. */ + | 'columns' + /** Icon motion segments evaluated on the renderer's clock. */ + | 'motionSegments' + /** glTF models. */ + | 'models' + /** Image, video and canvas planes. */ + | 'quads' + /** Custom meshes in a local east-north-up frame. */ + | 'meshes' + /** Wind streamlines, scalar rasters and raised raster shells. */ + | 'fields' + /** Stars and orbit rings in an inertial frame. */ + | 'celestial' + /** Agents moving along polylines (traffic flow). */ + | 'agents' + /** Screen-space clustering of icons. */ + | 'cluster' + /** Post-processing styles. */ + | 'styles' + /** A bloom pass. */ + | 'bloom' + /** A sharpen pass. */ + | 'sharpen' + /** Following with an automatically chosen frame (orbit or north-up). */ + | 'followAutodetect' + /** A built-in quality benchmark. */ + | 'benchmark'; diff --git a/src/render/contract/contract.test.mjs b/src/render/contract/contract.test.mjs new file mode 100644 index 000000000..167646543 --- /dev/null +++ b/src/render/contract/contract.test.mjs @@ -0,0 +1,168 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { + CAPABILITIES, + CONTRACT_VERSION, + FRAME_STEPS, + PORTS, + QUALITY_KEYS, + checkPortShape, + checkRendererShape, +} from './index.js'; + +const DIR = path.dirname(fileURLToPath(import.meta.url)); +const INDEX = path.join(DIR, 'index.d.ts'); + +const program = ts.createProgram([INDEX], { + strict: true, + noEmit: true, + target: ts.ScriptTarget.ES2022, + lib: ['lib.es2022.d.ts', 'lib.dom.d.ts'], + skipLibCheck: false, + types: [], +}); +const checker = program.getTypeChecker(); +const contractFiles = program + .getSourceFiles() + .filter((file) => path.dirname(file.fileName) === DIR); + +/** The named interface or type alias declared in the contract files. */ +function declared(name) { + for (const file of contractFiles) + for (const statement of file.statements) + if ( + (ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement)) && + statement.name.text === name + ) + return statement; + throw new Error(`${name} is not declared in the contract`); +} + +const PORT_INTERFACES = { + renderer: 'Renderer', + frame: 'FramePort', + globe: 'GlobePort', + draw: 'DrawPort', + camera: 'CameraPort', + query: 'QueryPort', + input: 'InputPort', + effects: 'EffectsPort', + credits: 'CreditsPort', + quality: 'QualityPort', + drawSet: 'DrawSet', + imageryLayer: 'ImageryLayer', + flight: 'Flight', + lease: 'Lease', +}; +const PORT_NAMES = new Set(Object.values(PORT_INTERFACES)); + +/** Whether a property's type is one of the contract's own object shapes to list member by member. */ +function nestedShape(type) { + if (type.isUnion() || type.getCallSignatures().length) return null; + const symbol = type.aliasSymbol ?? type.getSymbol(); + const declaration = symbol?.declarations?.[0]; + if (!declaration) return null; + const inContract = path.dirname(declaration.getSourceFile().fileName) === DIR; + if (!inContract) return null; + if (ts.isTypeLiteralNode(declaration)) return type; + if (ts.isInterfaceDeclaration(declaration)) + return PORT_NAMES.has(declaration.name.text) ? null : type; + return null; +} + +/** `[member, optional]` for every member, nested shapes dotted. */ +function members(type, prefix = '') { + const out = []; + for (const property of checker.getPropertiesOfType(type)) { + const name = `${prefix}${property.name}`; + const optional = Boolean(property.flags & ts.SymbolFlags.Optional); + out.push([name, optional]); + const declaration = property.valueDeclaration ?? property.declarations?.[0]; + const propertyType = checker.getTypeOfSymbolAtLocation( + property, + declaration, + ); + const nested = nestedShape(propertyType); + if (nested) out.push(...members(nested, `${name}.`)); + } + return out; +} + +function unionLiterals(name) { + const type = checker.getTypeAtLocation(declared(name).name); + return (type.isUnion() ? type.types : [type]).map((t) => t.value).sort(); +} + +test('the contract type-checks under strict TypeScript', () => { + const diagnostics = ts.getPreEmitDiagnostics(program).map((d) => { + const where = d.file + ? `${path.relative(DIR, d.file.fileName)}:${d.file.getLineAndCharacterOfPosition(d.start).line + 1}` + : ''; + return `${where} ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`; + }); + assert.deepEqual(diagnostics, []); +}); + +test('PORTS lists every member of every port, required exactly when the type requires it', () => { + for (const [port, interfaceName] of Object.entries(PORT_INTERFACES)) { + const type = checker.getTypeAtLocation(declared(interfaceName).name); + const typed = Object.fromEntries(members(type)); + const described = PORTS[port]; + assert.deepEqual( + Object.keys(described).sort(), + Object.keys(typed).sort(), + `${port} members`, + ); + for (const [member, capability] of Object.entries(described)) + assert.equal( + capability !== null, + typed[member], + `${port}.${member}: optional in the type exactly when a capability adds it`, + ); + } +}); + +test('the capability and frame-step lists are the types’ own', () => { + assert.deepEqual([...CAPABILITIES].sort(), unionLiterals('Capability')); + assert.deepEqual([...FRAME_STEPS].sort(), unionLiterals('FrameStep')); + for (const port of Object.values(PORTS)) + for (const capability of Object.values(port)) + if (capability !== null) assert.ok(CAPABILITIES.includes(capability)); + assert.equal(CONTRACT_VERSION, '0'); + const settings = checker.getTypeAtLocation(declared('QualitySettings').name); + assert.deepEqual( + [...QUALITY_KEYS].sort(), + checker + .getPropertiesOfType(settings) + .map((p) => p.name) + .sort(), + ); +}); + +test('the shape check names what is missing and what a capability does not allow', () => { + assert.deepEqual(checkRendererShape(null), ['renderer is not an object']); + assert.deepEqual(checkRendererShape({}), [ + 'renderer.capabilities is not a Set', + ]); + const problems = checkRendererShape({ + capabilities: new Set(['warp']), + contractVersion: '0', + }); + assert.ok(problems.includes('unknown capability warp')); + assert.ok(problems.includes('renderer.frame is missing')); + const set = Object.fromEntries( + Object.keys(PORTS.drawSet).map((member) => [member, () => {}]), + ); + assert.deepEqual(checkPortShape(set, 'drawSet', new Set()), [ + 'drawSet.setColumns is present without columns', + ]); + delete set.setColumns; + assert.deepEqual(checkPortShape(set, 'drawSet', new Set(['columns'])), [ + 'drawSet.setColumns is missing with columns', + ]); + assert.deepEqual(checkPortShape(set, 'drawSet', new Set()), []); +}); diff --git a/src/render/contract/credits.d.ts b/src/render/contract/credits.d.ts new file mode 100644 index 000000000..7cdb6c0ec --- /dev/null +++ b/src/render/contract/credits.d.ts @@ -0,0 +1,21 @@ +/** + * Attribution. The renderer reports every credit it owes (its own, each + * base layer's, and per-tile credits as tiles load); Core renders the + * credit DOM, so every renderer's credits look the same. + */ +import type { Off } from './common'; + +export interface Credit { + /** Sanitised HTML: text, links and images only. */ + html: string; + /** Shown on the map itself, rather than in the expanded list. */ + inline?: boolean; +} + +export interface CreditsPort { + /** Add a credit Core owes (a layer's source). */ + addStatic(credit: Credit): Off; + /** Every credit owed now, in display order. */ + list(): Credit[]; + on(event: 'change', cb: (credits: Credit[]) => void): Off; +} diff --git a/src/render/contract/draw.d.ts b/src/render/contract/draw.d.ts new file mode 100644 index 000000000..4a1827983 --- /dev/null +++ b/src/render/contract/draw.d.ts @@ -0,0 +1,477 @@ +/** + * Things to draw: batched sets of items keyed by string id. + * + * Core owns every record; a set is only what Core pushed. Each item may carry + * an opaque `meta` that comes back on a pick (`Hit.meta`). There is no world + * text here: every label goes through Core's screen-space label overlay, + * which only needs `query.project`/`projectMany` and `query.isOccluded`. + * + * Moving icons follow a motion segment the renderer evaluates on its own + * clock (`motionSegments`), so Core writes a row only when its motion or + * style changes, not every frame. + */ +import type { + Color, + Ecef, + GeoRect, + Handle, + Meta, + NearFar, + Off, + Position, + Quaternion, + ScreenXY, + Vec3, +} from './common'; + +/** How an item's height is read. */ +export type HeightMode = 'absolute' | 'clampGround' | 'relativeGround'; + +/** What `clampGround` / `relativeGround` clamp to. */ +export type ClampTarget = 'terrain' | 'tiles' | 'both'; + +export interface ClusterSpec { + /** Screen distance under which icons merge. */ + pixelRange: number; + /** Fewest icons that form a cluster. */ + minimumSize: number; + /** Draws a cluster's icon from its member count. */ + icon?: (count: number) => ImageRef; +} + +/** Options of a whole set; the mutable ones can change later through `setOptions`. */ +export interface SetSpec { + /** + * Unique among the renderer's live sets (making a second set with a live + * id throws; a disposed set's id can be used again); returned as `Hit.set`. + */ + id: string; + /** Hide items beyond the ellipsoid's horizon, for sets drawn without depth test. */ + horizonCull?: boolean; + /** `cluster`: screen-space clustering (icons only). */ + cluster?: ClusterSpec | null; + blend?: 'opaque' | 'translucent' | 'additive'; + /** Draw order between sets that do not depth-test against each other. */ + drawOrder?: number; + /** `false`: the set never takes part in picks. */ + pickable?: boolean; + /** Height queries see through this set. */ + excludeFromHeightQueries?: boolean; + /** What clamped items clamp to. */ + clampTo?: ClampTarget; + /** + * `motionSegments`: the clock motion segments are evaluated on. + * `live` (the default) is wall time minus `delayMs`, as a live feed is + * drawn a little behind real time; `scene` is the frame port's scene time. + */ + motionClock?: { kind: 'live'; delayMs?: number } | { kind: 'scene' }; + show?: boolean; +} + +/** Common to items placed at one position. */ +export interface Placed { + id: string; + pos: Position; + height?: HeightMode; + meta?: Meta; + show?: boolean; + /** `false`: this item never takes part in picks. */ + pickable?: boolean; + scaleByDistance?: NearFar; + fadeByDistance?: NearFar; + /** Shown only between these eye distances, metres. */ + displayRange?: [near: number, far: number]; + /** Draw over depth within this many metres of the eye (`Infinity`: always). */ + disableDepthTest?: number; +} + +/** An image an icon draws: a URL, a slot in a shared atlas, or generated. */ +export type ImageRef = + | string + | { atlas: string; slot: string } + | { svg: string } + | { blob: Blob } + | { canvas: HTMLCanvasElement | OffscreenCanvas; key: string }; + +/** + * How an icon moves between updates, on its set's `motionClock`. Times are + * epoch ms. Sending an item a new segment replaces its old one, whatever the + * times (an earlier or equal `t0` included). + * - hold: stays at `pos`. + * - lerp: straight in space from `from` (at `t0`) to `to` (at `t1`), holding + * at both ends; the course runs from `course0Deg` by `courseSlopeDeg` + * across the segment. + * - arc: a constant-rate turn in the plane tangent to the ellipsoid at `fix` + * (its east and north axes), at the fix's height, from `fix` at `t0`, at + * `speedMps` along `trackDeg` turning at `turnRateDps` (under 1e-4 rad/s is + * straight). The elapsed time is clamped to `[minDtS, maxDtS]`; a negative + * `minDtS` runs the fix backward before `t0` (a warm-up), `maxDtS` is how + * long it coasts. + * `courseGlide` eases the displayed course onto the segment's at `rateDps`. + */ +export type MotionSegment = + | { kind: 'hold'; courseDeg?: number } + | { + kind: 'lerp'; + from: Ecef; + to: Ecef; + t0: number; + t1: number; + course0Deg?: number; + courseSlopeDeg?: number; + } + | { + kind: 'arc'; + fix: Ecef; + t0: number; + minDtS: number; + maxDtS: number; + speedMps: number; + trackDeg: number; + turnRateDps: number; + }; + +export interface CourseGlide { + fromCourseDeg: number; + t0: number; + /** Degrees per second; 0 or less holds `fromCourseDeg`. */ + rateDps: number; +} + +export interface IconItem extends Placed { + image: ImageRef; + /** Drawn size in CSS pixels. */ + size: [width: number, height: number]; + color?: Color; + alpha?: number; + scale?: number; + /** `motionSegments`: motion on the renderer's clock. */ + motion?: MotionSegment; + courseGlide?: CourseGlide; + /** Degrees clockwise from north. */ + course?: number; + /** + * `screen`: upright on screen, rotated by `rotation`; + * `course`: rotated on screen along `course` (or the motion's course); + * `axis`: rotated about `alignedAxis`. + */ + rotationMode?: 'screen' | 'course' | 'axis'; + /** Radians counter-clockwise on screen, for `rotationMode: 'screen'`. */ + rotation?: number; + /** ECEF axis for `rotationMode: 'axis'`. */ + alignedAxis?: Vec3; + pixelOffset?: ScreenXY; + /** Offset toward the eye, metres in eye space. */ + eyeOffset?: Vec3; + anchor?: 'center' | 'bottom' | 'top'; + /** Swap to `near` when drawn larger than `switchPx`. */ + imageLod?: { near: ImageRef; switchPx: number }; + /** A larger invisible pick target, CSS pixels. */ + pickProxy?: { size: [width: number, height: number] }; + /** Fade and shrink toward the globe's limb. */ + recede?: boolean; + /** Emphasis factor for a focused item, 1 = none. */ + focus?: number; +} + +export interface PointItem extends Placed { + pixelSize: number; + color: Color; + outlineColor?: Color; + outlineWidth?: number; +} + +export type LineStyle = + | { kind: 'solid'; color: Color } + | { kind: 'glow'; color: Color; power: number; taper?: number } + | { + kind: 'dash'; + color: Color; + gapColor?: Color; + length: number; + pattern16?: number; + } + | { kind: 'arrow'; color: Color } + | { kind: 'pattern'; image: ImageRef; repeat: number } + | { kind: 'flow'; color: Color; repeat: number; duty: number; speed: number }; + +export interface LineItem { + id: string; + /** ECEF positions, or packed `x, y, z` triples. */ + positions: Ecef[] | Float64Array; + widthPx: number; + /** `3d`: straight in space; `geodesic`: along the ellipsoid; `draped`: on the surface. */ + mode: '3d' | 'geodesic' | 'draped'; + clampTo?: ClampTarget; + style: LineStyle; + /** Colour where the line is behind the surface; `null` hides those parts. */ + depthFailColor?: Color | null; + /** Per-segment colours; changing them does not rebuild the line. */ + segmentColors?: Color[]; + segmentShow?: boolean[]; + /** Draw only the first fraction of the line, 0..1. */ + revealFraction?: number; + /** Positions are in a frame rotating with the sky (orbits). */ + frame?: 'fixed' | 'inertial'; + meta?: Meta; + show?: boolean; + pickable?: boolean; +} + +/** A shape's fill. */ +export type Fill = + | Color + | { image: ImageRef; repeat?: [number, number] } + | { preset: 'radialRim'; color: Color; rimColor: Color; rimWidth: number }; + +/** Declarative animation the renderer runs (no per-frame callbacks). */ +export interface Pulse { + property: 'radius' | 'alpha'; + from: number; + to: number; + periodMs: number; + /** Repeat count; omitted repeats forever. */ + repeat?: number; +} + +/** A radius that follows the camera's height above the ground. */ +export interface RadiusByCameraHeight { + byCameraHeight: { factor: number; min: number; max: number }; +} + +export type ShapeGeometry = + | { kind: 'polygon'; rings: Position[][]; perPositionHeight?: boolean } + | { + kind: 'ellipse'; + center: Position; + semiMajor: number | RadiusByCameraHeight; + semiMinor: number | RadiusByCameraHeight; + /** Radians counter-clockwise from north to the semi-major axis. */ + rotation?: number; + /** Only part of the ellipse, radians from north. */ + arc?: { start: number; end: number }; + } + /** `height`: metres above the ellipsoid, for a rectangle not clamped. */ + | { kind: 'rectangle'; rect: GeoRect; height?: number } + | { + kind: 'box'; + center: Position; + dimensions: Vec3; + orientation?: Quaternion; + } + | { + kind: 'wall'; + positions: Position[]; + minimumHeights?: number[]; + maximumHeights?: number[]; + }; + +export interface ShapeItem { + id: string; + geometry: ShapeGeometry; + fill?: Fill | null; + outline?: Color | null; + outlineWidth?: number; + /** Draw the outline only. Clamped outlines are drawn as draped lines. */ + outlineOnly?: boolean; + /** Height of the extruded face, metres above the ellipsoid (not above the shape). */ + extrudeHeight?: number; + height?: 'absolute' | 'clampGround'; + clampTo?: ClampTarget; + /** Classify what is inside the volume (tint the tiles it covers). */ + classifyVolume?: boolean; + /** Pull toward the eye, metres, to win depth against coplanar surfaces. */ + depthBias?: number; + animate?: Pulse; + meta?: Meta; + show?: boolean; + pickable?: boolean; +} + +/** + * A glTF model. Every renderer draws one the same way: glTF is Y-up and is + * turned to Z-up before `orientation` applies; `minPixelSize` is measured at + * the model's bounding-sphere distance from the eye; `colorBlend` mixes the + * tint into the shaded colour and never changes the model's alpha. + */ +export interface ModelItem extends Placed { + url: string; + orientation: { heading: number; pitch: number; roll: number }; + scale?: number; + minPixelSize?: number; + maxPixelSize?: number; + maxScale?: number; + /** Can change live. */ + shading?: 'pbr' | 'unlit' | 'thermal'; + tint?: Color; + colorBlend?: { mode: 'highlight' | 'replace' | 'mix'; amount?: number }; + environmentMap?: boolean; +} + +/** A plane showing an image, a video or a canvas. */ +export interface QuadItem { + id: string; + center: Position; + orientation: Quaternion; + /** Metres. */ + size: [width: number, height: number]; + source: + | { image: ImageRef } + | { video: HTMLVideoElement } + | { canvas: HTMLCanvasElement | OffscreenCanvas }; + alpha?: number; + outline?: { color: Color; widthPx: number } | null; + meta?: Meta; + show?: boolean; + pickable?: boolean; +} + +/** Custom geometry in a local east-north-up frame at `origin`. */ +export interface MeshItem { + id: string; + origin: Ecef; + /** Packed `east, north, up` metres. */ + positions: Float32Array; + indices: Uint16Array | Uint32Array; + color: Color; + /** Unlit, with back faces drawn. */ + flat?: boolean; + meta?: Meta; + show?: boolean; + pickable?: boolean; +} + +export type FieldSpec = + | { + kind: 'windStreamlines'; + id: string; + /** Row-major `u` and `v` in m/s on a `width × height` grid over `rectangle`. */ + u: Float32Array; + v: Float32Array; + width: number; + height: number; + rectangle: GeoRect; + pathBudget: number; + steps: number; + style: { color: Color; widthPx: number; speedFactor?: number }; + /** Draw height, metres; omitted drapes on the surface. */ + heightM?: number; + } + | { + kind: 'scalarRaster'; + id: string; + values: Float32Array; + width: number; + height: number; + rectangle: GeoRect; + /** Colour ramp: value stops in ascending order. */ + ramp: Array<{ value: number; color: Color }>; + alpha?: number; + } + | { + kind: 'rasterShell'; + id: string; + /** A raster on a sphere raised above the ground. */ + image: ImageRef; + rectangle: GeoRect; + heightM: number; + liftM?: number; + /** Full detail inside this rectangle only. */ + detailWindow?: GeoRect; + alpha?: number; + }; + +export interface FieldHandle extends Handle { + update(patch: Partial): void; +} + +export type CelestialSpec = + | { + kind: 'stars'; + id: string; + catalogue: Float32Array; + magnitudeLimit?: number; + } + | { + kind: 'orbitRings'; + id: string; + /** Each ring: packed `x, y, z` in the inertial frame, metres. */ + rings: Float64Array[]; + color: Color; + widthPx: number; + }; + +/** `agents`: dots moving along polylines (traffic flow). */ +export interface AgentPathItem { + id: string; + positions: Ecef[] | Float64Array; + /** Agents on this path. */ + count: number; + speedMps: number; + /** 0 flows freely; 1 stopped; between creeps. */ + congestion?: number; + color: Color; + pixelSize: number; + fadeByDistance?: NearFar; + meta?: Meta; +} + +/** + * Columnar update for large sets (`columns`). Column `i` of each array is + * row `ids[i]`. `dirty` limits the upload to these index runs. + */ +export interface Columns { + ids: readonly string[]; + /** Packed ECEF `x, y, z`. */ + positions?: Float64Array; + /** Degrees clockwise from north. */ + courses?: Float32Array; + shown?: Uint8Array; + scales?: Float32Array; + alphas?: Float32Array; + /** Packed `r, g, b, a`, 0..255. */ + colors?: Uint8Array; + meta?: readonly Meta[]; + dirty?: ReadonlyArray<{ start: number; end: number }>; +} + +export interface DrawSet { + readonly id: string; + /** Replace every item. */ + set(items: T[]): void; + /** Add or replace items by id. */ + upsert(items: T[]): void; + remove(ids: string[]): void; + clear(): void; + /** `columns`. */ + setColumns?(columns: Columns): void; + /** Change the mutable set options. */ + setOptions(options: Partial>): void; + /** Items in the set. */ + size(): number; + show: boolean; + /** `models`: a model has loaded and is posed. */ + on(event: 'ready', cb: (id: string) => void): Off; + dispose(): void; +} + +export interface DrawPort { + icons(spec: SetSpec): DrawSet; + points(spec: SetSpec): DrawSet; + lines(spec: SetSpec): DrawSet; + shapes(spec: SetSpec): DrawSet; + /** `models`. */ + models?(spec: SetSpec): DrawSet; + /** `quads`. */ + quads?(spec: SetSpec): DrawSet; + /** `meshes`. */ + meshes?(spec: SetSpec): DrawSet; + /** `fields`. */ + field?(spec: FieldSpec): FieldHandle; + /** `celestial`: drawn in an inertial frame rotated by sidereal time. */ + celestial?(spec: CelestialSpec): Handle; + /** `agents`. */ + agents?(spec: SetSpec): DrawSet; + /** Register an atlas of images icons refer to by slot. */ + atlas(id: string, images: Record): void; +} diff --git a/src/render/contract/effects.d.ts b/src/render/contract/effects.d.ts new file mode 100644 index 000000000..ccfbd3c34 --- /dev/null +++ b/src/render/contract/effects.d.ts @@ -0,0 +1,48 @@ +/** + * Full-screen effects: visual styles, bloom, sharpen and screen modulation. + * Each style is a plain full-screen pass over the frame's colour, so every + * renderer can provide the same set. + */ +import type { Off } from './common'; + +export interface StyleDescriptor { + id: string; + /** Parameters and their ranges. */ + params: Array<{ key: string; min: number; max: number; default: number }>; + /** Animates, so it needs frames while on. */ + animated: boolean; +} + +/** A sweeping sector drawn over the frame (a sonar band), or null. */ +export interface ModulationSector { + /** Centre of the sweep, CSS pixels. */ + center: { x: number; y: number }; + /** Current angle, radians clockwise from up. */ + angle: number; + /** Width of the band, radians. */ + width: number; + /** 0..1. */ + strength: number; + color: string; +} + +export interface EffectsDiagnostics { + style: string | null; + passes: string[]; +} + +export interface EffectsPort { + /** `styles`. */ + styles(): StyleDescriptor[]; + setStyle(id: string | null, params?: Record): void; + setStyleIntensity(id: string, intensity: number): void; + setStyleParams(id: string, params: Record): void; + /** `bloom`. */ + bloom(settings: { enabled: boolean; strength?: number }): void; + /** `sharpen`. */ + sharpen(settings: { enabled: boolean; amount?: number }): void; + /** `screenModulation`. */ + screenModulation?(sector: ModulationSector | null): void; + diagnostics(): EffectsDiagnostics; + on(event: 'change', cb: (diagnostics: EffectsDiagnostics) => void): Off; +} diff --git a/src/render/contract/frame.d.ts b/src/render/contract/frame.d.ts new file mode 100644 index 000000000..a407347ba --- /dev/null +++ b/src/render/contract/frame.d.ts @@ -0,0 +1,76 @@ +/** + * The frame loop, render on demand and scene time. + * + * A renderer draws on demand: a frame is drawn when something asks for one + * (`requestRender`), while any continuous hold is out, or every frame on an + * engine that cannot idle (it then reports `mode()` as `'continuous'`). + * + * One frame runs in this order, and every renderer keeps it: + * camera flight → input → camera `changed` → follow → `preUpdate` → + * move detection (`moveStart` / `moveEnd`) → `preRender` → render → + * `postRender`. `preUpdate` fires on every frame the loop ticks, including + * frames that end up drawing nothing, so owners that write the camera there + * (a cockpit, a follow clamp) see each one. + */ +import type { Off, Release } from './common'; + +export type FramePhase = 'preUpdate' | 'preRender' | 'postRender'; + +/** The order the phases (and the steps between them) run in one frame. */ +export type FrameStep = + | 'flight' + | 'input' + | 'changed' + | 'follow' + | 'preUpdate' + | 'moveDetection' + | 'preRender' + | 'render' + | 'postRender'; + +export interface FrameTime { + /** Frames ticked since creation. */ + frame: number; + /** Scene time, epoch ms: what sun, stars and lighting show. */ + time: number; + /** The loop's clock, ms (virtual under a test clock). */ + now: number; + /** Seconds since the previous tick. */ + dt: number; + /** Whether this tick draws. */ + rendering: boolean; +} + +export interface FrameDiagnostics { + mode: 'demand' | 'continuous'; + frames: number; + rendered: number; + /** Reasons given to `requestRender` since the last frame drawn. */ + pendingReasons: string[]; + /** Reasons of the holds now out. */ + holds: string[]; + suspended: boolean; + targetFps: number; +} + +export interface FramePort { + on(phase: FramePhase, cb: (t: FrameTime) => void): Off; + /** Ask for one frame. The reason shows in diagnostics and signals. */ + requestRender(reason: string): void; + /** Keep drawing every frame until released. Holds are counted. */ + holdContinuous(reason: string): Release; + mode(): 'demand' | 'continuous'; + diagnostics(): FrameDiagnostics; + /** Stop ticking while the page is hidden; resume on `false`. */ + suspend(hidden: boolean): void; + /** Draw a frame now, synchronously. */ + renderNow(): void; + /** Resolves after the next frame has been drawn and presented. */ + renderOnce(options?: { timeoutMs?: number }): Promise; + /** Cap the loop's rate; 0 is the display's rate. */ + setTargetFps(fps: number): void; + /** Set scene time, epoch ms. */ + setTime(ms: number): void; + /** Scene time, epoch ms. */ + time(): number; +} diff --git a/src/render/contract/globe.d.ts b/src/render/contract/globe.d.ts new file mode 100644 index 000000000..0b35ebfb1 --- /dev/null +++ b/src/render/contract/globe.d.ts @@ -0,0 +1,210 @@ +/** + * The base world: photorealistic tiles or a globe with terrain and imagery, + * the sky, and what drapes onto them. + */ +import type { Credit } from './credits'; +import type { GeoRect, Off } from './common'; + +export interface PhotorealSpec { + provider: 'google'; + key?: string; + /** Renews a session token; called again when the token expires. */ + token?: () => Promise; + /** Fall back to an ion-hosted copy when Google refuses. */ + ionFallback?: boolean; +} + +export interface PhotorealStatus { + /** Which route is drawing, or `'none'` when photoreal is off or failed. */ + route: 'google' | 'ion' | 'none'; + error?: { code: string; message: string }; +} + +/** + * Heights everywhere in the contract are physical (metres above the + * ellipsoid, unexaggerated): absolute positions, `HeightSample.height`, + * `clampToSurface`. Only drawing follows the exaggerated surface: + * `clampGround` and `relativeGround` items sit on the ground as drawn. + */ +export type TerrainSpec = ( + | { kind: 'ellipsoid' } + | { kind: 'quantizedMesh'; url: string; requestVertexNormals?: boolean } + | { kind: 'ion'; assetId: number } + /** A DEM image pyramid (`{z}/{x}/{y}`), heights encoded in RGB. */ + | { + kind: 'heightmap'; + url: string; + encoding: 'terrarium' | 'mapbox'; + maxLevel?: number; + /** The geoid the encoded heights are relative to; omitted: the ellipsoid. */ + geoid?: 'egm96' | 'egm2008'; + } +) & { + /** Vertical exaggeration of the drawn terrain; default 1. */ + exaggeration?: number; +}; + +export type TilingScheme = 'webMercator' | 'geographic'; + +/** One tile request, as an admission hook sees it. */ +export interface TileRequest { + x: number; + y: number; + level: number; +} + +export interface ImagerySpec { + kind: 'xyz' | 'wmts' | 'arcgis' | 'bing' | 'single-image' | 'canvas'; + /** URL or URL template (`{x}`, `{y}`, `{z}`, `{s}`, `{time}`). */ + url?: string; + /** `'canvas'`: draws one tile. */ + drawTile?: (tile: TileRequest, canvas: HTMLCanvasElement) => void; + /** WMTS: layer, style, tile matrix set and format. */ + wmts?: { + layer: string; + style: string; + tileMatrixSetId: string; + format?: string; + }; + subdomains?: string[]; + tiling?: TilingScheme; + rectangle?: GeoRect; + minLevel?: number; + maxLevel?: number; + tileSize?: number; + credit?: Credit; + /** Requests in flight at once for this layer. */ + maxConcurrentRequests?: number; + /** HTTP statuses retried rather than treated as missing. */ + retryStatuses?: number[]; + /** Changes a tile's image before it is drawn. */ + tileTransform?: ( + tile: TileRequest, + image: ImageBitmap, + ) => ImageBitmap | Promise; + /** + * Decides each tile request: call `issue()` to fetch it, or return + * `undefined` to defer it and be asked again on a later frame. + */ + admit?: ( + tile: TileRequest, + issue: () => Promise | undefined, + ) => Promise | undefined; + /** Draw order among imagery layers; higher draws on top. */ + zOrder?: number; +} + +export interface TimedUrl { + /** Epoch ms the frame shows. */ + time: number; + url: string; +} + +export interface ImageryLayer { + alpha: number; + show: boolean; + /** Which side of the split this layer shows on. */ + split: 'none' | 'left' | 'right'; + /** What it drapes onto. `'active'` follows whichever base is showing. */ + drapeOn: 'globe' | 'tiles' | 'both' | 'active'; + /** `imageryFrames`: a timed sequence, prefetched hidden. */ + setFrames?( + frames: TimedUrl[], + options?: { mode?: 'nearest' | 'blend'; prefetch?: number }, + ): void; + /** `imageryFrames`: which time to show. */ + setFrameTime?(ms: number): void; + on(event: 'frameReady', cb: (time: number) => void): Off; + on( + event: 'tileError', + cb: (error: { tile: TileRequest; status?: number }) => void, + ): Off; + raise(): void; + lower(): void; + remove(): void; +} + +export interface BaseSpec { + photoreal?: PhotorealSpec | null; + /** `vectorBase`: a vector basemap from a style document. */ + vector?: { styleUrl: string } | null; + terrain?: TerrainSpec | null; + imagery?: ImagerySpec[]; + credit?: Credit; +} + +export interface BaseStatus { + photoreal: PhotorealStatus; + imagery: ImageryLayer[]; + settled: boolean; +} + +/** + * The sky. A renderer whose sun, moon or fog are not separate from its + * atmosphere follows `atmosphere` for them and reports them as it draws them. + */ +export interface SkySettings { + atmosphere: boolean; + atmosphereTuning?: { + brightness?: number; + hueShift?: number; + saturation?: number; + }; + stars: boolean; + /** Milky Way brightness, 0..1. */ + milkyWay: number; + sun: boolean; + moon: boolean; + /** Distance fog, separate from atmosphere on models. */ + fog: boolean; + /** Sun lighting of the globe and models. */ + lighting: boolean; +} + +export interface SurfaceShading { + kind: 'relief'; + /** Elevation-like field to shade, row-major, `width × height`. */ + values: Float32Array; + width: number; + height: number; + rectangle: GeoRect; + strength: number; +} + +export interface LoadProgress { + tilesPending: number; + imageryPending: number; + terrainPending: number; +} + +export type SettleKind = 'base' | 'tiles' | 'terrain' | 'imagery'; + +export interface GlobePort { + /** Switch the whole base at once; resolves when the new base has settled. */ + setBase(base: BaseSpec): Promise; + /** `photoreal`: on, off or a new token route. */ + setPhotoreal(spec: PhotorealSpec | null): Promise; + setTerrain(spec: TerrainSpec | null): Promise; + /** Show or hide the globe surface (hidden under photoreal). */ + setGlobeVisible(visible: boolean): void; + /** `surfaceShading`. */ + setSurfaceShading?(shading: SurfaceShading | null): void; + readonly imagery: { + add(spec: ImagerySpec, index?: number): ImageryLayer; + list(): ImageryLayer[]; + /** What imagery drapes onto now. */ + host(): 'globe' | 'tiles' | 'both'; + onHostChange(cb: (host: 'globe' | 'tiles' | 'both') => void): Off; + /** `splitSwipe`: split position, a fraction of the canvas's CSS width. */ + getSplit?(): number; + /** `splitSwipe`: move the split to `x01` of the canvas's CSS width. */ + setSplit?(x01: number): void; + }; + readonly sky: { + get(): SkySettings; + set(settings: Partial): void; + }; + /** Whether that part of the base has finished loading what is in view. */ + isSettled(kind: SettleKind): boolean; + on(event: 'progress', cb: (progress: LoadProgress) => void): Off; +} diff --git a/src/render/contract/index.d.ts b/src/render/contract/index.d.ts new file mode 100644 index 000000000..be8f6bc04 --- /dev/null +++ b/src/render/contract/index.d.ts @@ -0,0 +1,48 @@ +/** + * The renderer contract: what Core asks of a renderer, so a renderer other + * than the default one can draw the same application. + */ +export * from './common'; +export * from './renderer'; +export * from './frame'; +export * from './globe'; +export * from './draw'; +export * from './camera'; +export * from './query'; +export * from './input'; +export * from './effects'; +export * from './credits'; +export * from './quality'; + +/** Every capability a renderer may list. */ +export declare const CAPABILITIES: readonly import('./common').Capability[]; + +/** The contract version this module describes. */ +export declare const CONTRACT_VERSION: import('./renderer').ContractVersion; + +/** One port's members: null when required, else the capability that adds it. */ +export type PortDescriptor = Readonly< + Record +>; + +/** Every port's members, by port name. */ +export declare const PORTS: Readonly>; + +/** Every quality setting a renderer may support. */ +export declare const QUALITY_KEYS: readonly import('./quality').QualityKey[]; + +/** The phases and steps of one frame, in order. */ +export declare const FRAME_STEPS: readonly import('./frame').FrameStep[]; + +/** + * What a renderer is missing against the contract: each required member it + * lacks, and each capability-gated member present without its capability. + */ +export declare function checkRendererShape(renderer: unknown): string[]; + +/** What one port (or handed-out shape) is missing against its descriptor. */ +export declare function checkPortShape( + target: object, + port: string, + capabilities: ReadonlySet, +): string[]; diff --git a/src/render/contract/index.js b/src/render/contract/index.js new file mode 100644 index 000000000..2019f7995 --- /dev/null +++ b/src/render/contract/index.js @@ -0,0 +1,370 @@ +/** + * @module render/contract + * @description The renderer contract at run time: the capability flags, every + * port's members, and a shape check. The types live beside this file + * (`index.d.ts` and one `.d.ts` per port); `contract.test.mjs` keeps + * {@link PORTS} and those types in step, member for member. + * + * A member is required (`null`) or added by a capability: a renderer without + * that capability leaves the member out. + */ + +/** @type {import('./index').ContractVersion} */ +export const CONTRACT_VERSION = '0'; + +/** @type {readonly import('./common').Capability[]} */ +export const CAPABILITIES = Object.freeze([ + 'photoreal', + 'vectorBase', + 'finestHeights', + 'terrain', + 'modelAtmosphere', + 'imageryOnTiles', + 'splitSwipe', + 'imageryFrames', + 'surfaceShading', + 'screenModulation', + 'gpuTiming', + 'pickPosition', + 'sampleHeight', + 'rayCast', + 'columns', + 'motionSegments', + 'models', + 'quads', + 'meshes', + 'fields', + 'celestial', + 'agents', + 'cluster', + 'styles', + 'bloom', + 'sharpen', + 'followAutodetect', + 'benchmark', +]); + +/** @type {readonly import('./frame').FrameStep[]} */ +export const FRAME_STEPS = Object.freeze([ + 'flight', + 'input', + 'changed', + 'follow', + 'preUpdate', + 'moveDetection', + 'preRender', + 'render', + 'postRender', +]); + +/** Every quality setting a renderer may support. */ +export const QUALITY_KEYS = Object.freeze([ + 'pixelRatio', + 'overlayPixelRatio', + 'msaa', + 'tiles3dDetail', + 'terrainDetail', + 'imageryDetail', + 'tilesCacheBytes', + 'clouds', + 'shadows', + 'water', + 'atmosphere', + 'milkyWay', + 'particles', + 'models', + 'postFx', + 'animatedStyleFps', + 'sonarCadenceHz', + 'targetFps', +]); + +const required = (...names) => names.map((name) => [name, null]); + +/** + * Every port's members. Nested members are dotted (`imagery.add`); the + * instance shapes a port hands out (`drawSet`, `imageryLayer`, `flight`, + * `lease`) are listed as their own entries. + * @type {Readonly>>>} + */ +export const PORTS = deepFreeze({ + renderer: Object.fromEntries( + required( + 'id', + 'contractVersion', + 'capabilities', + 'canvas', + 'frame', + 'globe', + 'draw', + 'camera', + 'query', + 'input', + 'effects', + 'credits', + 'quality', + 'capture', + 'readPixels', + 'whenSettled', + 'resize', + 'releaseUnused', + 'on', + 'destroy', + ), + ), + frame: Object.fromEntries( + required( + 'on', + 'requestRender', + 'holdContinuous', + 'mode', + 'diagnostics', + 'suspend', + 'renderNow', + 'renderOnce', + 'setTargetFps', + 'setTime', + 'time', + ), + ), + globe: Object.fromEntries([ + ...required( + 'setBase', + 'setPhotoreal', + 'setTerrain', + 'setGlobeVisible', + 'imagery', + 'imagery.add', + 'imagery.list', + 'imagery.host', + 'imagery.onHostChange', + 'sky', + 'sky.get', + 'sky.set', + 'isSettled', + 'on', + ), + ['setSurfaceShading', 'surfaceShading'], + ['imagery.getSplit', 'splitSwipe'], + ['imagery.setSplit', 'splitSwipe'], + ]), + draw: Object.fromEntries([ + ...required('icons', 'points', 'lines', 'shapes', 'atlas'), + ['models', 'models'], + ['quads', 'quads'], + ['meshes', 'meshes'], + ['field', 'fields'], + ['celestial', 'celestial'], + ['agents', 'agents'], + ]), + camera: Object.fromEntries( + required( + 'pose', + 'setView', + 'setFov', + 'viewRectangle', + 'flyTo', + 'flyToBounds', + 'cancelFlight', + 'flying', + 'lookAt', + 'release', + 'move', + 'look', + 'rotateAround', + 'zoom', + 'follow', + 'followed', + 'followFramed', + 'followOffset', + 'followOffset.get', + 'followOffset.set', + 'controls', + 'controls.enabled', + 'controls.suspend', + 'controls.enableTilt', + 'controls.enableRotate', + 'controls.enableZoom', + 'controls.minDistance', + 'controls.maxDistance', + 'controls.inertiaSpin', + 'controls.inertiaTranslate', + 'controls.inertiaZoom', + 'controls.collision', + 'controls.zoomFactor', + 'controls.pitchRange', + 'controls.zoomInputs', + 'on', + 'onCommand', + 'matrices', + ), + ), + query: Object.fromEntries( + required( + 'pick', + 'drillPick', + 'pickPosition', + 'pickEllipsoid', + 'pickGlobe', + 'pickRay', + 'project', + 'projectMany', + 'isOccluded', + 'sampleHeightNow', + 'sampleHeightNowMany', + 'sampleHeight', + 'clampToSurface', + 'rayCast', + ), + ), + input: Object.fromEntries( + required( + 'on', + 'claimPointer', + 'isPointerFree', + 'suspendCameraControls', + 'behaviours', + 'behaviours.select', + 'behaviours.select.enabled', + 'behaviours.select.suppress', + 'behaviours.dblclickFollow', + 'behaviours.dblclickFollow.enabled', + 'behaviours.dblclickFollow.suppress', + 'ctrlWheelZoom', + ), + ), + effects: Object.fromEntries([ + ...required( + 'styles', + 'setStyle', + 'setStyleIntensity', + 'setStyleParams', + 'bloom', + 'sharpen', + 'diagnostics', + 'on', + ), + ['screenModulation', 'screenModulation'], + ]), + credits: Object.fromEntries(required('addStatic', 'list', 'on')), + quality: Object.fromEntries([ + ...required( + 'knobs', + 'get', + 'set', + 'device', + 'probe', + 'signals', + 'signals.gpuTiming', + 'signals.on', + ), + ['benchmark', 'benchmark'], + ]), + drawSet: Object.fromEntries([ + ...required( + 'id', + 'set', + 'upsert', + 'remove', + 'clear', + 'setOptions', + 'size', + 'show', + 'on', + 'dispose', + ), + ['setColumns', 'columns'], + ]), + imageryLayer: Object.fromEntries([ + ...required( + 'alpha', + 'show', + 'split', + 'drapeOn', + 'on', + 'raise', + 'lower', + 'remove', + ), + ['setFrames', 'imageryFrames'], + ['setFrameTime', 'imageryFrames'], + ]), + flight: Object.fromEntries(required('done', 'cancel')), + lease: Object.fromEntries(required('owner', 'current', 'release')), +}); + +/** The ports a renderer object holds, by the key it holds them under. */ +const RENDERER_PORTS = [ + 'frame', + 'globe', + 'draw', + 'camera', + 'query', + 'input', + 'effects', + 'credits', + 'quality', +]; + +function deepFreeze(value) { + for (const inner of Object.values(value)) + if (inner && typeof inner === 'object') deepFreeze(inner); + return Object.freeze(value); +} + +function has(target, dotted) { + let at = target; + for (const key of dotted.split('.')) { + if (at == null || (typeof at !== 'object' && typeof at !== 'function')) + return false; + if (!(key in at)) return false; + at = at[key]; + } + return true; +} + +/** + * What `target` is missing against one descriptor, and what it has that its + * capabilities do not allow. + * @param {object} target + * @param {string} port + * @param {ReadonlySet} capabilities + * @returns {string[]} + */ +export function checkPortShape(target, port, capabilities) { + const problems = []; + for (const [member, capability] of Object.entries(PORTS[port])) { + const present = has(target, member); + if (capability === null) { + if (!present) problems.push(`${port}.${member} is missing`); + } else if (capabilities.has(capability) && !present) { + problems.push(`${port}.${member} is missing with ${capability}`); + } else if (!capabilities.has(capability) && present) { + problems.push(`${port}.${member} is present without ${capability}`); + } + } + return problems; +} + +/** + * What a renderer is missing against the contract. + * @param {unknown} renderer + * @returns {string[]} + */ +export function checkRendererShape(renderer) { + if (!renderer || typeof renderer !== 'object') + return ['renderer is not an object']; + const r = /** @type {any} */ (renderer); + const capabilities = r.capabilities instanceof Set ? r.capabilities : null; + if (!capabilities) return ['renderer.capabilities is not a Set']; + const problems = []; + for (const capability of capabilities) + if (!CAPABILITIES.includes(capability)) + problems.push(`unknown capability ${capability}`); + if (r.contractVersion !== CONTRACT_VERSION) + problems.push(`contractVersion is ${r.contractVersion}`); + problems.push(...checkPortShape(r, 'renderer', capabilities)); + for (const port of RENDERER_PORTS) + if (r[port]) problems.push(...checkPortShape(r[port], port, capabilities)); + return problems; +} diff --git a/src/render/contract/input.d.ts b/src/render/contract/input.d.ts new file mode 100644 index 000000000..d1cc98013 --- /dev/null +++ b/src/render/contract/input.d.ts @@ -0,0 +1,93 @@ +/** + * Pointer input over the scene, through one dispatcher. + * + * A tool takes the pointer with a lease; while a lease is out, ambient + * subscribers (those without a lease) get only `move` and `up`: no presses, + * clicks, double-clicks, wheel or pinch. Suspending camera + * input is separate and counted, and takes effect on the same pointerdown + * that asked for it. + * + * A click is delivered with its pick already made, once, and shared by + * every subscriber (`PointerEv.pick`). A renderer whose picks answer a frame + * or two late delivers the click that much later rather than answering + * without them; `query.pick` stays synchronous for everything else. + * + * The engine's stock behaviours (select on click, follow on double-click) + * are explicit and removable here, never implicit. + */ +import type { Hit, PickWindow } from './query'; +import type { Ecef, Off, Release, ScreenXY } from './common'; + +export type PointerEventType = + 'down' | 'move' | 'up' | 'click' | 'dblclick' | 'wheel' | 'pinch'; + +export interface Modifiers { + shift: boolean; + ctrl: boolean; + alt: boolean; + meta: boolean; +} + +export interface PointerEv { + type: PointerEventType; + position: ScreenXY; + /** For `move`: where the pointer is now (`position` is where it was). */ + endPosition?: ScreenXY; + button: 0 | 1 | 2; + modifiers: Modifiers; + /** Event time, ms (`performance.now()` timeline). */ + timestamp: number; + pointerType: 'mouse' | 'touch' | 'pen'; + /** `click`: how far the pointer travelled and how long the press lasted. */ + gesture?: { travelPx: number; durationMs: number }; + /** `wheel`: lines scrolled; `pinch`: scale since the last event. */ + delta?: number; + /** `click` and `dblclick`: the hit under the pointer, picked once for every subscriber. */ + pick?(): Hit | null; + /** `click` and `dblclick`: every hit under the pointer, from the same pick. */ + drill?(options?: PickWindow & { limit?: number }): Hit[]; + /** `click` and `dblclick`: the surface position under the pointer. */ + ground?(): Ecef | null; +} + +/** A tool's hold on the pointer. */ +export interface Lease { + readonly owner: string; + readonly current: boolean; + release(): void; +} + +/** A stock behaviour, on until suppressed. */ +export interface StockBehaviour { + readonly enabled: boolean; + /** Turn it off until released. Holds are counted. */ + suppress(): Release; +} + +export interface InputPort { + on( + type: PointerEventType, + cb: (ev: PointerEv) => void, + options?: { lease?: Lease | (() => Lease | null) }, + ): Off; + /** Take the pointer for a tool. A new claim ends the previous lease. */ + claimPointer(owner: string): Lease; + isPointerFree(): boolean; + /** Suspend user camera input until released. Holds are counted. */ + suspendCameraControls(reason: string): Release; + /** + * Every behaviour the renderer does on its own in answer to input, each + * suppressible. `select` and `dblclickFollow` are always listed (a + * renderer without one reports it disabled); any other built-in one (a + * double-click zoom) is listed under its own name. + */ + readonly behaviours: { + /** Select the item under a click. */ + readonly select: StockBehaviour; + /** Follow the item under a double-click. */ + readonly dblclickFollow: StockBehaviour; + readonly [name: string]: StockBehaviour; + }; + /** Ctrl+wheel zooms (rather than the page). */ + ctrlWheelZoom: boolean; +} diff --git a/src/render/contract/quality.d.ts b/src/render/contract/quality.d.ts new file mode 100644 index 000000000..31f05e549 --- /dev/null +++ b/src/render/contract/quality.d.ts @@ -0,0 +1,116 @@ +/** + * Adaptive quality. A governor above the contract decides a level; each + * renderer reports its frame signals, lists the settings it supports and + * applies them. Nothing here is engine-specific. + */ +import type { Off } from './common'; + +export interface QualitySettings { + /** Absolute device pixel ratio for the scene; 0 = browser recommended. */ + pixelRatio: number; + /** Pixel ratio of screen-space overlays. */ + overlayPixelRatio: number; + msaa: 0 | 2 | 4 | 8; + /** 0..1: 3D tile detail (screen-space error, both directions). */ + tiles3dDetail: number; + terrainDetail: number; + imageryDetail: number; + tilesCacheBytes: number; + clouds: 'off' | 'low' | 'medium' | 'high' | 'ultra'; + shadows: boolean; + water: 'off' | 'low' | 'high'; + atmosphere: 'off' | 'low' | 'high'; + milkyWay: 'low' | 'high'; + /** 0..1 density of particle fields (wind, weather). */ + particles: number; + models: { maxInstances: number; maxDistance: number }; + postFx: { bloom: boolean; sharpen: boolean; style: 'low' | 'high' }; + /** Frame rate of animated styles. */ + animatedStyleFps: number; + /** Updates per second of a screen modulation sweep. */ + sonarCadenceHz: number; + targetFps: number; +} + +export type QualityKey = keyof QualitySettings; + +export interface KnobDescriptor { + key: QualityKey; + kind: 'number' | 'boolean' | 'enum' | 'object'; + min?: number; + max?: number; + options?: readonly string[] | readonly number[]; + default: unknown; + /** Relative cost, 0 (free) to 1 (the heaviest knob). */ + cost: number; + /** Changing it recreates the drawing context. */ + recreates?: boolean; + /** What else changing it affects (lower tile detail lowers height accuracy). */ + couples?: string[]; +} + +export interface FrameSignals { + cpuMs: number; + /** `gpuTiming`. */ + gpuMs?: number; + passes?: Record; + drawCalls?: number; + commands?: number; + tilesInFlight?: number; + memory?: { gpuBytes?: number; tilesBytes?: number }; + pixelRatio: number; + width: number; + height: number; + mode: 'demand' | 'continuous'; + /** This frame was asked for (not continuous). */ + requested: boolean; + holds: string[]; + /** Spread of present intervals over the last second, ms. */ + presentJitterMs?: number; +} + +export interface DeviceFacts { + backend: 'webgl1' | 'webgl2' | 'webgpu'; + gpuVendor?: string; + gpuRenderer?: string; + maxTextureSize: number; + msaaSamples: number[]; + gpuTimer: boolean; +} + +export interface ProbeResult { + device: DeviceFacts; + browser: { + devicePixelRatio: number; + screen: { width: number; height: number }; + hardwareConcurrency?: number; + deviceMemory?: number; + touchOnly: boolean; + }; + knobs: KnobDescriptor[]; +} + +export interface BenchResult { + frames: number; + cpuMs: { median: number; p95: number }; + gpuMs?: { median: number; p95: number }; +} + +export interface QualityPort { + /** The settings this renderer supports, with ranges, defaults and costs. */ + knobs(): KnobDescriptor[]; + get(): Partial; + /** Applied at the next frame; returns the keys it ignored as unsupported. */ + set(settings: Partial): QualityKey[]; + device(): DeviceFacts; + probe(): ProbeResult; + readonly signals: { + readonly gpuTiming: 'none' | 'frame' | 'perPass'; + on(event: 'frame', cb: (signals: FrameSignals) => void): Off; + }; + /** `benchmark`: the renderer's own; the governor has a default. */ + benchmark?(options: { + durationMs: number; + scene?: 'standard'; + }): Promise; +} diff --git a/src/render/contract/query.d.ts b/src/render/contract/query.d.ts new file mode 100644 index 000000000..31a6e9e48 --- /dev/null +++ b/src/render/contract/query.d.ts @@ -0,0 +1,106 @@ +/** + * Questions about what is on screen. + * + * Picks, projection and rays are synchronous and answer against the last + * presented frame. Heights have a synchronous cache-only form that may + * return null, and asynchronous forms that load what they need. + */ +import type { Ecef, LonLat, Meta, Ray, ScreenXY } from './common'; + +/** One thing under the pointer. */ +export interface Hit { + /** The draw set's id. */ + set: string; + /** The item's id. */ + id: string; + /** The item's `meta`, as it was given; absent when the item had none. */ + meta?: Meta; + /** The map itself (tiles or globe), rather than a drawn item. */ + surface: boolean; + /** Where the hit is, when the renderer knows it. */ + position?: Ecef; +} + +export interface PickWindow { + /** Pick window, CSS pixels; default 3 × 3. */ + width?: number; + height?: number; + /** + * What the pick is for. A hover pick may skip costly exact tests (a + * point-in-polygon on a large fill) that a select pick must make. + */ + purpose?: 'hover' | 'select'; +} + +export interface HeightOptions { + /** `surface`: tiles where they are drawn, otherwise terrain. */ + against?: 'surface' | 'terrain' | 'tiles'; + /** Set ids to see through. */ + exclude?: string[]; +} + +export interface HeightSample { + /** Metres above the ellipsoid. */ + height: number; + /** 1 for the most detailed data, lower for a coarser level. */ + confidence: number; + source: 'tiles' | 'terrain' | 'ellipsoid'; + /** The detail answered from: what is drawn now, or (`finestHeights`) the finest available. */ + lod: 'visible' | 'finest'; + /** Edge length of the tile answered from, metres, when known. */ + tileSizeM?: number; +} + +export interface QueryPort { + pick(x: number, y: number, window?: PickWindow): Hit | null; + drillPick( + x: number, + y: number, + options?: PickWindow & { limit?: number }, + ): Hit[]; + /** `pickPosition`: depth under the pointer, against tiles and terrain. */ + pickPosition(x: number, y: number): Ecef | null; + pickEllipsoid(x: number, y: number): Ecef | null; + /** Where a ray meets the globe surface (not tiles). */ + pickGlobe(ray: Ray): Ecef | null; + pickRay(x: number, y: number): Ray; + /** Screen position, or null behind the eye (off-screen points still answer). */ + project(position: Ecef): ScreenXY | null; + /** + * Packed `x, y, z` in → packed `x, y` out, CSS pixels; NaN for points + * behind the eye. For the label overlay's per-frame pass. + */ + projectMany(source: Float64Array, out: Float32Array): void; + /** Hidden by the ellipsoid's horizon from the current eye. */ + isOccluded(position: Ecef): boolean; + /** `sampleHeight`: cached height now, or null when not loaded. */ + sampleHeightNow(point: LonLat, options?: HeightOptions): number | null; + /** + * `sampleHeight`: cached heights for many points at once: packed `lon, lat` + * degrees in, one height per point out (NaN when not loaded). Core makes + * its per-frame height reads through this, once a frame. + */ + sampleHeightNowMany( + points: Float64Array, + out: Float64Array, + options?: HeightOptions, + ): void; + /** + * `sampleHeight`: heights, loading as needed; null per miss. + * `mostDetailed` (`finestHeights`) waits for the finest data. + */ + sampleHeight( + points: LonLat[], + options?: HeightOptions & { mostDetailed?: boolean }, + ): Promise>; + /** `sampleHeight`: positions moved onto the surface; null per miss. */ + clampToSurface( + positions: Ecef[], + options?: HeightOptions, + ): Promise>; + /** `rayCast`: first surface hit along a ray. */ + rayCast( + ray: Ray, + options?: HeightOptions & { maxDistance?: number }, + ): Promise; +} diff --git a/src/render/contract/renderer.d.ts b/src/render/contract/renderer.d.ts new file mode 100644 index 000000000..8b8ad2619 --- /dev/null +++ b/src/render/contract/renderer.d.ts @@ -0,0 +1,114 @@ +/** + * The renderer itself: what a plugin creates and what Core holds. + * + * A renderer draws the globe and what Core asks it to draw, owns the camera, + * and answers queries about what is on screen. Core keeps every record of + * its own; it never reads a renderer to learn its own state, only what the + * renderer itself measures (picks, depth, heights, readiness). + */ +import type { Capability, Off, ScreenRect } from './common'; +import type { CameraPort } from './camera'; +import type { CreditsPort } from './credits'; +import type { DrawPort } from './draw'; +import type { EffectsPort } from './effects'; +import type { FramePort } from './frame'; +import type { GlobePort, SkySettings } from './globe'; +import type { InputPort } from './input'; +import type { QualityPort } from './quality'; +import type { QueryPort } from './query'; + +/** The version of the contract this file describes. */ +export type ContractVersion = '0'; + +/** What Core is given to make a renderer. */ +export interface RendererPlugin { + /** Stable id, recorded with each session (`'cesium'`, ...). */ + id: string; + create(options: CreateOptions): Promise; +} + +export interface CreateOptions { + /** The element the renderer fills. It creates its own canvas inside. */ + container: HTMLElement; + /** Where Core renders credits, when it has a place for them. */ + creditContainer?: HTMLElement; + /** Google Maps Platform key for photorealistic tiles. */ + googleApiKey?: string; + /** Short-lived Google session tokens, when the key is held server-side. */ + googleTokens?: () => Promise; + /** Cesium ion token, for ion-hosted terrain and fallback tiles. */ + ionToken?: string; + /** Initial multisampling; quality can change it later. */ + msaa?: 0 | 2 | 4 | 8; + /** Keep the drawing buffer after presenting, for capture. */ + preserveDrawingBuffer?: boolean; + /** Sky applied before the first frame, so nothing draws in the wrong sky. */ + sky?: Partial; + /** Scene time in epoch ms. Core passes its boot time. */ + time?: number; + /** + * The clocks the renderer reads, for flights, inertia, tweens and + * `FrameTime.now`: `now` on the `performance.now()` timeline, `date` in + * epoch ms. Omitted: the browser's. Tests pass virtual ones. + */ + clock?: { now(): number; date(): number }; + /** Aborts creation; the renderer cleans up whatever it had built. */ + signal: AbortSignal; +} + +export interface CaptureOptions { + type?: 'png' | 'jpeg'; + /** JPEG quality, 0..1. */ + quality?: number; + /** Output scale relative to the canvas's CSS size. */ + scale?: number; + /** Part of the canvas, in CSS pixels. */ + crop?: ScreenRect; +} + +export interface SettleResult { + state: 'settled' | 'timeout' | 'failed'; + blockers: string[]; +} + +export interface Renderer { + readonly id: string; + readonly contractVersion: ContractVersion; + readonly capabilities: ReadonlySet; + readonly canvas: HTMLCanvasElement; + readonly frame: FramePort; + readonly globe: GlobePort; + readonly draw: DrawPort; + readonly camera: CameraPort; + readonly query: QueryPort; + readonly input: InputPort; + readonly effects: EffectsPort; + readonly credits: CreditsPort; + readonly quality: QualityPort; + /** The last presented frame as an encoded image. */ + capture(options?: CaptureOptions): Promise; + /** + * The last presented frame's pixels, synchronously, for loops that render + * a frame and read it at once (a share clip, a black-frame check). Call it + * right after `frame.renderNow()`. + */ + readPixels(options?: { crop?: ScreenRect; scale?: number }): ImageData; + /** + * Resolves once what is in view has loaded, or the timeout passes, or a + * load failed for good. `blockers` names what was still loading or + * failed (`'tiles'`, `'imagery:'`, `'models'`, ...); empty when settled. + */ + whenSettled(options?: { timeoutMs?: number }): Promise; + /** Follows the container's size. */ + resize(): void; + /** Frees caches and GPU memory nothing in view uses. */ + releaseUnused(): void; + on(event: 'error', cb: (error: Error) => void): Off; + /** + * The drawing context was lost, or came back. A renderer whose device + * cannot come back (WebGPU) never sends `contextRestored`; Core then makes + * a new renderer. + */ + on(event: 'contextLost' | 'contextRestored', cb: () => void): Off; + destroy(): void; +} diff --git a/src/render/stub/camera.js b/src/render/stub/camera.js new file mode 100644 index 000000000..1449b052d --- /dev/null +++ b/src/render/stub/camera.js @@ -0,0 +1,950 @@ +/** + * @module render/stub/camera + * @description The stub renderer's camera: exact poses for every camera + * command of the contract, flights on the stub's clock, follows, holds and + * events. It keeps the eye in a frame (the world, or a `lookAt` target's + * east-north-up frame) the way the default renderer does, so commands made + * in a target's frame behave the same. + */ +import { + EPSILON10, + IDENTITY, + PI_OVER_TWO, + TWO_PI, + acosClamped, + add, + clone, + cross, + distance, + dot, + eastNorthUp, + fromRadians, + framePoint, + frameVector, + geodeticSurfaceNormal, + headingOf, + headingPitchRollColumns, + inverseFramePoint, + inverseFrameVector, + isIdentity, + negativePiToPi, + magnitude, + negate, + normalize, + offsetFromHeadingPitchRange, + pitchOf, + rollOf, + rotate, + scale, + sub, + toCartographicRadians, + toDegrees, + toEcef, + toRadians, + vec, + zeroToTwoPi, +} from './math.js'; +import { geodesicAt } from './geodesic.js'; + +const DEFAULT_FOV = Math.PI / 3; +const MOVE_END_WAIT_MS = 500; +const MINIMUM_ZOOM = 100; +const DEFAULT_SPHERE_OFFSET = Object.freeze({ + heading: 0, + pitch: -Math.PI / 4, + range: 0, +}); + +const EASINGS = { + linear: (t) => t, + quadraticInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2), + cubicInOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2), + quinticInOut: (t) => (t < 0.5 ? 16 * t ** 5 : 1 - (-2 * t + 2) ** 5 / 2), + cubicOut: (t) => (t - 1) ** 3 + 1, +}; + +/** Above this height, a descending flight eases out rather than in and out. */ +const DESCENT_EASE_HEIGHT = 11500; +const ONE_DEGREE = Math.PI / 180; + +/** + * @param {object} options + * @param {() => {width: number, height: number}} options.viewport CSS size. + * @param {() => number} options.now The stub's clock, ms. + * @param {() => number} options.sceneTime Scene time, epoch ms. + * @param {(reason: string) => void} options.requestRender + * @param {(error: Error) => void} options.report + */ +export function createStubCamera({ + viewport, + now, + sceneTime, + requestRender, + report, + drawnPosition = () => null, +}) { + // Local frame values; world values are `frame` applied to them. + let frame = IDENTITY; + let position = vec(0, 0, 0); + let direction = vec(0, 0, -1); + let up = vec(0, 1, 0); + let right = vec(1, 0, 0); + let fovy = null; + let fov = DEFAULT_FOV; + const near = 1; + const far = 5e8; + + const listeners = { + changed: [], + moveStart: [], + moveEnd: [], + followChanged: [], + flightEnd: [], + }; + const commandHooks = []; + const holds = new Map(); + let holdSeq = 0; + let flight = null; + let followed = null; + let followFramed = false; + let lastPose = null; + let moving = false; + let lastMovedAt = 0; + + const controls = { + get enabled() { + return holds.size === 0; + }, + suspend(reason) { + const key = ++holdSeq; + holds.set(key, reason); + return () => holds.delete(key); + }, + enableTilt: true, + enableRotate: true, + enableZoom: true, + minDistance: 1, + maxDistance: Infinity, + inertiaSpin: 0.9, + inertiaTranslate: 0.9, + inertiaZoom: 0.8, + collision: 'none', + zoomFactor: 5, + pitchRange: [-Math.PI / 2, 0], + zoomInputs: ['wheel', 'pinch', 'rightDrag'], + }; + + function aspectRatio() { + const { width, height } = viewport(); + return width / height; + } + + function verticalFov() { + if (fovy !== null) return fovy; + const aspect = aspectRatio(); + return aspect <= 1 ? fov : 2 * Math.atan(Math.tan(fov * 0.5) / aspect); + } + + const positionWC = () => framePoint(frame, position); + const directionWC = () => normalize(frameVector(frame, direction)); + const upWC = () => normalize(frameVector(frame, up)); + const rightWC = () => normalize(frameVector(frame, right)); + + /** Move into a new frame, keeping the world pose. */ + function setFrame(next) { + const p = positionWC(); + const d = directionWC(); + const u = upWC(); + frame = next; + position = inverseFramePoint(frame, p); + direction = inverseFrameVector(frame, d); + up = inverseFrameVector(frame, u); + right = cross(direction, up); + } + + /** Heading, pitch and roll in the east-north-up frame at the eye. */ + function eyeAngles() { + return directionUpToAngles(positionWC(), { + direction: directionWC(), + up: upWC(), + }); + } + + function pose() { + const p = positionWC(); + const c = toCartographicRadians(p) ?? { lon: 0, lat: 0, height: 0 }; + const a = eyeAngles(); + return { + lon: toDegrees(c.lon), + lat: toDegrees(c.lat), + height: c.height, + heading: a.heading, + pitch: a.pitch, + roll: a.roll, + position: p, + direction: directionWC(), + up: upWC(), + right: rightWC(), + fovy: verticalFov(), + aspectRatio: aspectRatio(), + near, + far, + frameIsIdentity: isIdentity(frame), + }; + } + + function command(kind) { + for (const hook of commandHooks.slice()) { + if (!commandHooks.includes(hook)) continue; + try { + hook({ kind }); + } catch (error) { + report(error); + } + } + } + + /** Place the eye at `destination` (world) with `orientation` in its east-north-up frame. */ + function placeAt(destination, hpr) { + const kept = frame; + const enu = eastNorthUp(destination); + frame = enu; + position = vec(0, 0, 0); + const columns = headingPitchRollColumns(hpr.heading, hpr.pitch, hpr.roll); + direction = columns.direction; + up = columns.up; + right = cross(direction, up); + setFrame(kept); + } + + /** Orientation given as direction and up, as angles at `destination`. */ + function directionUpToAngles(destination, orientation) { + const enu = eastNorthUp(destination); + const d = inverseFrameVector(enu, orientation.direction); + const u = inverseFrameVector(enu, orientation.up); + const r = cross(d, u); + return { + heading: headingOf(d, u), + pitch: pitchOf(d), + roll: rollOf(d, u, r), + }; + } + + function resolveOrientation(destination, orientation = {}) { + if (orientation && 'direction' in orientation) + return directionUpToAngles(destination, orientation); + return { + heading: orientation?.heading ?? 0, + pitch: orientation?.pitch ?? -PI_OVER_TWO, + roll: orientation?.roll ?? 0, + }; + } + + /** + * The eye over a rectangle that keeps all of it in view, as the default + * renderer frames one: over the geodesic midpoint of its central meridian, + * far enough for its corners, edge centres and (across the equator) its + * equator points. + */ + function rectangleDestination(rect) { + const { north, south, west } = { + north: toRadians(rect.north), + south: toRadians(rect.south), + west: toRadians(rect.west), + }; + let east = toRadians(rect.east); + if (west > east) east += TWO_PI; + const lon = (west + east) * 0.5; + const lat = + south < -PI_OVER_TWO + ONE_DEGREE && north > PI_OVER_TWO - ONE_DEGREE + ? 0 + : geodesicAt(lon, north, lon, south, 0.5).lat; + const center = fromRadians(lon, lat, 0); + const dir = negate(geodeticSurfaceNormal(center)); + const r = normalize(cross(dir, vec(0, 0, 1))); + const u = cross(r, dir); + const tanPhi = Math.tan(verticalFov() * 0.5); + const tanTheta = aspectRatio() * tanPhi; + const points = [ + [west, north], + [east, south], + [east, north], + [west, south], + [lon, north], + [lon, south], + ]; + if (south < 0 && north > 0) points.push([west, 0], [east, 0]); + let d = -Infinity; + for (const [pointLon, pointLat] of points) { + const corner = sub(fromRadians(pointLon, pointLat, 0), center); + d = Math.max( + d, + Math.abs(dot(u, corner)) / tanPhi - dot(dir, corner), + Math.abs(dot(r, corner)) / tanTheta - dot(dir, corner), + ); + } + return add(center, scale(dir, -d)); + } + + function resolveDestination(destination, orientation) { + const target = + destination && 'rect' in destination + ? rectangleDestination(destination.rect) + : destination + ? toEcef(destination) + : positionWC(); + return { + destination: target, + angles: resolveOrientation(target, orientation), + }; + } + + function setView({ destination, orientation } = {}) { + command('setView'); + const resolved = resolveDestination(destination, orientation); + placeAt(resolved.destination, resolved.angles); + requestRender('camera'); + } + + function lookAtFrame(targetFrame, offset) { + setFrame(targetFrame); + position = offset; + direction = normalize(negate(position)); + right = cross(direction, vec(0, 0, 1)); + if (dot(right, right) < EPSILON10) right = vec(1, 0, 0); + right = normalize(right); + up = normalize(cross(right, direction)); + } + + function lookAt(target, offset) { + command('lookAt'); + lookAtFrame( + eastNorthUp(target), + offsetFromHeadingPitchRange(offset.heading, offset.pitch, offset.range), + ); + requestRender('camera'); + } + + function release() { + command('release'); + setFrame(IDENTITY); + requestRender('camera'); + } + + function finishFlight(outcome) { + const f = flight; + if (!f) return; + flight = null; + f.releaseHold(); + const cb = outcome === 'complete' ? f.onComplete : f.onCancel; + try { + cb?.(); + } catch (error) { + report(error); + } + f.resolve(outcome); + emit('flightEnd'); + } + + /** + * Whether the eye already stands at `destination` with `target` angles; + * like the reference camera, by its position in its current frame. + */ + function alreadyThere(destination, target) { + const p = position; + const a = eyeAngles(); + const close = (x, y) => + Math.abs(x - y) <= EPSILON10 * Math.max(1, Math.abs(x), Math.abs(y)); + const sameAngle = (x, y) => close(negativePiToPi(x), negativePiToPi(y)); + return ( + close(p.x, destination.x) && + close(p.y, destination.y) && + close(p.z, destination.z) && + sameAngle(a.heading, target.heading) && + sameAngle(a.pitch, target.pitch) && + sameAngle(a.roll, target.roll) + ); + } + + /** + * A flight that ends inside the call, as the reference camera's does: a + * duration of 0 or less lands at once, and one already at its destination + * completes without moving. + */ + function instantFlight(destination, angles, durationS, options) { + if (durationS !== undefined && durationS <= 0) { + placeAt(destination, angles); + requestRender('camera'); + } else if (!alreadyThere(destination, angles)) return null; + let resolve; + const done = new Promise((r) => (resolve = r)); + const handle = { done, cancel: () => {} }; + flight = { + handle, + releaseHold: () => {}, + onComplete: options?.onComplete, + resolve, + }; + finishFlight('complete'); + return handle; + } + + function startFlight(destination, angles, durationS, options) { + if (flight) finishFlight('cancelled'); + if (options?.endFrame === 'world') setFrame(IDENTITY); + const instant = instantFlight(destination, angles, durationS, options); + if (instant) return instant; + const startPos = positionWC(); + const duration = + durationS ?? + Math.min(3, Math.ceil(distance(position, destination) / 1e6) + 2); + const start = { pos: startPos, angles: eyeAngles() }; + const path = flightPath(destination, angles, options ?? {}); + let resolve; + const done = new Promise((r) => (resolve = r)); + const handle = { + done, + cancel: () => { + if (flight?.handle === handle) finishFlight('cancelled'); + }, + }; + flight = { + handle, + start, + destination, + angles, + durationMs: duration * 1000, + startedAt: null, + path, + easing: + EASINGS[options?.easing] ?? + (path.startHeight > path.endHeight && + path.startHeight > DESCENT_EASE_HEIGHT + ? EASINGS.cubicOut + : EASINGS.quinticInOut), + onComplete: options?.onComplete, + onCancel: options?.onCancel, + releaseHold: controls.suspend('flight'), + resolve, + }; + requestRender('flight'); + return handle; + } + + function flyTo(destination, options = {}) { + command('flyTo'); + const resolved = resolveDestination(destination, options.orientation); + return startFlight( + resolved.destination, + resolved.angles, + options.durationS, + options, + ); + } + + function flyToBounds(sphere, options = {}) { + command('flyToBounds'); + setFrame(IDENTITY); + const offset = { ...DEFAULT_SPHERE_OFFSET, ...(options.offset ?? {}) }; + if (!offset.range) { + const tanPhi = Math.tan(verticalFov() * 0.5); + const tanTheta = aspectRatio() * tanPhi; + offset.range = + sphere.radius === 0 + ? MINIMUM_ZOOM + : Math.max(sphere.radius / tanTheta, sphere.radius / tanPhi); + offset.range = Math.min( + controls.maxDistance, + Math.max(controls.minDistance, offset.range), + ); + } + const enu = eastNorthUp(sphere.center); + const eye = framePoint( + enu, + offsetFromHeadingPitchRange(offset.heading, offset.pitch, offset.range), + ); + const dir = normalize(sub(sphere.center, eye)); + let u = enu.z; + if (1 - Math.abs(dot(dir, u)) < 1e-6) + u = rotate(enu.y, dir, offset.heading); + const r = cross(dir, u); + u = normalize(cross(r, dir)); + return startFlight( + eye, + directionUpToAngles(eye, { direction: dir, up: u }), + options.durationS, + options, + ); + } + + /** One frame of the flight in progress. */ + function stepFlight() { + if (!flight) return; + const t = now(); + if (flight.startedAt === null) { + flight.startedAt = t; + if (flight.durationMs > 0) return; + } + const elapsed = t - flight.startedAt; + if (elapsed >= flight.durationMs) { + placeAt(flight.destination, flight.angles); + finishFlight('complete'); + return; + } + const s = flight.easing(elapsed / flight.durationMs); + const { path } = flight; + placeAt( + fromRadians( + path.startLon + (path.endLon - path.startLon) * s, + path.startLat + (path.endLat - path.startLat) * s, + path.height(s), + ), + { + heading: + path.startHeading + (flight.angles.heading - path.startHeading) * s, + pitch: path.pitch(s), + roll: path.startRoll + (flight.angles.roll - path.startRoll) * s, + }, + ); + requestRender('flight'); + } + + /** + * The reference flight path, fixed when the flight starts: longitude and + * latitude linear in eased time the short way round; height on an arc + * over the higher end when the two ends are far apart on screen (or + * below `maxHeight`); pitch linear, or straight down mid-flight when the + * arc tops `pitchAdjustHeight`. + */ + function flightPath(destination, angles, options) { + const from = toCartographicRadians(positionWC()); + const to = toCartographicRadians(destination); + let startLon = zeroToTwoPi(from.lon); + let endLon = zeroToTwoPi(to.lon); + if (startLon - endLon < -Math.PI) startLon += TWO_PI; + else if (startLon - endLon > Math.PI) endLon += TWO_PI; + const now = eyeAngles(); + const height = heightCurve( + destination, + from.height, + to.height, + options.maxHeight, + ); + return { + startLon, + endLon, + startLat: from.lat, + endLat: to.lat, + startHeight: from.height, + endHeight: to.height, + height, + pitch: pitchCurve( + now.pitch, + angles.pitch, + height, + options.pitchAdjustHeight, + ), + startHeading: forLerp(now.heading, angles.heading), + startRoll: forLerp(now.roll, angles.roll), + }; + } + + function heightCurve(destination, startHeight, endHeight, maxHeight) { + let altitude = maxHeight; + if (altitude === undefined) { + const diff = sub(position, destination); + const vertical = Math.abs(dot(diff, up)); + const horizontal = Math.abs(dot(diff, right)); + const tanHalf = Math.tan(0.5 * verticalFov()); + altitude = Math.min( + Math.max(vertical / (aspectRatio() * tanHalf), horizontal / tanHalf) * + 0.2, + 1e9, + ); + } + if (Math.max(startHeight, endHeight) < altitude) { + const power = 8; + const factor = 1e6; + const s = -(((altitude - startHeight) * factor) ** (1 / power)); + const e = ((altitude - endHeight) * factor) ** (1 / power); + return (t) => -((t * (e - s) + s) ** power) / factor + altitude; + } + return (t) => startHeight + (endHeight - startHeight) * t; + } + + function pitchCurve(startPitch, endPitch, height, adjustHeight) { + if (adjustHeight !== undefined && height(0.5) > adjustHeight) { + const h0 = height(0); + const h1 = height(1); + const mid = height(0.5); + return (t) => { + const h = height(t); + if (t <= 0.5) + return ( + startPitch + (-Math.PI / 2 - startPitch) * ((h - h0) / (mid - h0)) + ); + return ( + -Math.PI / 2 + (endPitch + Math.PI / 2) * (1 - (h - h1) / (mid - h1)) + ); + }; + } + return (t) => startPitch + (endPitch - startPitch) * t; + } + + /** The start angle moved by a turn so a straight blend takes the short way. */ + function forLerp(start, end) { + let a = Math.abs(start - TWO_PI) <= 1e-11 ? 0 : start; + if (end > a + Math.PI) a += TWO_PI; + else if (end < a - Math.PI) a -= TWO_PI; + return a; + } + + function cancelFlight() { + command('cancelFlight'); + finishFlight('cancelled'); + } + + function move(dir, metres) { + const axis = { + forward: direction, + back: negate(direction), + right, + left: negate(right), + up, + down: negate(up), + }[dir]; + position = add(position, scale(axis, metres)); + requestRender('camera'); + } + + function look(dir, radians) { + const [axis, sign] = { + left: [up, -1], + right: [up, 1], + up: [right, -1], + down: [right, 1], + }[dir]; + const a = clone(axis); + const angle = -(sign * radians); + direction = rotate(direction, a, angle); + up = rotate(up, a, angle); + right = rotate(right, a, angle); + requestRender('camera'); + } + + function rotateAround(axis, radians) { + const a = normalize(axis); + position = rotate(position, a, -radians); + direction = rotate(direction, a, -radians); + up = rotate(up, a, -radians); + right = cross(direction, up); + up = cross(right, direction); + requestRender('camera'); + } + + function zoom(metres) { + move('forward', metres); + } + + // --- Follow ---------------------------------------------------------------- + + /** Where the target is drawn, for a drawn item; otherwise its own answer. */ + const targetAt = (target, t, aheadMs = 0) => + (target.item && drawnPosition(target.item, aheadMs)) || + target.position(t + aheadMs); + + function followFrame(target, t) { + const at = targetAt(target, t); + if (!at) return null; + if (target.frame === 'autodetect') { + const ahead = targetAt(target, t, 1000); + const enu = eastNorthUp(at); + if (ahead && distance(ahead, at) > 0.01) { + const along = normalize(sub(ahead, at)); + const upAxis = enu.z; + const forward = normalize( + sub(along, scale(upAxis, dot(along, upAxis))), + ); + const side = cross(forward, upAxis); + return { x: side, y: forward, z: upAxis, origin: at }; + } + return enu; + } + return eastNorthUp(at); + } + + function follow(target) { + command('follow'); + followed = target; + followFramed = false; + if (!target) setFrame(IDENTITY); + emit('followChanged'); + requestRender('follow'); + } + + function stepFollow() { + if (!followed) return; + const t = sceneTime(); + const targetFrame = followFrame(followed, t); + if (!targetFrame) return; + if (!followFramed) { + const viewFrom = + typeof followed.viewFrom === 'function' + ? followed.viewFrom(t) + : followed.viewFrom; + let offset = clone(viewFrom); + const range = magnitude(offset); + const minRange = Math.max( + followed.minRange ?? 0, + followed.boundingRadius ?? 0, + ); + if (range < minRange && range > 0) + offset = scale(offset, minRange / range); + lookAtFrame(targetFrame, offset); + followFramed = true; + return; + } + // Keep the eye's local offset in the moving frame. + const keptPosition = position; + const keptDirection = direction; + const keptUp = up; + frame = targetFrame; + position = keptPosition; + direction = keptDirection; + up = keptUp; + right = cross(direction, up); + } + + // --- Events ---------------------------------------------------------------- + + function emit(event) { + for (const entry of listeners[event].slice()) { + if (!entry.live) continue; + try { + entry.cb(); + } catch (error) { + report(error); + } + } + } + + function on(event, cb, options = {}) { + const entry = { + cb, + live: true, + threshold: options.threshold ?? 0.5, + ref: null, + }; + listeners[event].push(entry); + return () => { + entry.live = false; + const list = listeners[event]; + const i = list.indexOf(entry); + if (i >= 0) list.splice(i, 1); + }; + } + + /** How far the view moved from a listener's reference, as a fraction. */ + const angleDelta = (a, b) => { + const d = Math.abs(a - b) % TWO_PI; + return (d > Math.PI ? TWO_PI - d : d) / Math.PI; + }; + + /** + * Per listener, the reference camera's formula: heading and roll each + * against their own last reported value, then direction and position + * against the last reported view. Each can report in the same frame. + */ + function stepChanged() { + const list = listeners.changed; + if (!list.length) return; + const current = pose(); + for (const entry of list.slice()) { + if (!entry.live) continue; + const t = entry.threshold; + entry.heading ??= current.heading; + entry.roll ??= current.roll; + const headingF = angleDelta(entry.heading, current.heading); + const rollF = angleDelta(entry.roll, current.roll); + if (headingF > t) entry.heading = current.heading; + if (rollF > t) entry.roll = current.roll; + if (headingF > t || rollF > t) deliver(entry); + if (!entry.view) { + entry.view = { + position: current.position, + direction: current.direction, + }; + continue; + } + const dirF = + acosClamped(dot(current.direction, entry.view.direction)) / + (current.fovy * 0.5); + const heightF = + distance(current.position, entry.view.position) / current.height; + if (dirF > t || heightF > t) { + entry.view = { + position: current.position, + direction: current.direction, + }; + deliver(entry); + } + } + } + + function deliver(entry) { + if (!entry.live) return; + try { + entry.cb(); + } catch (error) { + report(error); + } + } + + function sameView(a, b) { + return ( + a && + b && + distance(a.position, b.position) === 0 && + dot(a.direction, b.direction) === dot(a.direction, a.direction) && + dot(a.up, b.up) === dot(a.up, a.up) + ); + } + + function stepMoveDetection() { + const current = { + position: positionWC(), + direction: directionWC(), + up: upWC(), + }; + const changed = lastPose && !sameView(lastPose, current); + lastPose = current; + const t = now(); + if (changed) { + lastMovedAt = t; + if (!moving) { + moving = true; + emit('moveStart'); + } + } else if (moving && !followed && t - lastMovedAt >= MOVE_END_WAIT_MS) { + moving = false; + emit('moveEnd'); + } + } + + function matrices() { + const p = positionWC(); + const d = directionWC(); + const u = upWC(); + const r = rightWC(); + // Column-major view matrix: rows are right, up and -direction. + const view = new Float64Array([ + r.x, + u.x, + -d.x, + 0, + r.y, + u.y, + -d.y, + 0, + r.z, + u.z, + -d.z, + 0, + -dot(r, p), + -dot(u, p), + dot(d, p), + 1, + ]); + const top = near * Math.tan(verticalFov() * 0.5); + const rightEdge = aspectRatio() * top; + const projection = new Float64Array([ + near / rightEdge, + 0, + 0, + 0, + 0, + near / top, + 0, + 0, + 0, + 0, + -(far + near) / (far - near), + -1, + 0, + 0, + (-2 * far * near) / (far - near), + 0, + ]); + return { view, projection }; + } + + const followOffset = { + get: () => (followed ? clone(position) : null), + set: (offset) => { + if (!followed) return; + lookAtFrame(frame, clone(offset)); + requestRender('follow'); + }, + }; + + const port = { + pose, + setView, + setFov({ vertical }) { + fovy = vertical; + requestRender('camera'); + }, + viewRectangle: () => null, + flyTo, + flyToBounds, + cancelFlight, + get flying() { + return flight !== null; + }, + lookAt, + release, + move, + look, + rotateAround, + zoom, + follow, + get followed() { + return followed; + }, + get followFramed() { + return followFramed; + }, + followOffset, + controls, + on, + onCommand(cb) { + commandHooks.push(cb); + return () => { + const i = commandHooks.indexOf(cb); + if (i >= 0) commandHooks.splice(i, 1); + }; + }, + matrices, + }; + + return { + port, + /** Ends a flight in progress as cancelled, without announcing a command. */ + endFlight: () => finishFlight('cancelled'), + /** Advance camera steps of one frame, in the contract's order. */ + steps: { stepFlight, stepChanged, stepFollow, stepMoveDetection }, + eye: () => ({ + position: positionWC(), + direction: directionWC(), + up: upWC(), + right: rightWC(), + fovy: verticalFov(), + aspectRatio: aspectRatio(), + near, + }), + holds: () => [...holds.values()], + setViewRectangle(fn) { + port.viewRectangle = fn; + }, + }; +} diff --git a/src/render/stub/geodesic.js b/src/render/stub/geodesic.js new file mode 100644 index 000000000..12fac64d3 --- /dev/null +++ b/src/render/stub/geodesic.js @@ -0,0 +1,164 @@ +/** + * @module render/stub/geodesic + * @description The point a fraction of the way along the WGS84 geodesic + * between two places: Vincenty's inverse formula, then the series the + * default renderer interpolates with, term for term, so a rectangle's + * centre matches its to floating-point noise. + */ +import { TWO_PI, WGS84 } from './math.js'; + +const EPSILON12 = 1e-12; +const A = WGS84.radii.x; +const B = WGS84.radii.z; +const F = (A - B) / A; + +function deltaLambda(sineAlpha, cos2Alpha, sigma, sinSigma, cosSigma, cos2Sm) { + const C = (F * cos2Alpha * (4 + F * (4 - 3 * cos2Alpha))) / 16; + return ( + (1 - C) * + F * + sineAlpha * + (sigma + C * sinSigma * (cos2Sm + C * cosSigma * (2 * cos2Sm * cos2Sm - 1))) + ); +} + +/** Distance, start heading and u² of the geodesic from (lon1, lat1) to (lon2, lat2), radians. */ +function inverse(lon1, lat1, lon2, lat2) { + const l = lon2 - lon1; + const u1 = Math.atan((1 - F) * Math.tan(lat1)); + const u2 = Math.atan((1 - F) * Math.tan(lat2)); + const cosU1 = Math.cos(u1); + const sinU1 = Math.sin(u1); + const cosU2 = Math.cos(u2); + const sinU2 = Math.sin(u2); + const cc = cosU1 * cosU2; + const cs = cosU1 * sinU2; + const ss = sinU1 * sinU2; + const sc = sinU1 * cosU2; + let lambda = l; + let lambdaDot = TWO_PI; + let cosLambda; + let sinLambda; + let sigma; + let cosSigma; + let sinSigma; + let cos2Alpha; + let cos2Sm; + do { + cosLambda = Math.cos(lambda); + sinLambda = Math.sin(lambda); + const temp = cs - sc * cosLambda; + sinSigma = Math.sqrt(cosU2 * cosU2 * sinLambda * sinLambda + temp * temp); + cosSigma = ss + cc * cosLambda; + sigma = Math.atan2(sinSigma, cosSigma); + let sineAlpha; + if (sinSigma === 0) { + sineAlpha = 0; + cos2Alpha = 1; + } else { + sineAlpha = (cc * sinLambda) / sinSigma; + cos2Alpha = 1 - sineAlpha * sineAlpha; + } + lambdaDot = lambda; + cos2Sm = cosSigma - (2 * ss) / cos2Alpha; + if (!isFinite(cos2Sm)) cos2Sm = 0; + lambda = + l + deltaLambda(sineAlpha, cos2Alpha, sigma, sinSigma, cosSigma, cos2Sm); + } while (Math.abs(lambda - lambdaDot) > EPSILON12); + const uSquared = (cos2Alpha * (A * A - B * B)) / (B * B); + const bigA = + 1 + + (uSquared * (4096 + uSquared * (uSquared * (320 - 175 * uSquared) - 768))) / + 16384; + const bigB = + (uSquared * (256 + uSquared * (uSquared * (74 - 47 * uSquared) - 128))) / + 1024; + const cos2Sm2 = cos2Sm * cos2Sm; + const deltaSigma = + bigB * + sinSigma * + (cos2Sm + + (bigB * + (cosSigma * (2 * cos2Sm2 - 1) - + (bigB * cos2Sm * (4 * sinSigma * sinSigma - 3) * (4 * cos2Sm2 - 3)) / + 6)) / + 4); + return { + distance: B * bigA * (sigma - deltaSigma), + startHeading: Math.atan2(cosU2 * sinLambda, cs - sc * cosLambda), + uSquared, + }; +} + +/** + * The longitude and latitude (radians) `fraction` of the way along the + * geodesic from (lon1, lat1) to (lon2, lat2). + */ +export function geodesicAt(lon1, lat1, lon2, lat2, fraction) { + const { distance, startHeading, uSquared } = inverse(lon1, lat1, lon2, lat2); + const cosH = Math.cos(startHeading); + const sinH = Math.sin(startHeading); + const tanU = (1 - F) * Math.tan(lat1); + const cosU = 1 / Math.sqrt(1 + tanU * tanU); + const sinU = cosU * tanU; + const sigma1 = Math.atan2(tanU, cosH); + const sineAlpha = cosU * sinH; + const cos2Alpha = 1 - sineAlpha * sineAlpha; + const cosAlpha = Math.sqrt(cos2Alpha); + const u2 = uSquared / 4; + const u4 = u2 * u2; + const u6 = u4 * u2; + const u8 = u4 * u4; + const a0 = 1 + u2 - (3 * u4) / 4 + (5 * u6) / 4 - (175 * u8) / 64; + const a1 = 1 - u2 + (15 * u4) / 8 - (35 * u6) / 8; + const a2 = 1 - 3 * u2 + (35 * u4) / 4; + const a3 = 1 - 5 * u2; + const distanceRatio = + a0 * sigma1 - + (a1 * Math.sin(2 * sigma1) * u2) / 2 - + (a2 * Math.sin(4 * sigma1) * u4) / 16 - + (a3 * Math.sin(6 * sigma1) * u6) / 48 - + (Math.sin(8 * sigma1) * 5 * u8) / 512; + + const s = distanceRatio + (distance * fraction) / B; + const cos2S = Math.cos(2 * s); + const cos4S = Math.cos(4 * s); + const cos6S = Math.cos(6 * s); + const sin2S = Math.sin(2 * s); + const sin4S = Math.sin(4 * s); + const sin6S = Math.sin(6 * s); + const sin8S = Math.sin(8 * s); + const s2 = s * s; + const s3 = s * s2; + let sigma = + (2 * s3 * u8 * cos2S) / 3 + + s * + (1 - + u2 + + (7 * u4) / 4 - + (15 * u6) / 4 + + (579 * u8) / 64 - + (u4 - (15 * u6) / 4 + (187 * u8) / 16) * cos2S - + ((5 * u6) / 4 - (115 * u8) / 16) * cos4S - + (29 * u8 * cos6S) / 16) + + (u2 / 2 - u4 + (71 * u6) / 32 - (85 * u8) / 16) * sin2S + + ((5 * u4) / 16 - (5 * u6) / 4 + (383 * u8) / 96) * sin4S - + s2 * ((u6 - (11 * u8) / 2) * sin2S + (5 * u8 * sin4S) / 2) + + ((29 * u6) / 96 - (29 * u8) / 16) * sin6S + + (539 * u8 * sin8S) / 1536; + + const theta = Math.asin(Math.sin(sigma) * cosAlpha); + const lat = Math.atan((A / B) * Math.tan(theta)); + sigma -= sigma1; + const cos2Sm = Math.cos(2 * sigma1 + sigma); + const sinSigma = Math.sin(sigma); + const cosSigma = Math.cos(sigma); + const lambda = Math.atan2( + sinSigma * sinH, + cosU * cosSigma - sinU * sinSigma * cosH, + ); + const l = + lambda - + deltaLambda(sineAlpha, cos2Alpha, sigma, sinSigma, cosSigma, cos2Sm); + return { lon: lon1 + l, lat }; +} diff --git a/src/render/stub/harness.js b/src/render/stub/harness.js new file mode 100644 index 000000000..969890f09 --- /dev/null +++ b/src/render/stub/harness.js @@ -0,0 +1,27 @@ +/** + * @module render/stub/harness + * @description The stub as the conformance suite's renderer under test, for + * the Node test and the browser page alike. + */ +import { DEFAULT_TOLERANCE } from '../conformance/index.js'; +import { createStubRenderer } from './index.js'; +import { toEcef } from './math.js'; + +/** @returns {import('../conformance/index.js').ConformanceHarness} */ +export function createStubHarness(name = 'stub') { + return { + name, + create: async ({ viewport, capabilities }) => + createStubRenderer({ viewport, capabilities }), + frame: (r) => { + r.testing.tick(); + }, + now: (r) => r.testing.now(), + toEcef, + tolerance: DEFAULT_TOLERANCE, + flightPaths: 'trace', + pointer: (r, type, x, y, extra) => r.testing.pointer(type, x, y, extra), + scriptHeights: (r, fn) => r.testing.setHeights(fn), + wallTime: (r) => r.testing.wallTime(), + }; +} diff --git a/src/render/stub/index.js b/src/render/stub/index.js new file mode 100644 index 000000000..26507eee6 --- /dev/null +++ b/src/render/stub/index.js @@ -0,0 +1,1245 @@ +/** + * @module render/stub + * @description A renderer that draws nothing: every port of the contract in + * memory, with exact camera poses, a scripted clock, picks against what was + * drawn (or a scripted table), scripted heights, and a log of calls. + * + * Tests across Core use it in place of a real renderer, and the conformance + * suite runs against it, so the stub is itself checked against the contract. + * Nothing runs on its own: `testing.tick()` and `testing.advance()` drive the + * loop on a virtual clock, one frame at a time, in the contract's order. + */ +import { CAPABILITIES, CONTRACT_VERSION } from '../contract/index.js'; +import { createStubCamera } from './camera.js'; +import { motionPosition } from './motion.js'; +import { computeViewRectangle } from './viewRectangle.js'; +import { + add, + dot, + magnitude, + normalize, + scale, + sub, + toCartographicRadians, + toDegrees, + toEcef, + vec, + WGS84, +} from './math.js'; + +const FRAME_MS = 1000 / 60; + +/** Every setting the stub accepts, with its descriptor. */ +const KNOBS = Object.freeze([ + { key: 'pixelRatio', kind: 'number', min: 0, max: 4, default: 0, cost: 1 }, + { key: 'msaa', kind: 'enum', options: [0, 2, 4, 8], default: 4, cost: 0.4 }, + { + key: 'tiles3dDetail', + kind: 'number', + min: 0, + max: 1, + default: 1, + cost: 0.8, + couples: ['lower detail lowers height accuracy'], + }, + { + key: 'terrainDetail', + kind: 'number', + min: 0, + max: 1, + default: 1, + cost: 0.4, + }, + { + key: 'imageryDetail', + kind: 'number', + min: 0, + max: 1, + default: 1, + cost: 0.3, + }, + { key: 'particles', kind: 'number', min: 0, max: 1, default: 1, cost: 0.3 }, + { key: 'targetFps', kind: 'number', min: 0, max: 240, default: 0, cost: 0.2 }, +]); + +function uncaught(error) { + if (typeof globalThis.reportError === 'function') + globalThis.reportError(error); + else + queueMicrotask(() => { + throw error; + }); +} + +/** + * @param {object} [options] + * @param {Iterable} [options.capabilities] Default: all. + * @param {{width: number, height: number}} [options.viewport] CSS size; default 1280 × 720. + * @param {number} [options.time] Scene time, epoch ms. + * @returns {import('../contract/renderer').Renderer & {testing: StubTesting}} + */ +export function createStubRenderer(options = {}) { + const capabilities = new Set(options.capabilities ?? CAPABILITIES); + const viewport = { width: 1280, height: 720, ...(options.viewport ?? {}) }; + const calls = []; + const log = (port, member, args) => calls.push({ port, member, args }); + const rendererListeners = { error: [], contextLost: [], contextRestored: [] }; + + let clock = 0; + let sceneTime = options.time ?? Date.UTC(2026, 0, 1); + // Wall time is the virtual clock from a fixed epoch, so live motion is scripted too. + const wallEpoch = options.wallTime ?? Date.UTC(2026, 0, 1); + const wallTime = () => wallEpoch + clock; + let frameCount = 0; + let rendered = 0; + let suspended = false; + let targetFps = 0; + let destroyed = false; + const pendingReasons = []; + const continuous = new Map(); + let holdSeq = 0; + const phaseListeners = { preUpdate: [], preRender: [], postRender: [] }; + const onceWaiters = []; + const onceTimers = new Set(); + const stepLog = []; + + /** + * Hands a listener's throw to the `error` listeners. With none, it goes to + * the platform's `reportError` (or is rethrown on a microtask), as a + * browser reports an event listener's exception: loud, but the frame and + * the other listeners carry on. + */ + function report(error) { + const errorListeners = rendererListeners.error; + if (!errorListeners.length) return uncaught(error); + for (const entry of errorListeners.slice()) { + // One removed during delivery does not run, as everywhere. + if (!entry.live) continue; + try { + entry.cb(error); + } catch (thrown) { + uncaught(thrown); + } + } + } + + /** + * Calls each listener in `list` (callbacks, or `{cb, live}` entries) with + * the contract's rules: one removed during delivery does not run, one + * added during delivery waits for the next event, and one that throws is + * reported while the rest still run. + */ + function deliver(list, ...args) { + for (const entry of list.slice()) { + const fn = typeof entry === 'function' ? entry : entry.cb; + if (typeof entry === 'function' ? !list.includes(entry) : !entry.live) + continue; + try { + fn(...args); + } catch (error) { + report(error); + } + } + } + + function requestRender(reason) { + if (!pendingReasons.includes(reason)) pendingReasons.push(reason); + } + + const camera = createStubCamera({ + viewport: () => viewport, + now: () => clock, + sceneTime: () => sceneTime, + requestRender, + report, + drawnPosition: (ref, aheadMs) => drawnPosition(ref, aheadMs), + }); + camera.setViewRectangle(viewRectangle); + camera.port.setView({ + destination: { lon: -97.7431, lat: 30.2672, height: 15000000 }, + }); + pendingReasons.length = 0; + + // --- Frame ----------------------------------------------------------------- + + function runPhase(phase, t) { + deliver(phaseListeners[phase], t); + } + + const signalListeners = []; + + function tick() { + if (destroyed || suspended) return false; + clock += FRAME_MS; + frameCount += 1; + stepLog.length = 0; + const step = (name) => stepLog.push(name); + step('flight'); + camera.steps.stepFlight(); + step('input'); + step('changed'); + camera.steps.stepChanged(); + step('follow'); + camera.steps.stepFollow(); + const rendering = pendingReasons.length > 0 || continuous.size > 0; + const t = { + frame: frameCount, + time: sceneTime, + now: clock, + dt: FRAME_MS / 1000, + rendering, + }; + step('preUpdate'); + runPhase('preUpdate', t); + step('moveDetection'); + camera.steps.stepMoveDetection(); + if (!rendering) return false; + const requested = pendingReasons.length > 0; + pendingReasons.length = 0; + step('preRender'); + runPhase('preRender', t); + step('render'); + rendered += 1; + step('postRender'); + runPhase('postRender', t); + for (const waiter of onceWaiters.splice(0)) waiter(); + const signals = { + cpuMs: 0, + pixelRatio: qualitySettings.pixelRatio || 1, + width: viewport.width, + height: viewport.height, + mode: 'demand', + requested, + holds: [...continuous.values()], + drawCalls: sets.size, + }; + deliver(signalListeners, signals); + return true; + } + + const frame = { + on(phase, cb) { + const entry = { cb, live: true }; + phaseListeners[phase].push(entry); + return () => { + entry.live = false; + const list = phaseListeners[phase]; + const i = list.indexOf(entry); + if (i >= 0) list.splice(i, 1); + }; + }, + requestRender(reason) { + requestRender(String(reason)); + }, + holdContinuous(reason) { + const key = ++holdSeq; + continuous.set(key, String(reason)); + return () => continuous.delete(key); + }, + mode: () => 'demand', + diagnostics: () => ({ + mode: 'demand', + frames: frameCount, + rendered, + pendingReasons: pendingReasons.slice(), + holds: [...continuous.values()], + suspended, + targetFps, + }), + suspend(hidden) { + suspended = Boolean(hidden); + }, + renderNow() { + requestRender('renderNow'); + tick(); + }, + renderOnce({ timeoutMs } = {}) { + requestRender('renderOnce'); + return new Promise((resolve, reject) => { + let timer = null; + const waiter = () => { + clearTimeout(timer); + resolve(); + }; + onceWaiters.push(waiter); + if (timeoutMs !== undefined) + timer = setTimeout(() => { + const i = onceWaiters.indexOf(waiter); + if (i >= 0) onceWaiters.splice(i, 1); + reject(new Error('renderOnce timed out')); + }, timeoutMs); + onceTimers.add(() => clearTimeout(timer)); + }); + }, + setTargetFps(fps) { + targetFps = fps; + }, + setTime(ms) { + sceneTime = ms; + requestRender('time'); + }, + time: () => sceneTime, + }; + + // --- Draw ------------------------------------------------------------------ + + /** @type {Map, columns: object|null, show: boolean, ready: Function[]}>} */ + const sets = new Map(); + const atlases = new Map(); + + function makeSet(kind, spec) { + if (!spec?.id) throw new TypeError('A draw set needs an id'); + if (sets.has(spec.id)) + throw new Error(`Draw set ${spec.id} already exists`); + const record = { + kind, + spec: { ...spec }, + items: new Map(), + show: spec.show ?? true, + readyListeners: [], + }; + sets.set(spec.id, record); + log('draw', kind, [spec]); + const ready = (ids) => { + if (kind !== 'models') return; + for (const id of ids) deliver(record.readyListeners, id); + }; + const handle = { + id: spec.id, + set(items) { + record.items.clear(); + for (const item of items) + record.items.set(String(item.id), { ...item }); + requestRender('draw'); + ready(items.map((item) => String(item.id))); + }, + upsert(items) { + for (const item of items) + record.items.set(String(item.id), { ...item }); + requestRender('draw'); + ready(items.map((item) => String(item.id))); + }, + remove(ids) { + for (const id of ids) record.items.delete(String(id)); + requestRender('draw'); + }, + clear() { + record.items.clear(); + requestRender('draw'); + }, + setOptions(patch) { + Object.assign(record.spec, patch); + if ('show' in patch) record.show = patch.show; + requestRender('draw'); + }, + size: () => record.items.size, + get show() { + return record.show; + }, + set show(value) { + record.show = Boolean(value); + requestRender('draw'); + }, + on(event, cb) { + if (event !== 'ready') return () => {}; + const entry = { cb, live: true }; + record.readyListeners.push(entry); + return () => { + entry.live = false; + }; + }, + dispose() { + // A newer set under the same id is not this handle's to remove. + if (sets.get(spec.id) === record) sets.delete(spec.id); + requestRender('draw'); + }, + }; + if (capabilities.has('columns')) + // Columns become rows like any other, so remove, size and picks see them. + handle.setColumns = (columns) => { + const next = new Map(); + columns.ids.forEach((id, i) => { + const key = String(id); + const row = { ...(record.items.get(key) ?? { id: key }) }; + const p = columns.positions; + if (p) row.pos = vec(p[i * 3], p[i * 3 + 1], p[i * 3 + 2]); + if (columns.courses) row.course = columns.courses[i]; + if (columns.shown) row.show = columns.shown[i] !== 0; + if (columns.scales) row.scale = columns.scales[i]; + if (columns.alphas) row.alpha = columns.alphas[i]; + if (columns.colors) + row.color = [0, 1, 2, 3].map( + (k) => columns.colors[i * 4 + k] / 255, + ); + if (columns.meta) row.meta = columns.meta[i]; + next.set(key, row); + }); + record.items = next; + requestRender('draw'); + }; + return handle; + } + + function simpleHandle(kind, spec) { + const record = { + kind, + spec: { ...spec }, + items: new Map(), + show: true, + readyListeners: [], + }; + sets.set(spec.id, record); + log('draw', kind, [spec]); + requestRender('draw'); + const handle = { + get show() { + return record.show; + }, + set show(value) { + record.show = Boolean(value); + requestRender('draw'); + }, + dispose() { + // A newer set under the same id is not this handle's to remove. + if (sets.get(spec.id) === record) sets.delete(spec.id); + requestRender('draw'); + }, + }; + return handle; + } + + const draw = { + icons: (spec) => makeSet('icons', spec), + points: (spec) => makeSet('points', spec), + lines: (spec) => makeSet('lines', spec), + shapes: (spec) => makeSet('shapes', spec), + atlas(id, images) { + atlases.set(id, { ...images }); + log('draw', 'atlas', [id]); + }, + }; + if (capabilities.has('models')) + draw.models = (spec) => makeSet('models', spec); + if (capabilities.has('quads')) draw.quads = (spec) => makeSet('quads', spec); + if (capabilities.has('meshes')) + draw.meshes = (spec) => makeSet('meshes', spec); + if (capabilities.has('agents')) + draw.agents = (spec) => makeSet('agents', spec); + if (capabilities.has('fields')) + draw.field = (spec) => { + const handle = simpleHandle('field', spec); + handle.update = (patch) => { + Object.assign(sets.get(spec.id).spec, patch); + requestRender('draw'); + }; + return handle; + }; + if (capabilities.has('celestial')) + draw.celestial = (spec) => simpleHandle('celestial', spec); + + // --- Query ----------------------------------------------------------------- + + function eye() { + return camera.eye(); + } + + function project(position) { + const e = eye(); + const v = sub(position, e.position); + const depth = dot(v, e.direction); + if (depth <= 0) return null; + const right = e.right; + const tanPhi = Math.tan(e.fovy * 0.5); + const tanTheta = e.aspectRatio * tanPhi; + const x = dot(v, right) / (depth * tanTheta); + const y = dot(v, e.up) / (depth * tanPhi); + return { + x: ((x + 1) / 2) * viewport.width, + y: ((1 - y) / 2) * viewport.height, + }; + } + + function pickRay(x, y) { + const e = eye(); + const tanPhi = Math.tan(e.fovy * 0.5); + const tanTheta = e.aspectRatio * tanPhi; + const nx = (2 / viewport.width) * x - 1; + const ny = (2 / viewport.height) * (viewport.height - y) - 1; + const near = e.near; + const target = add( + add( + add(e.position, scale(e.direction, near)), + scale(e.right, nx * near * tanTheta), + ), + scale(e.up, ny * near * tanPhi), + ); + return { + origin: e.position, + direction: normalize(sub(target, e.position)), + }; + } + + /** Nearest hit of a ray on the ellipsoid. */ + function rayEllipsoid(ray) { + const r = WGS84.oneOverRadii; + const q = vec(ray.origin.x * r.x, ray.origin.y * r.y, ray.origin.z * r.z); + const w = vec( + ray.direction.x * r.x, + ray.direction.y * r.y, + ray.direction.z * r.z, + ); + const a = dot(w, w); + const b = 2 * dot(q, w); + const c = dot(q, q) - 1; + const disc = b * b - 4 * a * c; + if (disc < 0) return null; + const sq = Math.sqrt(disc); + const t0 = (-b - sq) / (2 * a); + const t1 = (-b + sq) / (2 * a); + const t = t0 >= 0 ? t0 : t1 >= 0 ? t1 : null; + if (t === null) return null; + return add(ray.origin, scale(ray.direction, t)); + } + + function isOccluded(position) { + const e = eye(); + const r = WGS84.oneOverRadii; + const cv = vec(e.position.x * r.x, e.position.y * r.y, e.position.z * r.z); + const vh = dot(cv, cv) - 1; + const t = vec(position.x * r.x, position.y * r.y, position.z * r.z); + const vt = sub(t, cv); + const vtDotVc = -dot(vt, cv); + if (vh < 0) return vtDotVc > 0; + return vtDotVc > vh && (vtDotVc * vtDotVc) / dot(vt, vt) > vh; + } + + let scriptedPicks = null; + const heightCache = new Map(); + let heightSource = null; + const cacheKey = (p, against = 'surface') => + `${against}:${p.lon.toFixed(7)},${p.lat.toFixed(7)}`; + + /** The time a set's motion segments are evaluated at. */ + function motionTime(set) { + const clockSpec = set.spec.motionClock; + return clockSpec?.kind === 'scene' + ? sceneTime + : wallTime() - (clockSpec?.delayMs ?? 0); + } + + function itemPosition(set, item, aheadMs = 0) { + if (!item.pos) return null; + const base = toEcef(item.pos); + if ( + set.kind === 'icons' && + item.motion && + capabilities.has('motionSegments') + ) + return motionPosition(item.motion, base, motionTime(set) + aheadMs); + return base; + } + + /** Where a drawn item is now (or `aheadMs` from now), or null. */ + function drawnPosition(ref, aheadMs = 0) { + const set = sets.get(ref.set); + const item = set?.items.get(String(ref.id)); + return set && item ? itemPosition(set, item, aheadMs) : null; + } + + function hitsAt(x, y, win = {}) { + if (scriptedPicks) return scriptedPicks(x, y, win) ?? []; + const halfW = (win.width ?? 3) / 2; + const halfH = (win.height ?? 3) / 2; + const hits = []; + for (const [setId, set] of sets) { + if (!set.show || set.spec.pickable === false) continue; + if ( + set.kind !== 'icons' && + set.kind !== 'points' && + set.kind !== 'models' + ) + continue; + for (const item of set.items.values()) { + if (item.show === false || item.pickable === false) continue; + const p = itemPosition(set, item); + if (!p || isOccluded(p)) continue; + const s = project(p); + if (!s) continue; + const size = item.pickProxy?.size ?? + item.size ?? [item.pixelSize ?? 10, item.pixelSize ?? 10]; + if ( + Math.abs(s.x - x) <= size[0] / 2 + halfW && + Math.abs(s.y - y) <= size[1] / 2 + halfH + ) + hits.push({ + set: setId, + id: item.id, + meta: item.meta, + surface: false, + position: p, + depth: magnitude(sub(p, eye().position)), + }); + } + } + hits.sort((a, b) => a.depth - b.depth); + for (const hit of hits) delete hit.depth; + return hits; + } + + function surfaceHit(x, y) { + const position = rayEllipsoid(pickRay(x, y)); + return position + ? { set: '', id: '', meta: undefined, surface: true, position } + : null; + } + + /** + * The geodetic box of the ellipsoid under the screen's border, or null + * when any of the border looks past the globe. A pole in view widens it to + * that pole and every longitude. + */ + function viewRectangle() { + return computeViewRectangle(camera.port.pose(), viewport, (x, y) => + rayEllipsoid(pickRay(x, y)), + ); + } + + function lookupHeight(point, against) { + const key = cacheKey(point, against); + if (heightCache.has(key)) return heightCache.get(key); + if (heightSource) return heightSource(point, against); + return null; + } + + const query = { + pick(x, y, win) { + return hitsAt(x, y, win)[0] ?? surfaceHit(x, y); + }, + drillPick(x, y, opts = {}) { + const hits = hitsAt(x, y, opts); + return opts.limit === undefined ? hits : hits.slice(0, opts.limit); + }, + pickPosition(x, y) { + if (!capabilities.has('pickPosition')) return null; + return rayEllipsoid(pickRay(x, y)); + }, + pickEllipsoid: (x, y) => rayEllipsoid(pickRay(x, y)), + pickGlobe: (ray) => rayEllipsoid(ray), + pickRay, + project, + projectMany(source, out) { + for (let i = 0, j = 0; i + 2 < source.length; i += 3, j += 2) { + const s = project(vec(source[i], source[i + 1], source[i + 2])); + out[j] = s ? s.x : NaN; + out[j + 1] = s ? s.y : NaN; + } + }, + isOccluded, + sampleHeightNow(point, opts = {}) { + if (!capabilities.has('sampleHeight')) return null; + const key = cacheKey(point, opts.against); + return heightCache.has(key) ? heightCache.get(key) : null; + }, + sampleHeightNowMany(points, out, opts = {}) { + for (let i = 0; i < out.length; i++) { + const height = query.sampleHeightNow( + { lon: points[i * 2], lat: points[i * 2 + 1] }, + opts, + ); + out[i] = height ?? NaN; + } + }, + async sampleHeight(points, opts = {}) { + if (!capabilities.has('sampleHeight')) return points.map(() => null); + return points.map((point) => { + const height = lookupHeight(point, opts.against); + if (height === null || height === undefined) return null; + heightCache.set(cacheKey(point, opts.against), height); + const lod = + opts.mostDetailed && capabilities.has('finestHeights') + ? 'finest' + : 'visible'; + return { height, confidence: 1, source: 'terrain', lod }; + }); + }, + async clampToSurface(positions, opts = {}) { + const points = positions.map((p) => { + const c = toCartographicRadians(p); + return { lon: toDegrees(c.lon), lat: toDegrees(c.lat) }; + }); + const samples = await query.sampleHeight(points, opts); + return samples.map((sample, i) => + sample ? toEcef({ ...points[i], height: sample.height }) : null, + ); + }, + async rayCast(ray) { + if (!capabilities.has('rayCast')) return null; + return rayEllipsoid(ray); + }, + }; + + // --- Input ----------------------------------------------------------------- + + const pointerListeners = []; + let currentLease = null; + const cameraHolds = new Map(); + const behaviourHolds = { select: 0, dblclickFollow: 0 }; + + function makeBehaviour(name) { + return { + get enabled() { + return behaviourHolds[name] === 0; + }, + suppress() { + behaviourHolds[name] += 1; + let released = false; + return () => { + if (released) return; + released = true; + behaviourHolds[name] -= 1; + }; + }, + }; + } + + const leaseOf = (entry) => + typeof entry.lease === 'function' ? entry.lease() : entry.lease; + + const input = { + on(type, cb, opts = {}) { + const entry = { type, cb, lease: opts.lease ?? null, live: true }; + pointerListeners.push(entry); + return () => { + entry.live = false; + const i = pointerListeners.indexOf(entry); + if (i >= 0) pointerListeners.splice(i, 1); + }; + }, + claimPointer(owner) { + if (currentLease) currentLease.release(); + let released = false; + const lease = { + owner: String(owner), + get current() { + return !released && currentLease === lease; + }, + release() { + if (released) return; + released = true; + if (currentLease === lease) currentLease = null; + }, + }; + currentLease = lease; + return lease; + }, + isPointerFree: () => currentLease === null, + suspendCameraControls(reason) { + return camera.port.controls.suspend(String(reason)); + }, + behaviours: { + select: makeBehaviour('select'), + dblclickFollow: makeBehaviour('dblclickFollow'), + }, + ctrlWheelZoom: false, + }; + + function dispatchPointer(type, x, y, extra = {}) { + const free = currentLease === null; + let picked; + const ev = { + type, + position: { x, y }, + button: extra.button ?? 0, + modifiers: { + shift: false, + ctrl: false, + alt: false, + meta: false, + ...(extra.modifiers ?? {}), + }, + timestamp: clock, + pointerType: extra.pointerType ?? 'mouse', + ...(extra.endPosition ? { endPosition: extra.endPosition } : {}), + ...(extra.delta !== undefined ? { delta: extra.delta } : {}), + }; + if (type === 'click' || type === 'dblclick') { + ev.gesture = extra.gesture ?? { travelPx: 0, durationMs: 120 }; + const hits = () => (picked ??= hitsAt(x, y)); + ev.pick = () => hits()[0] ?? surfaceHit(x, y); + ev.drill = (opts = {}) => + opts.limit === undefined ? hits().slice() : hits().slice(0, opts.limit); + ev.ground = () => rayEllipsoid(pickRay(x, y)); + } + const ambientAlways = type === 'move' || type === 'up'; + for (const entry of pointerListeners.slice()) { + if (!entry.live || entry.type !== type) continue; + if (entry.lease) { + const lease = leaseOf(entry); + if (!lease?.current) continue; + } else if (!free && !ambientAlways) continue; + try { + entry.cb(ev); + } catch (error) { + report(error); + } + } + requestRender('input'); + } + + // --- Globe ----------------------------------------------------------------- + + const imageryLayers = []; + let split = 0.5; + let imageryHost = 'globe'; + const hostListeners = []; + const progressListeners = []; + const settled = { base: true, tiles: true, terrain: true, imagery: true }; + let sky = { + atmosphere: true, + stars: true, + milkyWay: 0.5, + sun: true, + moon: true, + fog: true, + lighting: false, + ...(options.sky ?? {}), + }; + let photorealStatus = { route: 'none' }; + + function addImagery(spec, index) { + const listeners = { frameReady: [], tileError: [] }; + const layer = { + spec: { ...spec }, + alpha: 1, + show: true, + split: 'none', + drapeOn: 'active', + on(event, cb) { + listeners[event]?.push(cb); + return () => { + const list = listeners[event] ?? []; + const i = list.indexOf(cb); + if (i >= 0) list.splice(i, 1); + }; + }, + raise() { + const i = imageryLayers.indexOf(layer); + if (i >= 0 && i < imageryLayers.length - 1) { + imageryLayers.splice(i, 1); + imageryLayers.splice(i + 1, 0, layer); + } + requestRender('imagery'); + }, + lower() { + const i = imageryLayers.indexOf(layer); + if (i > 0) { + imageryLayers.splice(i, 1); + imageryLayers.splice(i - 1, 0, layer); + } + requestRender('imagery'); + }, + remove() { + const i = imageryLayers.indexOf(layer); + if (i >= 0) imageryLayers.splice(i, 1); + requestRender('imagery'); + }, + }; + if (capabilities.has('imageryFrames')) { + let frames = []; + layer.setFrames = (list) => { + frames = list.slice(); + }; + layer.setFrameTime = (ms) => { + const nearest = frames.reduce( + (best, f) => + !best || Math.abs(f.time - ms) < Math.abs(best.time - ms) + ? f + : best, + null, + ); + if (nearest) deliver(listeners.frameReady, nearest.time); + }; + } + if (index === undefined) imageryLayers.push(layer); + else imageryLayers.splice(index, 0, layer); + log('globe', 'imagery.add', [spec, index]); + requestRender('imagery'); + return layer; + } + + const globe = { + async setBase(base) { + log('globe', 'setBase', [base]); + for (const layer of imageryLayers.splice(0)) void layer; + if (base.photoreal !== undefined) + await globe.setPhotoreal(base.photoreal); + const layers = (base.imagery ?? []).map((spec) => addImagery(spec)); + return { photoreal: photorealStatus, imagery: layers, settled: true }; + }, + async setPhotoreal(spec) { + log('globe', 'setPhotoreal', [spec]); + photorealStatus = + spec && capabilities.has('photoreal') + ? { route: 'google' } + : { route: 'none' }; + const next = photorealStatus.route === 'none' ? 'globe' : 'tiles'; + if (next !== imageryHost) { + imageryHost = next; + deliver(hostListeners, next); + } + requestRender('base'); + return photorealStatus; + }, + async setTerrain(spec) { + log('globe', 'setTerrain', [spec]); + requestRender('base'); + }, + setGlobeVisible(visible) { + log('globe', 'setGlobeVisible', [visible]); + requestRender('base'); + }, + imagery: { + add: addImagery, + list: () => imageryLayers.slice(), + host: () => imageryHost, + onHostChange(cb) { + hostListeners.push(cb); + return () => { + const i = hostListeners.indexOf(cb); + if (i >= 0) hostListeners.splice(i, 1); + }; + }, + }, + sky: { + get: () => ({ ...sky }), + set(patch) { + sky = { ...sky, ...patch }; + requestRender('sky'); + }, + }, + isSettled: (kind) => settled[kind] ?? true, + on(event, cb) { + if (event !== 'progress') return () => {}; + progressListeners.push(cb); + return () => { + const i = progressListeners.indexOf(cb); + if (i >= 0) progressListeners.splice(i, 1); + }; + }, + }; + if (capabilities.has('surfaceShading')) + globe.setSurfaceShading = (shading) => { + log('globe', 'setSurfaceShading', [shading]); + requestRender('base'); + }; + if (capabilities.has('splitSwipe')) { + globe.imagery.getSplit = () => split; + globe.imagery.setSplit = (x01) => { + split = Math.min(1, Math.max(0, x01)); + requestRender('imagery'); + }; + } + + // --- Effects, credits, quality --------------------------------------------- + + const effectState = { + style: null, + params: {}, + intensity: {}, + bloom: null, + sharpen: null, + modulation: null, + }; + const effectListeners = []; + const STYLES = Object.freeze([ + { + id: 'retro', + params: [{ key: 'grain', min: 0, max: 1, default: 0.5 }], + animated: true, + }, + { id: 'thermal', params: [], animated: false }, + ]); + function effectsChanged() { + const d = effects.diagnostics(); + deliver(effectListeners, d); + requestRender('effects'); + } + const effects = { + styles: () => + capabilities.has('styles') ? STYLES.map((s) => ({ ...s })) : [], + setStyle(id, params) { + effectState.style = id; + if (params) effectState.params[id] = { ...params }; + effectsChanged(); + }, + setStyleIntensity(id, value) { + effectState.intensity[id] = value; + effectsChanged(); + }, + setStyleParams(id, params) { + effectState.params[id] = { ...(effectState.params[id] ?? {}), ...params }; + effectsChanged(); + }, + bloom(settings) { + effectState.bloom = capabilities.has('bloom') ? { ...settings } : null; + effectsChanged(); + }, + sharpen(settings) { + effectState.sharpen = capabilities.has('sharpen') + ? { ...settings } + : null; + effectsChanged(); + }, + diagnostics: () => ({ + style: effectState.style, + passes: [ + ...(effectState.style ? [`style:${effectState.style}`] : []), + ...(effectState.bloom?.enabled ? ['bloom'] : []), + ...(effectState.sharpen?.enabled ? ['sharpen'] : []), + ...(effectState.modulation ? ['modulation'] : []), + ], + }), + on(event, cb) { + if (event !== 'change') return () => {}; + effectListeners.push(cb); + return () => { + const i = effectListeners.indexOf(cb); + if (i >= 0) effectListeners.splice(i, 1); + }; + }, + }; + if (capabilities.has('screenModulation')) + effects.screenModulation = (sector) => { + effectState.modulation = sector ? { ...sector } : null; + effectsChanged(); + }; + + const creditList = []; + const creditListeners = []; + const creditsChanged = () => { + const list = creditList.map((c) => ({ ...c })); + deliver(creditListeners, list); + }; + const credits = { + addStatic(credit) { + const entry = { ...credit }; + creditList.push(entry); + creditsChanged(); + return () => { + const i = creditList.indexOf(entry); + if (i < 0) return; + creditList.splice(i, 1); + creditsChanged(); + }; + }, + list: () => creditList.map((c) => ({ ...c })), + on(event, cb) { + if (event !== 'change') return () => {}; + creditListeners.push(cb); + return () => { + const i = creditListeners.indexOf(cb); + if (i >= 0) creditListeners.splice(i, 1); + }; + }, + }; + + const qualitySettings = Object.fromEntries( + KNOBS.map((k) => [k.key, k.default]), + ); + const device = () => ({ + backend: 'webgl2', + maxTextureSize: 16384, + msaaSamples: [0, 2, 4, 8], + gpuTimer: capabilities.has('gpuTiming'), + }); + const quality = { + knobs: () => KNOBS.map((k) => ({ ...k })), + get: () => ({ ...qualitySettings }), + set(patch) { + const ignored = []; + for (const [key, value] of Object.entries(patch)) { + if (key in qualitySettings) qualitySettings[key] = value; + else ignored.push(key); + } + requestRender('quality'); + return ignored; + }, + device, + probe: () => ({ + device: device(), + browser: { + devicePixelRatio: 1, + screen: { width: viewport.width, height: viewport.height }, + touchOnly: false, + }, + knobs: quality.knobs(), + }), + signals: { + gpuTiming: capabilities.has('gpuTiming') ? 'frame' : 'none', + on(event, cb) { + const entry = { cb, live: true }; + if (event === 'frame') signalListeners.push(entry); + return () => { + entry.live = false; + const i = signalListeners.indexOf(entry); + if (i >= 0) signalListeners.splice(i, 1); + }; + }, + }, + }; + if (capabilities.has('benchmark')) + quality.benchmark = async ({ durationMs }) => ({ + frames: Math.round(durationMs / FRAME_MS), + cpuMs: { median: 0, p95: 0 }, + }); + + // --- Renderer -------------------------------------------------------------- + + const canvas = /** @type {HTMLCanvasElement} */ ( + /** @type {unknown} */ ({ + width: viewport.width, + height: viewport.height, + clientWidth: viewport.width, + clientHeight: viewport.height, + }) + ); + + const renderer = { + id: 'stub', + contractVersion: CONTRACT_VERSION, + capabilities, + canvas, + frame, + globe, + draw, + camera: camera.port, + query, + input, + effects, + credits, + quality, + async capture(opts = {}) { + return new Blob([], { type: `image/${opts.type ?? 'png'}` }); + }, + readPixels(opts = {}) { + const width = Math.round( + (opts.crop?.width ?? viewport.width) * (opts.scale ?? 1), + ); + const height = Math.round( + (opts.crop?.height ?? viewport.height) * (opts.scale ?? 1), + ); + return /** @type {ImageData} */ ( + /** @type {unknown} */ ({ + width, + height, + data: new Uint8ClampedArray(width * height * 4), + }) + ); + }, + async whenSettled() { + const blockers = Object.keys(settled).filter((kind) => !settled[kind]); + return { state: blockers.length ? 'timeout' : 'settled', blockers }; + }, + resize() { + requestRender('resize'); + }, + releaseUnused() { + log('renderer', 'releaseUnused', []); + }, + on(event, cb) { + const list = rendererListeners[event]; + if (!list) return () => {}; + // Each subscription is its own entry, so one callback subscribed + // again during delivery waits for the next event like any other. + const entry = { cb, live: true }; + list.push(entry); + return () => { + entry.live = false; + const i = list.indexOf(entry); + if (i >= 0) list.splice(i, 1); + }; + }, + destroy() { + // A flight in progress ends cancelled and gives back its input hold. + camera.endFlight(); + destroyed = true; + for (const clear of onceTimers) clear(); + onceTimers.clear(); + sets.clear(); + }, + testing: { + /** Run one frame of the loop; true when it drew. */ + tick, + /** Run frames until `ms` of the clock has passed. */ + advance(ms) { + // By frame count: a suspended or destroyed loop does not move the clock. + const frames = Math.floor(ms / FRAME_MS + 1e-9); + let drew = 0; + for (let i = 0; i < frames; i++) if (tick()) drew += 1; + return drew; + }, + /** Run frames until a condition holds, at most `maxFrames`. */ + until(condition, maxFrames = 10000) { + for (let i = 0; i < maxFrames; i++) { + if (condition()) return i; + tick(); + } + throw new Error('condition not met'); + }, + now: () => clock, + /** Wall time, epoch ms: what a set's `live` motion clock reads before its delay. */ + wallTime, + /** The steps of the last frame, in order. */ + lastSteps: () => stepLog.slice(), + /** A pointer event at `(x, y)`. */ + pointer: dispatchPointer, + /** Answer picks from a function instead of the drawn items. */ + scriptPicks(fn) { + scriptedPicks = fn; + }, + /** Heights: a function answering async samples, and cached values. */ + setHeights(fn) { + heightSource = fn; + }, + /** A cached height, against `against` (default `'surface'`). */ + cacheHeight(point, height, against) { + heightCache.set(cacheKey(point, against), height); + }, + setSettled(kind, value) { + settled[kind] = value; + }, + setViewport(size) { + Object.assign(viewport, size); + canvas.width = viewport.width; + canvas.height = viewport.height; + }, + /** What a set holds. */ + setRecord: (id) => sets.get(id) ?? null, + sets: () => [...sets.keys()], + calls, + effects: () => ({ ...effectState }), + emitError: (error) => report(error), + }, + }; + return renderer; +} + +/** + * @typedef {object} StubTesting + * @property {() => boolean} tick + * @property {(ms: number) => number} advance + * @property {(condition: () => boolean, maxFrames?: number) => number} until + * @property {() => number} now + * @property {() => string[]} lastSteps + * @property {(type: string, x: number, y: number, extra?: object) => void} pointer + * @property {(fn: ((x: number, y: number, win: object) => object[])|null) => void} scriptPicks + * @property {(fn: ((point: object, against?: string) => number|null)|null) => void} setHeights + * @property {(point: object, height: number) => void} cacheHeight + * @property {(kind: string, value: boolean) => void} setSettled + * @property {(size: {width?: number, height?: number}) => void} setViewport + * @property {(id: string) => object|null} setRecord + * @property {() => string[]} sets + * @property {Array<{port: string, member: string, args: unknown[]}>} calls + * @property {() => object} effects + * @property {(error: Error) => void} emitError + */ diff --git a/src/render/stub/math.js b/src/render/stub/math.js new file mode 100644 index 000000000..391f1b0cf --- /dev/null +++ b/src/render/stub/math.js @@ -0,0 +1,310 @@ +/** + * @module render/stub/math + * @description The geodesy and vector maths the stub renderer poses its + * camera with: WGS84, east-north-up frames, rigid transforms and the camera + * angle conventions of the contract (heading clockwise from north, pitch + * negative looking down). Plain objects and numbers, no engine. + * + * The formulas follow the default renderer's, step for step, so the stub's + * poses match its reference traces to floating-point noise. + */ + +export const WGS84 = Object.freeze({ + radii: Object.freeze({ x: 6378137.0, y: 6378137.0, z: 6356752.3142451793 }), + radiiSquared: Object.freeze({ + x: 6378137.0 * 6378137.0, + y: 6378137.0 * 6378137.0, + z: 6356752.3142451793 * 6356752.3142451793, + }), + oneOverRadii: Object.freeze({ + x: 1 / 6378137.0, + y: 1 / 6378137.0, + z: 1 / 6356752.3142451793, + }), + oneOverRadiiSquared: Object.freeze({ + x: 1 / (6378137.0 * 6378137.0), + y: 1 / (6378137.0 * 6378137.0), + z: 1 / (6356752.3142451793 * 6356752.3142451793), + }), +}); + +export const TWO_PI = Math.PI * 2; +export const PI_OVER_TWO = Math.PI / 2; +const EPSILON3 = 1e-3; +const EPSILON10 = 1e-10; +const EPSILON12 = 1e-12; +const EPSILON14 = 1e-14; +const CENTER_TOLERANCE_SQUARED = 0.1; + +export const vec = (x = 0, y = 0, z = 0) => ({ x, y, z }); +export const clone = (a) => ({ x: a.x, y: a.y, z: a.z }); +export const add = (a, b) => vec(a.x + b.x, a.y + b.y, a.z + b.z); +export const sub = (a, b) => vec(a.x - b.x, a.y - b.y, a.z - b.z); +export const scale = (a, s) => vec(a.x * s, a.y * s, a.z * s); +export const negate = (a) => vec(-a.x, -a.y, -a.z); +export const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z; +export const cross = (a, b) => + vec(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x); +export const magnitude = (a) => Math.sqrt(dot(a, a)); +export const distance = (a, b) => magnitude(sub(a, b)); +export function normalize(a) { + const m = magnitude(a); + return vec(a.x / m, a.y / m, a.z / m); +} + +export const toRadians = (degrees) => (degrees * Math.PI) / 180; +export const toDegrees = (radians) => (radians * 180) / Math.PI; +export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); +export const acosClamped = (v) => Math.acos(clamp(v, -1, 1)); +export const equalsEpsilon = (a, b, eps) => Math.abs(a - b) <= eps; + +/** An angle in [0, 2π). */ +export function zeroToTwoPi(angle) { + if (angle >= 0 && angle <= TWO_PI) return angle; + const mod = ((angle % TWO_PI) + TWO_PI) % TWO_PI; + if (Math.abs(mod) < EPSILON14 && Math.abs(angle) > EPSILON14) return TWO_PI; + return mod; +} + +/** An angle in [-π, π]. */ +export function negativePiToPi(angle) { + if (angle >= -Math.PI && angle <= Math.PI) return angle; + return zeroToTwoPi(angle + Math.PI) - Math.PI; +} + +// --- Geodesy ----------------------------------------------------------------- + +export function geodeticSurfaceNormal(p) { + const o = WGS84.oneOverRadiiSquared; + return normalize(vec(p.x * o.x, p.y * o.y, p.z * o.z)); +} + +function geodeticSurfaceNormalCartographic(lon, lat) { + const cosLat = Math.cos(lat); + return normalize( + vec(cosLat * Math.cos(lon), cosLat * Math.sin(lon), Math.sin(lat)), + ); +} + +/** Geodetic radians and metres to ECEF. */ +export function fromRadians(lon, lat, height = 0) { + const n = geodeticSurfaceNormalCartographic(lon, lat); + const r = WGS84.radiiSquared; + let k = vec(r.x * n.x, r.y * n.y, r.z * n.z); + const gamma = Math.sqrt(dot(n, k)); + k = scale(k, 1 / gamma); + return add(k, scale(n, height)); +} + +/** Geodetic degrees and metres to ECEF. */ +export const fromDegrees = (lon, lat, height = 0) => + fromRadians(toRadians(lon), toRadians(lat), height); + +/** A position in either contract form, as ECEF. */ +export function toEcef(position) { + if ('lon' in position && 'lat' in position) + return fromDegrees(position.lon, position.lat, position.height ?? 0); + return clone(position); +} + +/** The point on the ellipsoid along the geodetic normal, or null at the centre. */ +export function scaleToGeodeticSurface(p) { + const { x: positionX, y: positionY, z: positionZ } = p; + const oneOverRadiiX = WGS84.oneOverRadii.x; + const oneOverRadiiY = WGS84.oneOverRadii.y; + const oneOverRadiiZ = WGS84.oneOverRadii.z; + const x2 = positionX * positionX * oneOverRadiiX * oneOverRadiiX; + const y2 = positionY * positionY * oneOverRadiiY * oneOverRadiiY; + const z2 = positionZ * positionZ * oneOverRadiiZ * oneOverRadiiZ; + const squaredNorm = x2 + y2 + z2; + const ratio = Math.sqrt(1.0 / squaredNorm); + const intersection = scale(p, ratio); + if (squaredNorm < CENTER_TOLERANCE_SQUARED) + return Number.isFinite(ratio) ? intersection : null; + + const oneOverRadiiSquaredX = WGS84.oneOverRadiiSquared.x; + const oneOverRadiiSquaredY = WGS84.oneOverRadiiSquared.y; + const oneOverRadiiSquaredZ = WGS84.oneOverRadiiSquared.z; + const gradient = vec( + intersection.x * oneOverRadiiSquaredX * 2.0, + intersection.y * oneOverRadiiSquaredY * 2.0, + intersection.z * oneOverRadiiSquaredZ * 2.0, + ); + let lambda = ((1.0 - ratio) * magnitude(p)) / (0.5 * magnitude(gradient)); + let correction = 0.0; + let func, denominator, xMultiplier, yMultiplier, zMultiplier; + let xMultiplier2, yMultiplier2, zMultiplier2; + let xMultiplier3, yMultiplier3, zMultiplier3; + do { + lambda -= correction; + xMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredX); + yMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredY); + zMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredZ); + xMultiplier2 = xMultiplier * xMultiplier; + yMultiplier2 = yMultiplier * yMultiplier; + zMultiplier2 = zMultiplier * zMultiplier; + xMultiplier3 = xMultiplier2 * xMultiplier; + yMultiplier3 = yMultiplier2 * yMultiplier; + zMultiplier3 = zMultiplier2 * zMultiplier; + func = x2 * xMultiplier2 + y2 * yMultiplier2 + z2 * zMultiplier2 - 1.0; + denominator = + x2 * xMultiplier3 * oneOverRadiiSquaredX + + y2 * yMultiplier3 * oneOverRadiiSquaredY + + z2 * zMultiplier3 * oneOverRadiiSquaredZ; + const derivative = -2.0 * denominator; + correction = func / derivative; + } while (Math.abs(func) > EPSILON12); + return vec( + positionX * xMultiplier, + positionY * yMultiplier, + positionZ * zMultiplier, + ); +} + +/** ECEF to geodetic `{lon, lat}` radians and `height` metres, or null at the centre. */ +export function toCartographicRadians(p) { + const surface = scaleToGeodeticSurface(p); + if (!surface) return null; + const n = geodeticSurfaceNormal(surface); + const h = sub(p, surface); + return { + lon: Math.atan2(n.y, n.x), + lat: Math.asin(n.z), + height: Math.sign(dot(h, p)) * magnitude(h), + }; +} + +/** ECEF to `{lon, lat}` degrees and `height` metres. */ +export function toLonLatH(p) { + const c = toCartographicRadians(p); + if (!c) return null; + return { lon: toDegrees(c.lon), lat: toDegrees(c.lat), height: c.height }; +} + +// --- Rigid frames ------------------------------------------------------------ + +/** + * A rigid frame: unit axes `x`, `y`, `z` (columns) and an `origin`, mapping + * local coordinates to ECEF. + * @typedef {{x: object, y: object, z: object, origin: object}} Frame + */ + +export const IDENTITY = Object.freeze({ + x: Object.freeze(vec(1, 0, 0)), + y: Object.freeze(vec(0, 1, 0)), + z: Object.freeze(vec(0, 0, 1)), + origin: Object.freeze(vec(0, 0, 0)), +}); + +export const isIdentity = (frame) => + frame === IDENTITY || + (frame.x.x === 1 && + frame.y.y === 1 && + frame.z.z === 1 && + frame.x.y === 0 && + frame.x.z === 0 && + frame.y.x === 0 && + frame.y.z === 0 && + frame.z.x === 0 && + frame.z.y === 0 && + frame.origin.x === 0 && + frame.origin.y === 0 && + frame.origin.z === 0); + +/** East-north-up at `origin`. */ +export function eastNorthUp(origin) { + if ( + equalsEpsilon(origin.x, 0, EPSILON14) && + equalsEpsilon(origin.y, 0, EPSILON14) + ) { + // At a pole: east is +y, north points away from the pole. + const sign = Math.sign(origin.z) || 1; + return { + x: vec(0, 1, 0), + y: vec(-sign, 0, 0), + z: vec(0, 0, sign), + origin: clone(origin), + }; + } + const up = geodeticSurfaceNormal(origin); + const east = normalize(vec(-origin.y, origin.x, 0)); + const north = cross(up, east); + return { x: east, y: north, z: up, origin: clone(origin) }; +} + +export const frameVector = (frame, v) => + vec( + frame.x.x * v.x + frame.y.x * v.y + frame.z.x * v.z, + frame.x.y * v.x + frame.y.y * v.y + frame.z.y * v.z, + frame.x.z * v.x + frame.y.z * v.y + frame.z.z * v.z, + ); +export const framePoint = (frame, p) => + add(frameVector(frame, p), frame.origin); +export const inverseFrameVector = (frame, v) => + vec(dot(frame.x, v), dot(frame.y, v), dot(frame.z, v)); +export const inverseFramePoint = (frame, p) => + inverseFrameVector(frame, sub(p, frame.origin)); + +// --- Rotations --------------------------------------------------------------- + +/** Rotate `v` about unit `axis` by `angle` (right-handed). */ +export function rotate(v, axis, angle) { + const c = Math.cos(angle); + const s = Math.sin(angle); + const d = dot(axis, v); + const k = cross(axis, v); + return vec( + v.x * c + k.x * s + axis.x * d * (1 - c), + v.y * c + k.y * s + axis.y * d * (1 - c), + v.z * c + k.z * s + axis.z * d * (1 - c), + ); +} + +/** + * The rotation the contract's heading, pitch and roll describe in a local + * east-north-up frame, as its three columns: the direction, the left axis + * and the up vector. + */ +export function headingPitchRollColumns(heading, pitch, roll) { + const h = heading - PI_OVER_TWO; + const rotateAll = (v) => + rotate( + rotate(rotate(v, vec(1, 0, 0), roll), vec(0, 1, 0), -pitch), + vec(0, 0, 1), + -h, + ); + return { + direction: rotateAll(vec(1, 0, 0)), + left: rotateAll(vec(0, 1, 0)), + up: rotateAll(vec(0, 0, 1)), + }; +} + +/** Heading of a local direction and up, radians clockwise from north. */ +export function headingOf(direction, up) { + const h = !equalsEpsilon(Math.abs(direction.z), 1.0, EPSILON3) + ? Math.atan2(direction.y, direction.x) - PI_OVER_TWO + : Math.atan2(up.y, up.x) - PI_OVER_TWO; + return TWO_PI - zeroToTwoPi(h); +} + +export const pitchOf = (direction) => PI_OVER_TWO - acosClamped(direction.z); + +export function rollOf(direction, up, right) { + if (equalsEpsilon(Math.abs(direction.z), 1.0, EPSILON3)) return 0; + return zeroToTwoPi(Math.atan2(-right.z, up.z) + TWO_PI); +} + +/** The local offset a heading, pitch and range puts the eye at. */ +export function offsetFromHeadingPitchRange(heading, pitch, range) { + const p = clamp(pitch, -PI_OVER_TWO, PI_OVER_TWO); + const h = zeroToTwoPi(heading) - PI_OVER_TWO; + const offset = rotate( + rotate(vec(1, 0, 0), vec(0, 1, 0), -p), + vec(0, 0, 1), + -h, + ); + return scale(negate(offset), range); +} + +export { EPSILON10 }; diff --git a/src/render/stub/motion.js b/src/render/stub/motion.js new file mode 100644 index 000000000..d8bd5ad48 --- /dev/null +++ b/src/render/stub/motion.js @@ -0,0 +1,53 @@ +/** + * @module render/stub/motion + * @description Where a motion segment puts an icon at a time, exactly as + * the contract's `MotionSegment` describes it. + */ +import { add, eastNorthUp, scale, sub } from './math.js'; + +const STRAIGHT_RAD_PER_S = 1e-4; + +/** + * @param {import('../contract/draw').MotionSegment} segment + * @param {{x: number, y: number, z: number}} pos The item's own position (for `hold`). + * @param {number} t Epoch ms on the set's motion clock. + * @returns {{x: number, y: number, z: number}} + */ +export function motionPosition(segment, pos, t) { + switch (segment.kind) { + case 'hold': + return pos; + case 'lerp': { + const span = segment.t1 - segment.t0; + const s = + span > 0 + ? Math.min(1, Math.max(0, (t - segment.t0) / span)) + : t >= segment.t1 + ? 1 + : 0; + return add(segment.from, scale(sub(segment.to, segment.from), s)); + } + case 'arc': { + const dt = Math.min( + segment.maxDtS, + Math.max(segment.minDtS, (t - segment.t0) / 1000), + ); + const track = (segment.trackDeg * Math.PI) / 180; + const w = (segment.turnRateDps * Math.PI) / 180; + const v = segment.speedMps; + let east; + let north; + if (Math.abs(w) < STRAIGHT_RAD_PER_S) { + east = v * dt * Math.sin(track); + north = v * dt * Math.cos(track); + } else { + east = (v / w) * (Math.cos(track) - Math.cos(track + w * dt)); + north = (v / w) * (Math.sin(track + w * dt) - Math.sin(track)); + } + const enu = eastNorthUp(segment.fix); + return add(segment.fix, add(scale(enu.x, east), scale(enu.y, north))); + } + default: + throw new Error(`unknown motion segment ${segment.kind}`); + } +} diff --git a/src/render/stub/stub.test.mjs b/src/render/stub/stub.test.mjs new file mode 100644 index 000000000..5b4595b85 --- /dev/null +++ b/src/render/stub/stub.test.mjs @@ -0,0 +1,77 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createStubRenderer } from './index.js'; +import { createStubHarness } from './harness.js'; +import { runConformance } from '../conformance/index.js'; + +test('the stub passes the conformance suite', async () => { + const report = await runConformance(createStubHarness()); + const failed = report.results.filter((r) => r.status === 'fail'); + assert.deepEqual( + failed.map((r) => `${r.id}: ${r.detail}`), + [], + ); + const skipped = report.results + .filter((r) => r.status === 'skip') + .map((r) => r.id); + // Only the model check, which needs a glTF asset the stub has no use for. + assert.deepEqual(skipped, ['draw.models']); +}); + +test('a frame runs the contract’s steps in order', () => { + const r = createStubRenderer(); + r.frame.requestRender('test'); + r.testing.tick(); + assert.deepEqual(r.testing.lastSteps(), [ + 'flight', + 'input', + 'changed', + 'follow', + 'preUpdate', + 'moveDetection', + 'preRender', + 'render', + 'postRender', + ]); + r.testing.tick(); + assert.deepEqual( + r.testing.lastSteps().at(-1), + 'moveDetection', + 'an idle frame stops before preRender', + ); +}); + +test('a throwing listener with no error listener is reported and the frame goes on', async () => { + const reported = []; + const saved = globalThis.reportError; + globalThis.reportError = (error) => reported.push(error.message); + try { + const r = createStubRenderer(); + r.frame.on('preUpdate', () => { + throw new Error('phase'); + }); + let after = false; + r.frame.on('preUpdate', () => (after = true)); + r.frame.requestRender('test'); + r.testing.tick(); + assert.ok(after, 'the next listener still ran'); + assert.equal( + r.testing.lastSteps().at(-1), + 'postRender', + 'the frame ran on', + ); + const flight = r.camera.flyTo( + { lon: 0, lat: 0, height: 1e6 }, + { + durationS: 0, + onComplete: () => { + throw new Error('complete'); + }, + }, + ); + assert.equal(await flight.done, 'complete', 'the flight still resolved'); + assert.deepEqual(reported, ['phase', 'complete']); + } finally { + globalThis.reportError = saved; + } +}); diff --git a/src/render/stub/viewRectangle.js b/src/render/stub/viewRectangle.js new file mode 100644 index 000000000..40aaf57d4 --- /dev/null +++ b/src/render/stub/viewRectangle.js @@ -0,0 +1,167 @@ +/** + * @module render/stub/viewRectangle + * @description The geodetic rectangle in view, as the default renderer + * computes it: the canvas corners picked on the ellipsoid, with the horizon + * standing in for a corner that misses, the whole globe when fewer than two + * corners land, and the full longitude span when the view goes over a pole. + */ +import { + TWO_PI, + WGS84, + add, + cross, + dot, + magnitude, + normalize, + scale, + sub, + toCartographicRadians, + toDegrees, + vec, +} from './math.js'; + +const EPSILON9 = 1e-9; +const EPSILON10 = 1e-10; +/** The reference renderer's epsilon test: absolute and relative at once. */ +const equalsEpsilon = (a, b, eps) => + Math.abs(a - b) <= eps * Math.max(1, Math.abs(a), Math.abs(b)); + +const WHOLE_GLOBE = Object.freeze({ + west: -180, + south: -90, + east: 180, + north: 90, +}); + +/** Whether the ellipsoid's bounding sphere lies wholly outside the view frustum. */ +function globeOutsideFrustum(pose) { + const { position, direction, up, near, far } = pose; + const top = near * Math.tan(pose.fovy * 0.5); + const right = pose.aspectRatio * top; + const r = cross(direction, up); + const nearCenter = add(position, scale(direction, near)); + const farCenter = add(position, scale(direction, far)); + const sideNormals = [ + normalize( + cross(normalize(sub(add(nearCenter, scale(r, -right)), position)), up), + ), + normalize(cross(up, sub(add(nearCenter, scale(r, right)), position))), + normalize(cross(r, sub(add(nearCenter, scale(up, -top)), position))), + normalize(cross(sub(add(nearCenter, scale(up, top)), position), r)), + ]; + // Each plane's distance from the globe's centre (the origin). + const distances = [ + ...sideNormals.map((n) => -dot(n, position)), + -dot(direction, nearCenter), + dot(direction, farCenter), + ]; + const radius = WGS84.radii.x; + return distances.some((d) => d < -radius); +} + +/** Where the ellipsoid's limb meets the view's four quadrants, from the eye. */ +function horizonQuad(p) { + const radii = WGS84.radii; + const q = vec( + p.x * WGS84.oneOverRadii.x, + p.y * WGS84.oneOverRadii.y, + p.z * WGS84.oneOverRadii.z, + ); + const qMagnitude = magnitude(q); + const qUnit = normalize(q); + let eUnit; + let nUnit; + const z = vec(0, 0, 1); + if ( + equalsEpsilon(qUnit.x, 0, EPSILON10) && + equalsEpsilon(qUnit.y, 0, EPSILON10) && + equalsEpsilon(qUnit.z, 1, EPSILON10) + ) { + eUnit = vec(0, 1, 0); + nUnit = vec(0, 0, 1); + } else { + eUnit = normalize(cross(z, qUnit)); + nUnit = normalize(cross(qUnit, eUnit)); + } + const wMagnitude = Math.sqrt(dot(q, q) - 1); + const center = scale(qUnit, 1 / qMagnitude); + const s = wMagnitude / qMagnitude; + const eastOffset = scale(eUnit, s); + const northOffset = scale(nUnit, s); + const toWorld = (v) => vec(v.x * radii.x, v.y * radii.y, v.z * radii.z); + return [ + toWorld(sub(add(center, northOffset), eastOffset)), + toWorld(sub(sub(center, northOffset), eastOffset)), + toWorld(add(sub(center, northOffset), eastOffset)), + toWorld(add(add(center, northOffset), eastOffset)), + ]; +} + +/** + * @param {{position: object, direction: object, up: object, fovy: number, + * aspectRatio: number, near: number, far: number}} pose The camera's pose. + * @param {{width: number, height: number}} viewport CSS pixels. + * @param {(x: number, y: number) => object|null} pickEllipsoid + * @returns {{west: number, south: number, east: number, north: number}|null} Degrees. + */ +export function computeViewRectangle(pose, viewport, pickEllipsoid) { + if (globeOutsideFrustum(pose)) return null; + const { width, height } = viewport; + const horizon = horizonQuad(pose.position); + const corners = [ + [0, 0], + [0, height], + [width, height], + [width, 0], + ]; + let hits = 0; + const cartos = corners.map(([x, y], i) => { + const hit = pickEllipsoid(x, y); + if (hit) hits += 1; + return toCartographicRadians(hit ?? horizon[i]); + }); + if (hits < 2) return { ...WHOLE_GLOBE }; + + let west = Infinity; + let east = -Infinity; + let westOverIDL = Infinity; + let eastOverIDL = -Infinity; + let south = Infinity; + let north = -Infinity; + for (const c of cartos) { + west = Math.min(west, c.lon); + east = Math.max(east, c.lon); + south = Math.min(south, c.lat); + north = Math.max(north, c.lat); + const lonAdjusted = c.lon >= 0 ? c.lon : c.lon + TWO_PI; + westOverIDL = Math.min(westOverIDL, lonAdjusted); + eastOverIDL = Math.max(eastOverIDL, lonAdjusted); + } + if (east - west > eastOverIDL - westOverIDL) { + west = westOverIDL; + east = eastOverIDL; + if (east > Math.PI) east -= TWO_PI; + if (west > Math.PI) west -= TWO_PI; + } + + // Over a pole the corners circle it: the longitude turns a full circle. + let turned = 0; + let lastLon = cartos[3].lon; + for (const c of cartos) { + const diff = Math.abs(c.lon - lastLon); + turned += diff > Math.PI ? TWO_PI - diff : diff; + lastLon = c.lon; + } + if (equalsEpsilon(Math.abs(turned), TWO_PI, EPSILON9)) { + west = -Math.PI; + east = Math.PI; + if (cartos[0].lat >= 0) north = Math.PI / 2; + else south = -Math.PI / 2; + } + return { + west: toDegrees(west), + south: toDegrees(south), + east: toDegrees(east), + north: toDegrees(north), + }; +} diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index c118dd4f0..185488550 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -145,7 +145,7 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }, { nominatimStorageDir }); const standaloneProviders = config.plugins - .slice(4, -6) + .slice(4, -7) .map((plugin) => plugin.name); assert.equal( standaloneProviders.filter((name) => name === 'gemini-live-proxy').length, @@ -161,10 +161,12 @@ test('root config retains existing named exports and standalone provider order', .localProviderPlugins({ nominatimStorageDir }) .map((plugin) => plugin.name), ); - assert.equal(config.plugins.at(-7).name, 'gev-key-setup'); + assert.equal(config.plugins.at(-8).name, 'gev-key-setup'); // Token fallback follows real providers and local MCP before the API 404. - assert.equal(config.plugins.at(-6).name, 'local-mcp'); - assert.equal(config.plugins.at(-5).name, 'google-token-unavailable'); + assert.equal(config.plugins.at(-7).name, 'local-mcp'); + assert.equal(config.plugins.at(-6).name, 'google-token-unavailable'); + // Dev-only, under its own path. + assert.equal(config.plugins.at(-5).name, 'conformance-references'); assert.equal(config.plugins.at(-4).name, 'api-not-found'); // Config-only, so it sits after the provider slice rather than in it. assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs');