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
+ Renderer
+
+ cesium (scene renderer)
+
+ Run logic suite (stub)
+ Check scenes
+ Record references
+
+
+
+
+
+
+
+
+
+
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) =>
+ `${r.status} ${r.id} ${escape(r.detail ?? '')} `,
+ )
+ .join('')}
`;
+ 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');