Define the renderer contract, with a stub renderer and a conformance suite

The renderer contract is what the application asks of a 3D renderer, so a
renderer other than Cesium can draw the same application. It is nine ports
(frame, globe, draw, camera, query, input, effects, credits, quality) plus
the renderer itself, one strict .d.ts per port in src/render/contract/.
PORTS lists every member at run time, and a TypeScript-compiler test fails
when the list and the types disagree on one member. An optional member
exists only with its capability; a required one answers null or empty
without it. Nothing in the application uses the contract yet.

The stub renderer (src/render/stub/) implements every port in memory and
draws nothing. Its camera is a port of the default camera's maths: views,
lookAt, flights with their height arc and easing, rectangle framing on the
geodesic midpoint, the view rectangle with its horizon and pole cases,
changed thresholds, follows, and flights that finish inside the call.

The conformance suite (src/render/conformance/) runs 59 checks against
any renderer through the contract alone, in Node or in a browser page. The
camera checks compare traces recorded from Cesium's own Camera, headless on
a virtual clock (scripts/record-camera-goldens.mjs), to 1 mm, 1e-9 and
1e-3 px. The other checks cover listener rules on every subscription, the
frame loop, draw sets, picks, motion clocks, heights, leases, imagery,
styles, credits and quality knobs. The stub passes all of them but the
model check, which needs a glTF asset.

Reference scenes draw one scene per primitive family, a city and a
calibration path from orbit to the street, from contract calls only and
with no network. /dev/conformance/ draws them on the Cesium scene renderer
(src/render/cesium/sceneRenderer.js), records references through a
dev-server-only, loopback-only endpoint, and compares captures with
pixelmatch's antialias detection plus a solid-pixel count. Measured on
Cesium: every MSAA setting passes, and a dropped 4 px point, a dropped 2 px
line and a 1 px shift of the view fail. visual.js states the limits.

The package exports ./render/contract, ./render/stub and
./render/conformance. Documented in docs/RENDERER-CONTRACT.md,
docs/CODE-BOUNDARIES.md and docs/CURRENT-STATE.md.
This commit is contained in:
Sameh Khamis committed 2026-11-04 14:21:48 -08:00
1 parent fb30287898
commit 2fc99e7c19
65 files changed
+12761 -9

No files matched your search

+2
View File
@@ -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
+113
View File
@@ -0,0 +1,113 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Renderer conformance</title>
<style>
:root {
color-scheme: dark;
font:
13px/1.4 system-ui,
sans-serif;
}
body {
margin: 0;
background: #0d1117;
color: #e6edf3;
}
header {
display: flex;
gap: 12px;
align-items: center;
padding: 10px 16px;
border-bottom: 1px solid #30363d;
flex-wrap: wrap;
}
h1 {
font-size: 15px;
margin: 0 12px 0 0;
}
button,
select {
font: inherit;
padding: 4px 10px;
}
#stage {
width: 640px;
height: 360px;
position: fixed;
left: -10000px;
top: 0;
}
main {
padding: 12px 16px;
}
table {
border-collapse: collapse;
width: 100%;
}
td,
th {
border-bottom: 1px solid #21262d;
padding: 4px 8px;
text-align: left;
vertical-align: top;
}
.pass {
color: #3fb950;
}
.fail {
color: #f85149;
}
.skip {
color: #8b949e;
}
.views {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
gap: 12px;
margin-top: 12px;
}
figure {
margin: 0;
background: #161b22;
border: 1px solid #30363d;
padding: 6px;
}
figure img {
width: 100%;
display: block;
}
figure .row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 4px;
}
figcaption {
margin-top: 4px;
}
</style>
</head>
<body>
<header>
<h1>Renderer conformance</h1>
<label
>Renderer
<select id="renderer">
<option value="cesium">cesium (scene renderer)</option>
</select></label
>
<button id="logic">Run logic suite (stub)</button>
<button id="check">Check scenes</button>
<button id="record">Record references</button>
<span id="status"></span>
</header>
<div id="stage"></div>
<main>
<section id="logicResults"></section>
<section class="views" id="views"></section>
</main>
<script type="module" src="./main.js"></script>
</body>
</html>
+215
View File
@@ -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 =
`<p>${report.harness}: <b class="pass">${report.passed} passed</b>, <b class="fail">${report.failed} failed</b>, ${report.skipped} skipped</p>
<table>${report.results
.filter((r) => r.status !== 'pass')
.map(
(r) =>
`<tr><td class="${r.status}">${r.status}</td><td>${r.id}</td><td>${escape(r.detail ?? '')}</td></tr>`,
)
.join('')}</table>`;
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[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 = `<img src="${objectUrl(capture)}"><figcaption class="fail">${name}: ${escape(detail)}</figcaption>`;
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 = `<img src="${objectUrl(capture)}"><figcaption>${name}: recorded</figcaption>`;
return;
}
const refBlob = await reference(name);
if (!refBlob) {
results.push({
scene: scene.id,
view: view.name,
status: 'fail',
detail: 'no reference',
});
figure.innerHTML = `<img src="${objectUrl(capture)}"><figcaption class="fail">${name}: no reference</figcaption>`;
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 = `<div class="row"><img src="${objectUrl(capture)}" title="${which}"><img src="${objectUrl(refBlob)}" title="reference"><img src="${diffUrl}" title="difference"></div>
<figcaption class="${outcome.match ? 'pass' : 'fail'}">${name}: ${outcome.solidPixels} solid, ${outcome.mismatchedPixels} unexcused, ${(outcome.diffFraction * 100).toFixed(3)}% differ ${outcome.reason ?? ''}</figcaption>`;
});
}
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 };
+2 -1
View File
@@ -33,12 +33,13 @@ export inventory. Use declared exports rather than reaching into internal files.
| `src/layers/<family>/` | 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
+14
View File
@@ -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`
+158
View File
@@ -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.
+15
View File
@@ -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",
+5 -1
View File
@@ -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"
}
}
+22 -1
View File
@@ -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"
]
+2 -1
View File
@@ -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(
+22
View File
@@ -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": []
}
}
+28
View File
@@ -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)}`);
}
@@ -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));
}
});
});
},
};
}
+2
View File
@@ -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(),
],
+5 -1
View File
@@ -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, '')
+316
View File
@@ -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<string, object>}>}
*/
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 };
}
+16
View File
@@ -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',
);
});
+9
View File
@@ -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();
}
+6
View File
@@ -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', () => {
+656
View File
@@ -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<object>} 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();
},
};
}
+171
View File
@@ -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<void>} 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<void>, 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,
};
}
+422
View File
@@ -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 },
],
},
],
},
]);
+505
View File
@@ -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);
File diff suppressed because it is too large. Load diff
+93
View File
@@ -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);
}
}
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -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"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

+123
View File
@@ -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<import('../contract/renderer').Renderer|null>} create
* @property {(r: import('../contract/renderer').Renderer) => void|Promise<void>} 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<void>} [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<ConformanceTolerance>}
*/
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';
+558
View File
@@ -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: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 32 32">${body}</svg>`,
});
const ICONS = {
dot: svg('<circle cx="16" cy="16" r="14" fill="#ffffff"/>'),
arrow: svg('<path d="M16 2 L28 30 L16 23 L4 30 Z" fill="#ffffff"/>'),
square: svg(
'<rect x="3" y="3" width="26" height="26" fill="#ffffff" stroke="#000000" stroke-width="3"/>',
),
};
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<object>} */
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;
}
+229
View File
@@ -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);
});
+227
View File
@@ -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<void>} 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();
}
}
+247
View File
@@ -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<HeadingPitchRoll> | { 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 };
}
+200
View File
@@ -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';
+168
View File
@@ -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()), []);
});
+21
View File
@@ -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;
}
+477
View File
@@ -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<FieldSpec>): 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<T> {
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<Omit<SetSpec, 'id'>>): 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<IconItem>;
points(spec: SetSpec): DrawSet<PointItem>;
lines(spec: SetSpec): DrawSet<LineItem>;
shapes(spec: SetSpec): DrawSet<ShapeItem>;
/** `models`. */
models?(spec: SetSpec): DrawSet<ModelItem>;
/** `quads`. */
quads?(spec: SetSpec): DrawSet<QuadItem>;
/** `meshes`. */
meshes?(spec: SetSpec): DrawSet<MeshItem>;
/** `fields`. */
field?(spec: FieldSpec): FieldHandle;
/** `celestial`: drawn in an inertial frame rotated by sidereal time. */
celestial?(spec: CelestialSpec): Handle;
/** `agents`. */
agents?(spec: SetSpec): DrawSet<AgentPathItem>;
/** Register an atlas of images icons refer to by slot. */
atlas(id: string, images: Record<string, ImageRef>): void;
}
+48
View File
@@ -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<string, number>): void;
setStyleIntensity(id: string, intensity: number): void;
setStyleParams(id: string, params: Record<string, number>): 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;
}
+76
View File
@@ -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<void>;
/** 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;
}
+210
View File
@@ -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<string>;
/** 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<ImageBitmap>;
/**
* 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<unknown> | undefined,
) => Promise<unknown> | 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<BaseStatus>;
/** `photoreal`: on, off or a new token route. */
setPhotoreal(spec: PhotorealSpec | null): Promise<PhotorealStatus>;
setTerrain(spec: TerrainSpec | null): Promise<void>;
/** 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<SkySettings>): 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;
}
+48
View File
@@ -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<string, import('./common').Capability | null>
>;
/** Every port's members, by port name. */
export declare const PORTS: Readonly<Record<string, PortDescriptor>>;
/** 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>,
): string[];
+370
View File
@@ -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<Record<string, Readonly<Record<string, import('./common').Capability|null>>>>}
*/
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<string>} 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;
}
+93
View File
@@ -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;
}
+116
View File
@@ -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<string, number>;
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<QualitySettings>;
/** Applied at the next frame; returns the keys it ignored as unsupported. */
set(settings: Partial<QualitySettings>): 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<BenchResult>;
}
+106
View File
@@ -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<Array<HeightSample | null>>;
/** `sampleHeight`: positions moved onto the surface; null per miss. */
clampToSurface(
positions: Ecef[],
options?: HeightOptions,
): Promise<Array<Ecef | null>>;
/** `rayCast`: first surface hit along a ray. */
rayCast(
ray: Ray,
options?: HeightOptions & { maxDistance?: number },
): Promise<Ecef | null>;
}
+114
View File
@@ -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<Renderer>;
}
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<string>;
/** 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<SkySettings>;
/** 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<Capability>;
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<Blob>;
/**
* 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:<id>'`, `'models'`, ...); empty when settled.
*/
whenSettled(options?: { timeoutMs?: number }): Promise<SettleResult>;
/** 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;
}
+950
View File
@@ -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;
},
};
}
+164
View File
@@ -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 };
}
+27
View File
@@ -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(),
};
}
File diff suppressed because it is too large. Load diff
+310
View File
@@ -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 };
+53
View File
@@ -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}`);
}
}
+77
View File
@@ -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;
}
});
+167
View File
@@ -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),
};
}
+6 -4
View File
@@ -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');