From 2fc99e7c19a5478b7c6166158a1507192802a68e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 4 Nov 2026 14:21:48 -0800 Subject: [PATCH] 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. --- .prettierignore | 2 + dev/conformance/index.html | 113 + dev/conformance/main.js | 215 ++ docs/CODE-BOUNDARIES.md | 3 +- docs/CURRENT-STATE.md | 14 + docs/RENDERER-CONTRACT.md | 158 ++ package-lock.json | 15 + package.json | 6 +- scripts/format-scope.json | 23 +- scripts/i18n.mjs | 3 +- scripts/package-boundaries.json | 22 + scripts/record-camera-goldens.mjs | 28 + server/standalone/conformance-references.js | 59 + server/standalone/vite.config.js | 2 + src/queries/heights.test.mjs | 6 +- src/render/cesium/cameraGoldens.js | 316 +++ src/render/cesium/cameraGoldens.test.mjs | 16 + src/render/cesium/internals.js | 9 + src/render/cesium/internals.test.mjs | 6 + src/render/cesium/sceneRenderer.js | 656 +++++ src/render/conformance/cameraRunner.js | 171 ++ src/render/conformance/cameraScripts.js | 422 +++ src/render/conformance/checks/camera.js | 505 ++++ src/render/conformance/checks/ports.js | 1163 +++++++++ src/render/conformance/expect.js | 93 + src/render/conformance/goldens/camera.json | 2290 +++++++++++++++++ .../conformance/goldens/visual/city-city.png | Bin 0 -> 25373 bytes .../conformance/goldens/visual/city-orbit.png | Bin 0 -> 9715 bytes .../goldens/visual/city-street.png | Bin 0 -> 9462 bytes .../goldens/visual/icons-tilted.png | Bin 0 -> 24159 bytes .../conformance/goldens/visual/icons-top.png | Bin 0 -> 25534 bytes .../goldens/visual/lines-tilted.png | Bin 0 -> 10066 bytes .../conformance/goldens/visual/lines-top.png | Bin 0 -> 10564 bytes .../conformance/goldens/visual/manifest.json | 20 + .../goldens/visual/models-oblique.png | Bin 0 -> 12776 bytes .../conformance/goldens/visual/points-top.png | Bin 0 -> 17195 bytes .../goldens/visual/shapes-tilted.png | Bin 0 -> 15011 bytes .../conformance/goldens/visual/shapes-top.png | Bin 0 -> 8203 bytes src/render/conformance/index.js | 123 + src/render/conformance/scenes.js | 558 ++++ src/render/conformance/scenes.test.mjs | 229 ++ src/render/conformance/visual.js | 227 ++ src/render/contract/camera.d.ts | 247 ++ src/render/contract/common.d.ts | 200 ++ src/render/contract/contract.test.mjs | 168 ++ src/render/contract/credits.d.ts | 21 + src/render/contract/draw.d.ts | 477 ++++ src/render/contract/effects.d.ts | 48 + src/render/contract/frame.d.ts | 76 + src/render/contract/globe.d.ts | 210 ++ src/render/contract/index.d.ts | 48 + src/render/contract/index.js | 370 +++ src/render/contract/input.d.ts | 93 + src/render/contract/quality.d.ts | 116 + src/render/contract/query.d.ts | 106 + src/render/contract/renderer.d.ts | 114 + src/render/stub/camera.js | 950 +++++++ src/render/stub/geodesic.js | 164 ++ src/render/stub/harness.js | 27 + src/render/stub/index.js | 1245 +++++++++ src/render/stub/math.js | 310 +++ src/render/stub/motion.js | 53 + src/render/stub/stub.test.mjs | 77 + src/render/stub/viewRectangle.js | 167 ++ src/tooling/viteBuild.test.mjs | 10 +- 65 files changed, 12761 insertions(+), 9 deletions(-) create mode 100644 dev/conformance/index.html create mode 100644 dev/conformance/main.js create mode 100644 docs/RENDERER-CONTRACT.md create mode 100644 scripts/record-camera-goldens.mjs create mode 100644 server/standalone/conformance-references.js create mode 100644 src/render/cesium/cameraGoldens.js create mode 100644 src/render/cesium/cameraGoldens.test.mjs create mode 100644 src/render/cesium/sceneRenderer.js create mode 100644 src/render/conformance/cameraRunner.js create mode 100644 src/render/conformance/cameraScripts.js create mode 100644 src/render/conformance/checks/camera.js create mode 100644 src/render/conformance/checks/ports.js create mode 100644 src/render/conformance/expect.js create mode 100644 src/render/conformance/goldens/camera.json create mode 100644 src/render/conformance/goldens/visual/city-city.png create mode 100644 src/render/conformance/goldens/visual/city-orbit.png create mode 100644 src/render/conformance/goldens/visual/city-street.png create mode 100644 src/render/conformance/goldens/visual/icons-tilted.png create mode 100644 src/render/conformance/goldens/visual/icons-top.png create mode 100644 src/render/conformance/goldens/visual/lines-tilted.png create mode 100644 src/render/conformance/goldens/visual/lines-top.png create mode 100644 src/render/conformance/goldens/visual/manifest.json create mode 100644 src/render/conformance/goldens/visual/models-oblique.png create mode 100644 src/render/conformance/goldens/visual/points-top.png create mode 100644 src/render/conformance/goldens/visual/shapes-tilted.png create mode 100644 src/render/conformance/goldens/visual/shapes-top.png create mode 100644 src/render/conformance/index.js create mode 100644 src/render/conformance/scenes.js create mode 100644 src/render/conformance/scenes.test.mjs create mode 100644 src/render/conformance/visual.js create mode 100644 src/render/contract/camera.d.ts create mode 100644 src/render/contract/common.d.ts create mode 100644 src/render/contract/contract.test.mjs create mode 100644 src/render/contract/credits.d.ts create mode 100644 src/render/contract/draw.d.ts create mode 100644 src/render/contract/effects.d.ts create mode 100644 src/render/contract/frame.d.ts create mode 100644 src/render/contract/globe.d.ts create mode 100644 src/render/contract/index.d.ts create mode 100644 src/render/contract/index.js create mode 100644 src/render/contract/input.d.ts create mode 100644 src/render/contract/quality.d.ts create mode 100644 src/render/contract/query.d.ts create mode 100644 src/render/contract/renderer.d.ts create mode 100644 src/render/stub/camera.js create mode 100644 src/render/stub/geodesic.js create mode 100644 src/render/stub/harness.js create mode 100644 src/render/stub/index.js create mode 100644 src/render/stub/math.js create mode 100644 src/render/stub/motion.js create mode 100644 src/render/stub/stub.test.mjs create mode 100644 src/render/stub/viewRectangle.js diff --git a/.prettierignore b/.prettierignore index 2b2cc755a..9e1a01ef8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,6 +9,8 @@ coverage pinokio/ENVIRONMENT pinokio/.installed src/data/local_data/ +# Written by scripts/record-camera-goldens.mjs and compared byte for byte. +src/render/conformance/goldens/ *.geojson *.geojsonl diff --git a/dev/conformance/index.html b/dev/conformance/index.html new file mode 100644 index 000000000..1cd9bb7c5 --- /dev/null +++ b/dev/conformance/index.html @@ -0,0 +1,113 @@ + + + + + + Renderer conformance + + + +
+

Renderer conformance

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

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

+ ${report.results + .filter((r) => r.status !== 'pass') + .map( + (r) => + ``, + ) + .join('')}
${r.status}${r.id}${escape(r.detail ?? '')}
`; + status(''); + return { + passed: report.passed, + failed: report.failed, + skipped: report.skipped, + failures: report.results.filter((r) => r.status === 'fail'), + }; +} + +const escape = (s) => + s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]); + +async function pixels(blob) { + const bitmap = await createImageBitmap(blob); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const g = canvas.getContext('2d'); + g.drawImage(bitmap, 0, 0); + return g.getImageData(0, 0, bitmap.width, bitmap.height); +} + +// Object URLs shown for the current run, revoked when the next one starts. +let objectUrls = []; +function objectUrl(blob) { + const url = URL.createObjectURL(blob); + objectUrls.push(url); + return url; +} + +async function toUrl(imageData) { + const canvas = new OffscreenCanvas(imageData.width, imageData.height); + canvas.getContext('2d').putImageData(imageData, 0, 0); + return objectUrl(await canvas.convertToBlob()); +} + +/** The views the stored manifest lists, or none. */ +async function storedViews() { + try { + const res = await fetch(REFERENCES + 'manifest.json', { + cache: 'no-store', + }); + return res.ok ? ((await res.json()).views ?? []) : []; + } catch { + return []; + } +} + +async function reference(name) { + const res = await fetch(REFERENCES + name, { cache: 'no-store' }); + return res.ok && res.headers.get('content-type')?.startsWith('image/') + ? res.blob() + : null; +} + +/** Draws every scene; records references or compares against them. */ +async function runScenes({ record = false, only } = {}) { + const which = $('renderer').value; + const renderer = await RENDERERS[which](); + const results = []; + $('views').innerHTML = ''; + for (const url of objectUrls) URL.revokeObjectURL(url); + objectUrls = []; + try { + for (const scene of REFERENCE_SCENES) { + if (only && !only.includes(scene.id)) continue; + const unsupported = sceneSupport(scene, renderer, ctx); + if (unsupported) { + results.push({ scene: scene.id, status: 'skip', detail: unsupported }); + continue; + } + status(`${scene.id}…`); + await drawScene(renderer, scene, ctx, async (view, capture, settle) => { + const name = referenceName(scene.id, view.name); + const figure = document.createElement('figure'); + $('views').append(figure); + if (settle.state !== 'settled') { + const detail = `${settle.state}: ${settle.blockers.join(', ')}`; + results.push({ + scene: scene.id, + view: view.name, + status: 'fail', + detail, + }); + figure.innerHTML = `
${name}: ${escape(detail)}
`; + return; + } + if (record) { + const res = await fetch(SAVE + name, { + method: 'PUT', + body: capture, + }); + results.push({ + scene: scene.id, + view: view.name, + status: res.ok ? 'recorded' : 'fail', + detail: res.ok ? undefined : `save ${res.status}`, + }); + figure.innerHTML = `
${name}: recorded
`; + return; + } + const refBlob = await reference(name); + if (!refBlob) { + results.push({ + scene: scene.id, + view: view.name, + status: 'fail', + detail: 'no reference', + }); + figure.innerHTML = `
${name}: no reference
`; + return; + } + const [actual, expected] = await Promise.all([ + pixels(capture), + pixels(refBlob), + ]); + const diff = new Uint8ClampedArray(actual.data.length); + const outcome = compareImages(actual, expected, { diff }); + results.push({ + scene: scene.id, + view: view.name, + status: outcome.match ? 'pass' : 'fail', + diffFraction: outcome.diffFraction, + solidPixels: outcome.solidPixels, + mismatchedPixels: outcome.mismatchedPixels, + ...(outcome.reason ? { detail: outcome.reason } : {}), + }); + const diffUrl = outcome.reason + ? '' + : await toUrl(new ImageData(diff, actual.width, actual.height)); + figure.innerHTML = `
+
${name}: ${outcome.solidPixels} solid, ${outcome.mismatchedPixels} unexcused, ${(outcome.diffFraction * 100).toFixed(3)}% differ ${outcome.reason ?? ''}
`; + }); + } + if (record) { + // A partial recording keeps the other scenes' entries. + const kept = only + ? (await storedViews()).filter( + (view) => !only.some((id) => view.startsWith(`${id}-`)), + ) + : []; + const recorded = results + .filter((r) => r.status === 'recorded') + .map((r) => referenceName(r.scene, r.view)); + await fetch(SAVE + 'manifest.json', { + method: 'PUT', + body: + JSON.stringify( + { + renderer: which, + size: { + width: renderer.canvas.width, + height: renderer.canvas.height, + }, + views: [...kept, ...recorded], + }, + null, + 2, + ) + '\n', + }); + } + } finally { + renderer.destroy(); + status(''); + } + return results; +} + +$('logic').onclick = () => runLogic(); +$('check').onclick = () => runScenes(); +$('record').onclick = () => runScenes({ record: true }); +window.conformance = { runLogic, runScenes }; diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index f0bb9e979..7e95f7203 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -33,12 +33,13 @@ export inventory. Use declared exports rather than reaching into internal files. | `src/layers//` | Source acquisition, records and Cesium resources with explicit controller/renderer owners | | `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance | | `src/services/` | Supplied application operations; scene construction owns caches and cancellation | +| `src/render/` | The renderer contract, the stub renderer, the conformance suite and Cesium renderer pieces | | `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters | | `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup | | `server/standalone/` | Environment, local settings writes and server composition | See [application construction](APPLICATION.md) and the -[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces. +[infrastructure contract](INFRASTRUCTURE-LAYERS.md) for construction interfaces, and the [renderer contract](RENDERER-CONTRACT.md) for what a renderer provides. ## Import direction gates diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b68da8fa3..df8bf6d1e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5994,6 +5994,20 @@ easier to meet (detection is now on more often), but does not create it. above and the DISPLAY-rail entry below (was `OFF`). - Style shader starting params: CRT/NVG/FLIR pixelation `1.2` (just above the native `1.0` floor); thermal/FLIR ships an optional Ironbow "Predator" palette (`palette` uniform, default `0` = accurate grayscale). +## Renderer contract + +`src/render/contract/` types the renderer contract, version `0`: nine ports +(frame, globe, draw, camera, query, input, effects, credits and quality), +plus the renderer itself. `PORTS` lists every member at run time, and +`contract.test.mjs` keeps that list and the types in step. The application +does not yet draw through the contract: it still drives Cesium directly. +`src/render/stub/` is a renderer that draws nothing, with the default +camera's exact maths. `src/render/conformance/` is the suite: logic checks +in Node, plus camera traces recorded from Cesium's own `Camera` into +`goldens/camera.json`. It also holds the reference scenes, whose views are +checked against `goldens/visual/` on the `/dev/conformance/` page. See +[RENDERER-CONTRACT.md](RENDERER-CONTRACT.md). + ## Operational Notes - **Earthquake discs are STATIC geometry.** Every quake is a `CLAMP_TO_GROUND` diff --git a/docs/RENDERER-CONTRACT.md b/docs/RENDERER-CONTRACT.md new file mode 100644 index 000000000..fb38a01a1 --- /dev/null +++ b/docs/RENDERER-CONTRACT.md @@ -0,0 +1,158 @@ +# Renderer contract + +The renderer contract is what the application asks of a 3D renderer. A +renderer other than the default Cesium one can draw the same application if +it meets the contract. The contract is version `0`. Today the application +still drives Cesium directly; later work moves the layers onto the contract +one family at a time. + +The package exports three modules: + +| Export | What it is | +| ---------------------------------- | ------------------------------------------------------------------------------ | +| `gods-eye-view/render/contract` | The types, one `.d.ts` per port, plus the run-time member list and shape check | +| `gods-eye-view/render/stub` | A renderer that draws nothing, for tests | +| `gods-eye-view/render/conformance` | The conformance suite, reference scenes and visual comparison | + +## Ports + +A renderer is created by a `RendererPlugin` (`{ id, create(options) }`). It +exposes nine ports, each typed in its own file in `src/render/contract/`: + +| Port | File | Covers | +| --------- | -------------- | ------------------------------------------------------------------------------ | +| `frame` | `frame.d.ts` | The render-on-demand loop, frame phases, continuous holds, scene time | +| `globe` | `globe.d.ts` | Base (photoreal, terrain, vector, imagery), imagery layers, split, sky | +| `draw` | `draw.d.ts` | Draw sets: icons, points, lines, shapes, models, quads, meshes, fields, agents | +| `camera` | `camera.d.ts` | Pose, views, flights, `lookAt`, follow, user controls, events, matrices | +| `query` | `query.d.ts` | Picks, projection, rays, heights | +| `input` | `input.d.ts` | Pointer events with their pick made, leases, stock behaviours | +| `effects` | `effects.d.ts` | Screen styles, bloom, sharpen, screen modulation | +| `credits` | `credits.d.ts` | Data credits owed | +| `quality` | `quality.d.ts` | Quality knobs, device facts, frame signals, benchmark | + +The renderer itself (`renderer.d.ts`) adds capture, pixel reads, settling, +resize, context loss and `destroy`. `common.d.ts` holds the shared shapes +(ECEF positions, colours, listener semantics) and the capability list. + +The rules that hold everywhere: + +- **Core keeps its own records.** It never reads a renderer to learn its own + state, only what the renderer measures: picks, depth, heights and + readiness. +- **Capabilities.** An optional member (`?`) exists only with the capability + its documentation names first. A required member that names a capability + always exists, and without the capability it answers null or empty. + Quality support is listed by `quality.knobs()`, not by capabilities. +- **Heights are physical.** All heights are metres above the ellipsoid, + unexaggerated. Only drawing follows exaggerated terrain. +- **Listeners** run in subscription order. A listener removed during + delivery does not run; one added during delivery runs from the next event. + A listener that throws is reported to the renderer's `error` event, or to + the platform's `reportError` when nothing listens for errors. The other + listeners, and the frame, still run. +- **The frame order is fixed:** camera flight → input → camera `changed` → + follow → `preUpdate` → move detection → `preRender` → render → + `postRender`. + +`src/render/contract/index.js` lists every member of every port in `PORTS`, +along with `CAPABILITIES`, `FRAME_STEPS` and `QUALITY_KEYS`. +`checkRendererShape(renderer)` names each required member that is missing, +and each capability member present without its capability. +`contract.test.mjs` type-checks the contract under strict TypeScript, and +fails when `PORTS` and the types disagree on a single member. + +## The stub renderer + +`createStubRenderer({ capabilities, viewport, time })` implements every port +in memory and draws nothing. It has: + +- the default camera's exact maths: views, `lookAt`, flights with their + height arc and easing, `changed` thresholds and follows; +- picks against what was drawn, or a scripted table; +- scripted heights; +- a virtual clock that nothing advances on its own. + +`renderer.testing` drives it with `tick()`, `advance(ms)`, `pointer(type, x, y)`, +`scriptPicks`, `setHeights` and `cacheHeight`, and records its calls. +A listener that throws with nothing subscribed to `error` goes to +`reportError`; Node has none, so the stub rethrows it on a microtask and +`node:test` fails the running test. A test that expects a throw subscribes +to `error`. +The stub passes the whole conformance suite (`src/render/stub/stub.test.mjs`). + +## Conformance suite + +`runConformance(harness)` runs every check against a renderer through the +contract alone and reports each one as passed, failed or skipped. It runs in +Node and in a browser page. A harness tells the suite how to drive the +renderer under test: + +- `create({ viewport, capabilities })`; +- `frame(r)`: one frame, 1/60 s on a virtual clock; +- `now(r)` and `toEcef(p)`; +- `tolerance` and `flightPaths`. + +Optional members widen what is checked: `pointer` for synthetic input, +`scriptHeights`, and `assets.model`. + +There are two layers of checks. + +**Logic checks** run in CI. They cover: + +- the shape check; +- listener semantics and the frame loop; +- draw-set bookkeeping, picks and the `meta` round-trip; +- projection, rays and heights; +- leases and click picks; +- imagery order, sky, styles, credits and quality knobs; +- the camera traces. + +**Camera traces.** `cameraScripts.js` lists camera scripts in the contract's +terms. `scripts/record-camera-goldens.mjs` runs them on Cesium's own +`Camera`, headless, on a virtual clock, and writes +`src/render/conformance/goldens/camera.json`. A renderer under test must +match each recorded pose: + +- within 1 mm in position, 1e-9 in unit vectors and angles, and 1e-3 px on + screen; +- with every flight ending within one frame of the recording. + +`src/render/cesium/cameraGoldens.test.mjs` fails when the installed Cesium +no longer records the committed traces. After a Cesium upgrade or a script +change, run `node scripts/record-camera-goldens.mjs` and review the diff. + +**Visual checks** run in the browser. The reference scenes +(`src/render/conformance/scenes.js`) are built from contract calls only: + +- one scene per primitive family; +- a city scene; +- a calibration path from orbit down to the street. + +The scenes load nothing from the network. + +`npm run dev`, then open `/dev/conformance/`. The page can: + +- **Run logic suite (stub)** runs the logic checks in the browser. +- **Record references** draws every scene the chosen renderer supports. + It saves each view to `src/render/conformance/goldens/visual/`, through a + dev-server-only endpoint. +- **Check scenes** draws the scenes again and compares each capture with its + reference. Pixels are compared as pixelmatch compares them: by YIQ + difference (threshold 0.1), with antialiasing in either image excused. A + capture matches when at most 250 pixels are unexcused and at most 2 are + solid (a differing pixel whose four neighbours differ too). Between them + the two catch a dropped 4 px point, a dropped 2 px line and a 1 px shift + of the view, and pass any MSAA setting. A change nowhere 3 px thick (a + thin line gone, an item moved a pixel or two) that covers at most 250 px + passes. `visual.js` has the measurements. + The page shows the capture, the reference and the difference side by side. + +`window.conformance.runScenes()` and `runLogic()` do the same from a script. +Browser checks use the chrome-devtools MCP against a page in its own window. + +The Cesium scene renderer (`src/render/cesium/sceneRenderer.js`) draws the +icon, point, line, shape, model and city scenes, and the references were +recorded with it. It has only the members the scenes need. The quad, mesh, +field and agent scenes have no reference yet. Each family's reference is +recorded when the Cesium renderer gains that family. diff --git a/package-lock.json b/package-lock.json index dd60d3383..e6caa40f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "prettier": "3.9.6", "puppeteer": "^25.16.0", "sharp": "^0.35.4", + "typescript": "5.9.3", "vite": "^6.4.4", "vite-plugin-cesium": "^1.2.23", "ws": "^8.21.0" @@ -3545,6 +3546,20 @@ "dev": true, "license": "MIT" }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", diff --git a/package.json b/package.json index 9e2e96cfa..aecb52ce5 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "prettier": "3.9.6", "puppeteer": "^25.16.0", "sharp": "^0.35.4", + "typescript": "5.9.3", "vite": "^6.4.4", "vite-plugin-cesium": "^1.2.23", "ws": "^8.21.0" @@ -293,6 +294,9 @@ "./tools/panel": "./src/tools/panel.js", "./tools/services": "./src/tools/services.js", "./view": "./src/view/index.js", - "./agent/world-packet": "./src/agent/worldPacket.js" + "./agent/world-packet": "./src/agent/worldPacket.js", + "./render/contract": "./src/render/contract/index.js", + "./render/stub": "./src/render/stub/index.js", + "./render/conformance": "./src/render/conformance/index.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 15a0305ef..b3e77482e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1121,5 +1121,26 @@ "src/cameraGlobeRotation.test.mjs", "src/voice/globeResetConfirmation.test.mjs", "src/hudDms.test.mjs", - "server/providers/common/rateLimitKeys.test.mjs" + "server/providers/common/rateLimitKeys.test.mjs", + "src/render/contract/contract.test.mjs", + "src/render/contract/common.d.ts", + "src/render/contract/renderer.d.ts", + "src/render/contract/frame.d.ts", + "src/render/contract/globe.d.ts", + "src/render/contract/draw.d.ts", + "src/render/contract/camera.d.ts", + "src/render/contract/query.d.ts", + "src/render/contract/input.d.ts", + "src/render/contract/effects.d.ts", + "src/render/contract/credits.d.ts", + "src/render/contract/quality.d.ts", + "src/render/contract/index.d.ts", + "src/render/stub/stub.test.mjs", + "src/render/conformance/scenes.test.mjs", + "src/render/cesium/cameraGoldens.test.mjs", + "scripts/record-camera-goldens.mjs", + "server/standalone/conformance-references.js", + "dev/conformance/index.html", + "dev/conformance/main.js", + "docs/RENDERER-CONTRACT.md" ] diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index c424666a2..7baf43dc2 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -479,7 +479,8 @@ function readMarkup(file) { // Text that never reaches the page as UI: tool results and descriptions for // AI clients (MCP and the voice model). -const NOT_UI = [/^src\/tools\//]; +// The conformance suite's check titles are developer output, not UI. +const NOT_UI = [/^src\/tools\//, /^src\/render\/conformance\//]; function sourceFiles() { return trackedFiles( diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9c2c253bd..744e777b3 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -5126,5 +5126,27 @@ "exports": ["./queries/heights"], "modules": ["src/queries/heights.js"], "external": ["cesium"] + }, + "render": { + "exports": ["./render/contract", "./render/stub", "./render/conformance"], + "modules": [ + "src/render/contract/index.js", + "src/render/stub/index.js", + "src/render/conformance/index.js", + "src/render/conformance/scenes.js", + "src/render/conformance/cameraScripts.js", + "src/render/conformance/visual.js", + "src/render/conformance/cameraRunner.js", + "src/render/conformance/expect.js", + "src/render/conformance/checks/ports.js", + "src/render/stub/math.js", + "src/render/stub/motion.js", + "src/render/stub/camera.js", + "src/render/stub/geodesic.js", + "src/render/stub/viewRectangle.js", + "src/render/conformance/goldens/camera.json", + "src/render/conformance/checks/camera.js" + ], + "external": [] } } diff --git a/scripts/record-camera-goldens.mjs b/scripts/record-camera-goldens.mjs new file mode 100644 index 000000000..17f4131bc --- /dev/null +++ b/scripts/record-camera-goldens.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +// Writes the reference camera traces the conformance suite compares a +// renderer against: src/render/conformance/goldens/camera.json. Run it after +// a Cesium upgrade or a change to the camera scripts, and review the diff. +// +// node scripts/record-camera-goldens.mjs [--check] +// +// With --check it writes nothing and exits 1 when the file is out of date. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { recordCameraGoldens } from '../src/render/cesium/cameraGoldens.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const OUT = path.join(ROOT, 'src/render/conformance/goldens/camera.json'); + +const text = `${JSON.stringify(await recordCameraGoldens(), null, 2)}\n`; +if (process.argv.includes('--check')) { + const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : ''; + if (current !== text) { + console.error(`${path.relative(ROOT, OUT)} is out of date`); + process.exit(1); + } +} else { + fs.mkdirSync(path.dirname(OUT), { recursive: true }); + fs.writeFileSync(OUT, text); + console.log(`wrote ${path.relative(ROOT, OUT)}`); +} diff --git a/server/standalone/conformance-references.js b/server/standalone/conformance-references.js new file mode 100644 index 000000000..669fef616 --- /dev/null +++ b/server/standalone/conformance-references.js @@ -0,0 +1,59 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const DIR = fileURLToPath( + new URL('../../src/render/conformance/goldens/visual/', import.meta.url), +); +const NAME = /^[a-z0-9-]+\.(png|json)$/; +const MAX_BYTES = 8 * 1024 * 1024; + +/** + * Dev server only: lets `/dev/conformance/` save the visual references it + * records into `src/render/conformance/goldens/visual/`. Takes a PUT of one + * PNG or the manifest, named in the path; nothing else is written. + */ +export function conformanceReferencesPlugin() { + return { + name: 'conformance-references', + apply: 'serve', + configureServer(server) { + server.middlewares.use('/__conformance/references/', (req, res) => { + const name = decodeURIComponent((req.url ?? '').replace(/^\//, '')); + // Only the page on this machine: a loopback peer, sent from the + // dev server's own origin. + const peer = req.socket.remoteAddress ?? ''; + const loopback = /^(127\.|::1$|::ffff:127\.)/.test(peer); + let sameOrigin = false; + try { + sameOrigin = new URL(req.headers.origin).host === req.headers.host; + } catch { + // No Origin, or not a URL. + } + if (!loopback || !sameOrigin) { + res.writeHead(403).end(); + return; + } + if (req.method !== 'PUT' || !NAME.test(name)) { + res.writeHead(400).end(); + return; + } + const chunks = []; + let size = 0; + req.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_BYTES) req.destroy(); + else chunks.push(chunk); + }); + req.on('end', async () => { + try { + await mkdir(DIR, { recursive: true }); + await writeFile(DIR + name, Buffer.concat(chunks)); + res.writeHead(204).end(); + } catch (error) { + res.writeHead(500).end(String(error?.message ?? error)); + } + }); + }); + }, + }; +} diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index 76d26ba5d..f660bfbd3 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -5,6 +5,7 @@ import { createBrowserViteConfig } from '../../build/vite.js'; import { localProviderPlugins } from '../providers/local.js'; import { localMcpPlugin } from '../mcp/plugin.js'; import { apiNotFoundPlugin } from './api-not-found.js'; +import { conformanceReferencesPlugin } from './conformance-references.js'; import { googleTokenUnavailablePlugin } from './google-token-unavailable.js'; import { standaloneVoiceTools } from './voiceTools.js'; import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js'; @@ -30,6 +31,7 @@ export function standaloneConfig( }), localMcpPlugin(), googleTokenUnavailablePlugin(), + conformanceReferencesPlugin(), apiNotFoundPlugin(), unwatchedRuntimeDirsPlugin(), ], diff --git a/src/queries/heights.test.mjs b/src/queries/heights.test.mjs index 7acf03047..0cd2620be 100644 --- a/src/queries/heights.test.mjs +++ b/src/queries/heights.test.mjs @@ -193,6 +193,9 @@ test('height queries go through the service', () => { // A renderer that answers heights from a cache or asynchronously plugs in // at src/queries/heights.js; a site that asks Cesium directly bypasses it. const SERVICE = 'src/queries/heights.js'; + // The renderer contract's own height members, and the stub and suite that + // implement and call them, are not Cesium. + const CONTRACT = /^src\/render\/(contract|stub|conformance)\//; const NAMES = 'sampleHeight|clampToHeight|sampleHeightMostDetailed|clampToHeightMostDetailed|getHeight'; const direct = new RegExp( @@ -207,7 +210,8 @@ test('height queries go through the service', () => { return /\.(js|vue)$/.test(entry.name) ? [rel] : []; }); const offenders = walk('src').filter((file) => { - if (file === SERVICE) return false; + if (file === SERVICE || CONTRACT.test(file.split(path.sep).join('/'))) + return false; const code = fs .readFileSync(path.join(ROOT, file), 'utf8') .replace(/\/\*[\s\S]*?\*\//g, '') diff --git a/src/render/cesium/cameraGoldens.js b/src/render/cesium/cameraGoldens.js new file mode 100644 index 000000000..f68665464 --- /dev/null +++ b/src/render/cesium/cameraGoldens.js @@ -0,0 +1,316 @@ +/** + * @module render/cesium/cameraGoldens + * @description Records the reference camera traces: every conformance camera + * script run on Cesium's own `Camera`, headless, on a virtual clock that + * advances exactly 1/60 s per frame and restarts per script. No globe and no + * tiles, so the traces depend on the camera alone and are bit-identical run + * to run. `scripts/record-camera-goldens.mjs` writes them to + * `src/render/conformance/goldens/camera.json`. + * + * Cesium has one `changed` threshold per camera, where the contract has one + * per listener, so a script that listens at several thresholds is run once + * per threshold and each count is taken from its own run. + */ +import * as Cesium from 'cesium'; +import { + CAMERA_SCRIPTS, + SCRIPT_VIEWPORT, +} from '../conformance/cameraScripts.js'; +import { runCameraScript } from '../conformance/cameraRunner.js'; +import { updateCameraChanged } from './internals.js'; + +const FRAME_MS = 1000 / 60; + +/** The contract's easings as Cesium's. */ +const EASINGS = { + linear: Cesium.EasingFunction.LINEAR_NONE, + quadraticInOut: Cesium.EasingFunction.QUADRATIC_IN_OUT, + cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT, + cubicOut: Cesium.EasingFunction.CUBIC_OUT, + quinticInOut: Cesium.EasingFunction.QUINTIC_IN_OUT, +}; + +/** Run `fn` with `performance.now()` reading the virtual clock. */ +async function withVirtualClock(fn) { + const real = Object.getOwnPropertyDescriptor(globalThis, 'performance'); + const clock = { now: 0 }; + Object.defineProperty(globalThis, 'performance', { + configurable: true, + value: { now: () => clock.now, mark() {}, measure() {} }, + }); + try { + return await fn(clock); + } finally { + if (real) Object.defineProperty(globalThis, 'performance', real); + } +} + +function headlessCamera() { + const scene = { + canvas: { + clientWidth: SCRIPT_VIEWPORT.width, + clientHeight: SCRIPT_VIEWPORT.height, + }, + drawingBufferWidth: SCRIPT_VIEWPORT.width, + drawingBufferHeight: SCRIPT_VIEWPORT.height, + pixelRatio: 1, + mapProjection: new Cesium.GeographicProjection(), + mode: Cesium.SceneMode.SCENE3D, + tweens: new Cesium.TweenCollection(), + globe: undefined, + ellipsoid: Cesium.Ellipsoid.WGS84, + screenSpaceCameraController: { + minimumZoomDistance: 1, + maximumZoomDistance: Number.POSITIVE_INFINITY, + enableInputs: true, + }, + frameState: { mode: Cesium.SceneMode.SCENE3D }, + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + camera.frustum.aspectRatio = SCRIPT_VIEWPORT.width / SCRIPT_VIEWPORT.height; + return { scene, camera }; +} + +const cart = (p) => new Cesium.Cartesian3(p.x, p.y, p.z); +const toEcef = (p) => + 'lon' in p + ? Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height ?? 0) + : cart(p); + +function destinationOf(destination) { + if (!destination) return undefined; + if ('rect' in destination) { + const r = destination.rect; + return Cesium.Rectangle.fromDegrees(r.west, r.south, r.east, r.north); + } + return toEcef(destination); +} + +function orientationOf(orientation) { + if (!orientation) return undefined; + if ('direction' in orientation) + return { direction: cart(orientation.direction), up: cart(orientation.up) }; + return { ...orientation }; +} + +/** A driver for {@link runCameraScript} over a headless Cesium camera. */ +function cesiumDriver(clock, threshold) { + const { scene, camera } = headlessCamera(); + let flying = false; + let changedCount = 0; + camera.percentageChanged = threshold ?? 0.5; + if (threshold !== undefined) + camera.changed.addEventListener(() => (changedCount += 1)); + + function flight(start) { + let resolve; + const done = new Promise((r) => (resolve = r)); + flying = true; + start( + () => { + flying = false; + resolve('complete'); + }, + () => { + flying = false; + resolve('cancelled'); + }, + ); + return { done, cancel: () => camera.cancelFlight() }; + } + + const commands = { + setView({ destination, orientation } = {}) { + camera.setView({ + destination: destinationOf(destination), + orientation: orientationOf(orientation), + }); + }, + lookAt(target, offset) { + camera.lookAt( + cart(target), + new Cesium.HeadingPitchRange( + offset.heading, + offset.pitch, + offset.range, + ), + ); + }, + release() { + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + }, + move(direction, metres) { + const name = { + forward: 'moveForward', + back: 'moveBackward', + left: 'moveLeft', + right: 'moveRight', + up: 'moveUp', + down: 'moveDown', + }[direction]; + camera[name](metres); + }, + look(direction, radians) { + const name = { + left: 'lookLeft', + right: 'lookRight', + up: 'lookUp', + down: 'lookDown', + }[direction]; + camera[name](radians); + }, + rotateAround(axis, radians) { + camera.rotate(cart(axis), radians); + }, + zoom(metres) { + camera.zoomIn(metres); + }, + flyTo(destination, options = {}) { + return flight((complete, cancel) => + camera.flyTo({ + destination: destinationOf(destination), + orientation: orientationOf(options.orientation), + duration: options.durationS, + maximumHeight: options.maxHeight, + pitchAdjustHeight: options.pitchAdjustHeight, + easingFunction: EASINGS[options.easing], + complete, + cancel, + }), + ); + }, + flyToBounds(sphere, options = {}) { + return flight((complete, cancel) => + camera.flyToBoundingSphere( + new Cesium.BoundingSphere(cart(sphere.center), sphere.radius), + { + offset: options.offset + ? new Cesium.HeadingPitchRange( + options.offset.heading, + options.offset.pitch, + options.offset.range, + ) + : undefined, + duration: options.durationS, + complete, + cancel, + }, + ), + ); + }, + cancelFlight() { + camera.cancelFlight(); + }, + }; + + const viewProjection = () => + Cesium.Matrix4.multiply( + camera.frustum.projectionMatrix, + camera.viewMatrix, + new Cesium.Matrix4(), + ); + + return { + command: (name, args) => commands[name](...args), + frame() { + clock.now += FRAME_MS; + scene.tweens.update(); + updateCameraChanged(camera); + }, + listen: () => () => changedCount, + pose() { + const c = camera.positionCartographic; + return { + position: camera.positionWC, + direction: camera.directionWC, + up: camera.upWC, + right: camera.rightWC, + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + height: c.height, + near: camera.frustum.near, + far: camera.frustum.far, + frameIsIdentity: Cesium.Matrix4.equals( + camera.transform, + Cesium.Matrix4.IDENTITY, + ), + }; + }, + viewRectangle() { + const rect = camera.computeViewRectangle(Cesium.Ellipsoid.WGS84); + if (!rect) return null; + const deg = Cesium.Math.toDegrees; + return { + west: deg(rect.west), + south: deg(rect.south), + east: deg(rect.east), + north: deg(rect.north), + }; + }, + matrices: () => ({ + view: Float64Array.from(Cesium.Matrix4.toArray(camera.viewMatrix)), + projection: Float64Array.from( + Cesium.Matrix4.toArray(camera.frustum.projectionMatrix), + ), + }), + project(position) { + const clip = Cesium.Matrix4.multiplyByVector( + viewProjection(), + new Cesium.Cartesian4(position.x, position.y, position.z, 1), + new Cesium.Cartesian4(), + ); + if (clip.w <= 0) return null; + return { + x: ((clip.x / clip.w + 1) / 2) * SCRIPT_VIEWPORT.width, + y: ((1 - clip.y / clip.w) / 2) * SCRIPT_VIEWPORT.height, + }; + }, + pickEllipsoid: (s) => + camera.pickEllipsoid(new Cesium.Cartesian2(s.x, s.y)) ?? null, + pickRay: (s) => camera.getPickRay(new Cesium.Cartesian2(s.x, s.y)), + occluded: (p) => + !new Cesium.EllipsoidalOccluder( + Cesium.Ellipsoid.WGS84, + camera.positionWC, + ).isPointVisible(p), + flying: () => flying, + toEcef: (p) => { + const e = toEcef(p); + return { x: e.x, y: e.y, z: e.z }; + }, + }; +} + +/** Run one script on Cesium, once per `changed` threshold it listens at. */ +async function recordScript(script) { + const thresholds = script.steps + .filter((step) => step.do === 'listen') + .map((step) => step.threshold); + const base = await withVirtualClock((clock) => + runCameraScript(cesiumDriver(clock, undefined), script), + ); + if (!thresholds.length) return base; + const counts = []; + for (const threshold of thresholds) { + const run = await withVirtualClock((clock) => + runCameraScript(cesiumDriver(clock, threshold), script), + ); + const changed = run.records.find((r) => r.kind === 'changed'); + counts.push({ threshold, count: changed.value[0].count }); + } + for (const r of base.records) if (r.kind === 'changed') r.value = counts; + return base; +} + +/** + * Every script's trace on the installed Cesium. + * @returns {Promise<{cesium: string, viewport: object, scripts: Record}>} + */ +export async function recordCameraGoldens() { + const scripts = {}; + for (const script of CAMERA_SCRIPTS) + scripts[script.name] = await recordScript(script); + return { cesium: Cesium.VERSION, viewport: SCRIPT_VIEWPORT, scripts }; +} diff --git a/src/render/cesium/cameraGoldens.test.mjs b/src/render/cesium/cameraGoldens.test.mjs new file mode 100644 index 000000000..774b0a011 --- /dev/null +++ b/src/render/cesium/cameraGoldens.test.mjs @@ -0,0 +1,16 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { recordCameraGoldens } from './cameraGoldens.js'; + +test('the committed camera traces are what the installed Cesium records', async () => { + const committed = readFileSync( + new URL('../conformance/goldens/camera.json', import.meta.url), + 'utf8', + ); + const recorded = `${JSON.stringify(await recordCameraGoldens(), null, 2)}\n`; + assert.ok( + recorded === committed, + 'goldens/camera.json is out of date: run node scripts/record-camera-goldens.mjs and review the diff', + ); +}); diff --git a/src/render/cesium/internals.js b/src/render/cesium/internals.js index dd4ff2d0b..c7585803a 100644 --- a/src/render/cesium/internals.js +++ b/src/render/cesium/internals.js @@ -84,3 +84,12 @@ export function webglContext(scene) { export function cameraFlightActive(camera) { return camera?._currentFlight != null; } + +/** + * Run the camera's `changed` check, as a scene does once a frame. For a + * camera driven without a scene (the reference camera traces). + * @param {object} camera Cesium Camera. + */ +export function updateCameraChanged(camera) { + camera._updateCameraChanged(); +} diff --git a/src/render/cesium/internals.test.mjs b/src/render/cesium/internals.test.mjs index 6331163c0..fb596d152 100644 --- a/src/render/cesium/internals.test.mjs +++ b/src/render/cesium/internals.test.mjs @@ -12,6 +12,7 @@ import { commandExtents, defaultView, pickOffscreenView, + updateCameraChanged, webglContext, } from './internals.js'; @@ -47,6 +48,8 @@ test('every private field Core reaches is still declared by the installed Cesium ), ); assert.deepEqual(missing, []); + const camera = fs.readFileSync(path.join(ENGINE, 'Scene/Camera.js'), 'utf8'); + assert.match(camera, /Camera\.prototype\._updateCameraChanged = function/); }); test('the helpers read and write exactly those fields', () => { @@ -76,6 +79,9 @@ test('the helpers read and write exactly those fields', () => { assert.equal(webglContext({ context: { _gl: gl } }), gl); assert.equal(cameraFlightActive({ _currentFlight: {} }), true); assert.equal(cameraFlightActive({ _currentFlight: undefined }), false); + let checked = 0; + updateCameraChanged({ _updateCameraChanged: () => (checked += 1) }); + assert.equal(checked, 1); }); test('private Cesium fields are reached only through internals.js', () => { diff --git a/src/render/cesium/sceneRenderer.js b/src/render/cesium/sceneRenderer.js new file mode 100644 index 000000000..17376eb7c --- /dev/null +++ b/src/render/cesium/sceneRenderer.js @@ -0,0 +1,656 @@ +/** + * @module render/cesium/sceneRenderer + * @description The part of the Cesium renderer the reference scenes need, + * through the contract's own members: a bare globe and sky, icons, points, + * lines, shapes and models, `setView`, picks and projection, the frame + * phases, settling and capture. The `/dev/conformance/` page draws the + * scenes with it to make and check the visual references. + * + * It is not yet the whole Cesium renderer: each later migration adds the + * members its layer family needs, until it passes the whole suite. + * Members it does not have are absent, never stubbed. + */ +import * as Cesium from 'cesium'; + +const SKY_DEFAULTS = Object.freeze({ + atmosphere: false, + stars: false, + milkyWay: 0, + sun: false, + moon: false, + fog: false, + lighting: false, +}); + +/** The contract's colour as Cesium's. */ +export function toColor(color, alpha = 1) { + if (color === undefined || color === null) + return Cesium.Color.WHITE.withAlpha(alpha); + const c = + typeof color === 'string' + ? Cesium.Color.fromCssColorString(color) + : new Cesium.Color(color[0], color[1], color[2], color[3] ?? 1); + return c.withAlpha(c.alpha * alpha); +} + +/** An image reference as something a billboard takes. */ +export function toImage(image) { + if (typeof image === 'string') return image; + if (image?.svg) + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(image.svg)}`; + if (image?.canvas) return image.canvas; + throw new Error('the scene renderer takes URL, SVG and canvas images only'); +} + +/** A contract position as ECEF. */ +export function toCartesian(pos) { + if ('x' in pos) return new Cesium.Cartesian3(pos.x, pos.y, pos.z); + return Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, pos.height ?? 0); +} + +const ANCHORS = { + center: Cesium.VerticalOrigin.CENTER, + bottom: Cesium.VerticalOrigin.BOTTOM, + top: Cesium.VerticalOrigin.TOP, +}; + +/** + * @param {{container: HTMLElement, msaa?: number}} options + * @returns {Promise} A renderer with the members listed above. + */ +export async function createCesiumSceneRenderer({ container, msaa = 4 }) { + const credits = document.createElement('div'); + const widget = new Cesium.CesiumWidget(container, { + baseLayer: false, + terrainProvider: new Cesium.EllipsoidTerrainProvider(), + skyBox: false, + skyAtmosphere: false, + requestRenderMode: true, + maximumRenderTimeChange: Infinity, + msaaSamples: msaa, + creditContainer: credits, + contextOptions: { webgl: { preserveDrawingBuffer: true } }, + }); + const scene = widget.scene; + const camera = scene.camera; + // A fixed time, so lighting is the same in every capture. + widget.clock.shouldAnimate = false; + widget.clock.currentTime = Cesium.JulianDate.fromIso8601( + '2026-06-21T18:00:00Z', + ); + scene.globe.baseColor = Cesium.Color.fromCssColorString('#1c2a36'); + scene.backgroundColor = Cesium.Color.BLACK; + scene.globe.showGroundAtmosphere = false; + scene.fog.enabled = false; + if (scene.sun) scene.sun.show = false; + if (scene.moon) scene.moon.show = false; + + const sets = new Map(); + const sky = { ...SKY_DEFAULTS }; + const capabilities = new Set(['models']); + + function applySky() { + scene.globe.enableLighting = sky.lighting; + scene.fog.enabled = sky.fog; + if (scene.sun) scene.sun.show = sky.sun; + if (scene.moon) scene.moon.show = sky.moon; + scene.globe.showGroundAtmosphere = sky.atmosphere; + scene.requestRender(); + } + + const frameListeners = { preUpdate: [], preRender: [], postRender: [] }; + let frames = 0; + const onPhase = (phase) => (_scene, time) => { + const t = { + frame: frames, + time: Cesium.JulianDate.toDate( + time ?? widget.clock.currentTime, + ).getTime(), + now: performance.now(), + dt: 1 / 60, + rendering: phase !== 'preUpdate', + }; + for (const cb of frameListeners[phase].slice()) cb(t); + }; + const removers = [ + scene.preUpdate.addEventListener(() => (frames += 1)), + scene.preUpdate.addEventListener(onPhase('preUpdate')), + scene.preRender.addEventListener(onPhase('preRender')), + scene.postRender.addEventListener(onPhase('postRender')), + ]; + + function nextPostRender() { + return new Promise((resolve) => { + const off = scene.postRender.addEventListener(() => { + off(); + resolve(); + }); + scene.requestRender(); + }); + } + + const frame = { + on(phase, cb) { + frameListeners[phase].push(cb); + return () => { + const i = frameListeners[phase].indexOf(cb); + if (i >= 0) frameListeners[phase].splice(i, 1); + }; + }, + requestRender: () => scene.requestRender(), + renderNow: () => scene.render(widget.clock.currentTime), + renderOnce: () => nextPostRender(), + time: () => Cesium.JulianDate.toDate(widget.clock.currentTime).getTime(), + setTime(ms) { + widget.clock.currentTime = Cesium.JulianDate.fromDate(new Date(ms)); + widget.clock.shouldAnimate = false; + scene.requestRender(); + }, + }; + + // --- Draw ------------------------------------------------------------------ + + function makeSet(kind, spec, build) { + if (sets.has(spec.id)) throw new Error(`a set with id ${spec.id} is live`); + const items = new Map(); + const listeners = { ready: [] }; + const record = { kind, spec, items, listeners, host: null, rebuild: null }; + const handle = { + id: spec.id, + set(list) { + items.clear(); + for (const item of list) items.set(item.id, item); + build(record); + scene.requestRender(); + }, + upsert(list) { + for (const item of list) items.set(item.id, item); + build(record); + scene.requestRender(); + }, + remove(ids) { + for (const id of ids) items.delete(id); + build(record); + scene.requestRender(); + }, + clear() { + items.clear(); + build(record); + scene.requestRender(); + }, + setOptions(options) { + Object.assign(spec, options); + if (record.host) record.host.show = spec.show !== false; + scene.requestRender(); + }, + size: () => items.size, + get show() { + return spec.show !== false; + }, + set show(value) { + spec.show = value; + if (record.host) record.host.show = value; + scene.requestRender(); + }, + on(event, cb) { + listeners[event]?.push(cb); + return () => { + const list = listeners[event]; + const i = list?.indexOf(cb) ?? -1; + if (i >= 0) list.splice(i, 1); + }; + }, + dispose() { + if (record.host && !record.host.isDestroyed?.()) + scene.primitives.remove(record.host); + record.host = null; + // A newer set under the same id is not this handle's to remove. + if (sets.get(spec.id) === record) sets.delete(spec.id); + scene.requestRender(); + }, + }; + sets.set(spec.id, record); + return handle; + } + + const pickId = (record, item) => ({ + set: record.spec.id, + id: item.id, + meta: item.meta, + }); + + function replaceHost(record, host) { + if (record.host) scene.primitives.remove(record.host); + record.host = host; + host.show = record.spec.show !== false; + scene.primitives.add(host); + } + + function buildIcons(record) { + const host = new Cesium.BillboardCollection({ scene }); + for (const item of record.items.values()) { + const position = toCartesian(item.pos); + const options = { + position, + image: toImage(item.image), + width: item.size[0] * (item.scale ?? 1), + height: item.size[1] * (item.scale ?? 1), + color: toColor(item.color, item.alpha ?? 1), + verticalOrigin: ANCHORS[item.anchor ?? 'center'], + disableDepthTestDistance: item.disableDepthTest ?? 0, + show: item.show !== false, + id: pickId(record, item), + }; + if (item.rotationMode === 'screen') options.rotation = item.rotation ?? 0; + if (item.rotationMode === 'course') { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position); + options.alignedAxis = Cesium.Matrix4.multiplyByPointAsVector( + enu, + Cesium.Cartesian3.UNIT_Y, + new Cesium.Cartesian3(), + ); + options.rotation = -Cesium.Math.toRadians(item.course ?? 0); + } + host.add(options); + } + replaceHost(record, host); + } + + function buildPoints(record) { + const host = new Cesium.PointPrimitiveCollection(); + for (const item of record.items.values()) + host.add({ + position: toCartesian(item.pos), + pixelSize: item.pixelSize, + color: toColor(item.color), + outlineColor: toColor(item.outlineColor ?? [0, 0, 0, 0]), + outlineWidth: item.outlineWidth ?? 0, + disableDepthTestDistance: item.disableDepthTest ?? 0, + show: item.show !== false, + id: pickId(record, item), + }); + replaceHost(record, host); + } + + function lineMaterial(style) { + switch (style.kind) { + case 'solid': + return Cesium.Material.fromType('Color', { + color: toColor(style.color), + }); + case 'glow': + return Cesium.Material.fromType('PolylineGlow', { + color: toColor(style.color), + glowPower: style.power, + taperPower: style.taper ?? 1, + }); + case 'dash': + return Cesium.Material.fromType('PolylineDash', { + color: toColor(style.color), + gapColor: toColor(style.gapColor ?? [0, 0, 0, 0]), + dashLength: style.length, + ...(style.pattern16 !== undefined + ? { dashPattern: style.pattern16 } + : {}), + }); + case 'arrow': + return Cesium.Material.fromType('PolylineArrow', { + color: toColor(style.color), + }); + default: + throw new Error(`the scene renderer has no ${style.kind} lines`); + } + } + + function linePositions(item) { + const flat = item.positions instanceof Float64Array; + const points = []; + const n = flat ? item.positions.length / 3 : item.positions.length; + for (let i = 0; i < n; i++) + points.push( + flat + ? new Cesium.Cartesian3( + item.positions[i * 3], + item.positions[i * 3 + 1], + item.positions[i * 3 + 2], + ) + : toCartesian(item.positions[i]), + ); + if (item.mode !== 'geodesic') return points; + const dense = []; + for (let i = 0; i + 1 < points.length; i++) { + const a = Cesium.Cartographic.fromCartesian(points[i]); + const b = Cesium.Cartographic.fromCartesian(points[i + 1]); + const geodesic = new Cesium.EllipsoidGeodesic(a, b); + for (let k = 0; k < 64; k++) { + const s = k / 64; + const at = geodesic.interpolateUsingFraction(s); + dense.push( + Cesium.Cartesian3.fromRadians( + at.longitude, + at.latitude, + a.height + (b.height - a.height) * s, + ), + ); + } + } + dense.push(points.at(-1)); + return dense; + } + + function buildLines(record) { + const host = new Cesium.PolylineCollection(); + for (const item of record.items.values()) { + if (item.mode === 'draped') + throw new Error('the scene renderer has no draped lines'); + host.add({ + positions: linePositions(item), + width: item.widthPx, + material: lineMaterial(item.style), + show: item.show !== false, + id: pickId(record, item), + }); + } + replaceHost(record, host); + } + + const heightOf = (pos) => + 'x' in pos + ? Cesium.Cartographic.fromCartesian(toCartesian(pos)).height + : (pos.height ?? 0); + + function shapeGeometry(item) { + const g = item.geometry; + const extrudedHeight = item.extrudeHeight; + switch (g.kind) { + case 'polygon': { + const [outer, ...holes] = g.rings.map((ring) => ring.map(toCartesian)); + const hierarchy = new Cesium.PolygonHierarchy( + outer, + holes.map((h) => new Cesium.PolygonHierarchy(h)), + ); + return { + fill: new Cesium.PolygonGeometry({ + polygonHierarchy: hierarchy, + height: g.perPositionHeight ? undefined : heightOf(g.rings[0][0]), + extrudedHeight, + perPositionHeight: g.perPositionHeight, + }), + ring: outer, + }; + } + case 'ellipse': + return { + fill: new Cesium.EllipseGeometry({ + center: toCartesian(g.center), + semiMajorAxis: g.semiMajor, + semiMinorAxis: g.semiMinor, + rotation: g.rotation ?? 0, + height: heightOf(g.center), + extrudedHeight, + }), + }; + case 'rectangle': + return { + fill: new Cesium.RectangleGeometry({ + rectangle: Cesium.Rectangle.fromDegrees( + g.rect.west, + g.rect.south, + g.rect.east, + g.rect.north, + ), + height: g.height ?? 0, + extrudedHeight, + }), + }; + case 'box': { + const d = g.dimensions; + return { + fill: Cesium.BoxGeometry.fromDimensions({ + dimensions: new Cesium.Cartesian3(d.x, d.y, d.z), + }), + modelMatrix: Cesium.Transforms.eastNorthUpToFixedFrame( + toCartesian(g.center), + ), + }; + } + default: + throw new Error(`the scene renderer has no ${g.kind} shapes`); + } + } + + function buildShapes(record) { + const host = new Cesium.PrimitiveCollection(); + const outlines = new Cesium.PolylineCollection(); + for (const item of record.items.values()) { + if (item.show === false) continue; + if ( + item.fill && + typeof item.fill === 'object' && + !Array.isArray(item.fill) + ) + throw new Error('the scene renderer fills shapes with colours only'); + const shape = shapeGeometry(item); + const id = pickId(record, item); + const solid = + item.extrudeHeight !== undefined || item.geometry.kind === 'box'; + if (item.fill && !item.outlineOnly) { + const color = toColor(item.fill); + host.add( + new Cesium.Primitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry: shape.fill, + modelMatrix: shape.modelMatrix, + attributes: { + color: Cesium.ColorGeometryInstanceAttribute.fromColor(color), + }, + id, + }), + appearance: new Cesium.PerInstanceColorAppearance({ + translucent: color.alpha < 1, + closed: solid, + // Flat shapes unlit: lit, their two triangles shade apart. + flat: !solid, + }), + asynchronous: false, + }), + ); + } + if (item.outline && shape.ring) + outlines.add({ + positions: [...shape.ring, shape.ring[0]], + width: item.outlineWidth ?? 1, + material: Cesium.Material.fromType('Color', { + color: toColor(item.outline), + }), + id, + }); + } + host.add(outlines); + replaceHost(record, host); + } + + function buildModels(record) { + const host = new Cesium.PrimitiveCollection(); + replaceHost(record, host); + // A model counts as loading until it is ready or fails, not when added; + // each model settles once, and a failure is kept for `whenSettled`. + host.loading = record.items.size; + host.failed = 0; + for (const item of record.items.values()) { + let settled = false; + const settle = (failed) => { + if (settled) return; + settled = true; + host.loading -= 1; + if (failed) host.failed += 1; + scene.requestRender(); + }; + const o = item.orientation; + const modelMatrix = Cesium.Transforms.headingPitchRollToFixedFrame( + toCartesian(item.pos), + new Cesium.HeadingPitchRoll(o.heading, o.pitch, o.roll), + ); + Cesium.Model.fromGltfAsync({ + url: item.url, + modelMatrix, + scale: item.scale ?? 1, + minimumPixelSize: item.minPixelSize ?? 0, + // All textures before ready, so a settled capture is the whole model. + incrementallyLoadTextures: false, + id: pickId(record, item), + }).then( + (model) => { + if (record.host !== host) return model.destroy(); + host.add(model); + model.readyEvent.addEventListener(() => { + settle(false); + for (const cb of record.listeners.ready.slice()) cb(item.id); + }); + model.errorEvent.addEventListener(() => settle(true)); + scene.requestRender(); + }, + () => settle(true), + ); + } + } + + const draw = { + icons: (spec) => makeSet('icons', { ...spec }, buildIcons), + points: (spec) => makeSet('points', { ...spec }, buildPoints), + lines: (spec) => makeSet('lines', { ...spec }, buildLines), + shapes: (spec) => makeSet('shapes', { ...spec }, buildShapes), + models: (spec) => makeSet('models', { ...spec }, buildModels), + }; + + // --- Globe, camera, query -------------------------------------------------- + + const globe = { + async setBase(base) { + if ( + base.photoreal || + (base.terrain && base.terrain.kind !== 'ellipsoid') || + base.imagery?.length + ) + throw new Error('the scene renderer draws a bare globe only'); + return { photoreal: { route: 'none' }, imagery: [], settled: true }; + }, + setGlobeVisible(visible) { + scene.globe.show = visible; + scene.requestRender(); + }, + sky: { + get: () => ({ ...sky }), + set(settings) { + Object.assign(sky, settings); + applySky(); + }, + }, + isSettled: () => scene.globe.tilesLoaded, + }; + + const cameraPort = { + setView(spec) { + const d = spec.destination; + if (d && 'rect' in d) + throw new Error('the scene renderer takes position destinations only'); + const o = spec.orientation ?? {}; + camera.setView({ + ...(d ? { destination: toCartesian(d) } : {}), + orientation: { + heading: o.heading ?? 0, + pitch: o.pitch ?? -Math.PI / 2, + roll: o.roll ?? 0, + }, + }); + scene.requestRender(); + }, + pose() { + const v = (c) => ({ x: c.x, y: c.y, z: c.z }); + return { + position: v(camera.positionWC), + direction: v(camera.directionWC), + up: v(camera.upWC), + right: v(camera.rightWC), + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + height: camera.positionCartographic.height, + fovy: camera.frustum.fovy, + aspectRatio: camera.frustum.aspectRatio, + near: camera.frustum.near, + far: camera.frustum.far, + frameIsIdentity: Cesium.Matrix4.equals( + camera.transform, + Cesium.Matrix4.IDENTITY, + ), + }; + }, + }; + + const query = { + pick(x, y) { + const picked = scene.pick(new Cesium.Cartesian2(x, y)); + const id = picked?.id; + return id && typeof id.set === 'string' + ? { ...id, surface: false } + : null; + }, + project(position) { + const s = Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + toCartesian(position), + ); + return s ? { x: s.x, y: s.y } : null; + }, + pickEllipsoid(x, y) { + const c = camera.pickEllipsoid(new Cesium.Cartesian2(x, y)); + return c ? { x: c.x, y: c.y, z: c.z } : null; + }, + }; + + // --- Renderer -------------------------------------------------------------- + + async function whenSettled({ timeoutMs = 20000 } = {}) { + const models = () => + [...sets.values()].filter((r) => r.kind === 'models' && r.host); + const end = performance.now() + timeoutMs; + let quiet = 0; + while (performance.now() < end) { + await nextPostRender(); + if (models().some((r) => r.host.failed > 0)) + return { state: 'failed', blockers: ['models'] }; + const loading = models().some((r) => r.host.loading > 0); + quiet = scene.globe.tilesLoaded && !loading ? quiet + 1 : 0; + if (quiet >= 3) return { state: 'settled', blockers: [] }; + } + const blockers = []; + if (!scene.globe.tilesLoaded) blockers.push('globe'); + if (models().some((r) => r.host.loading > 0)) blockers.push('models'); + return { state: 'timeout', blockers }; + } + + return { + id: 'cesium', + contractVersion: '0', + capabilities, + canvas: scene.canvas, + frame, + globe, + draw, + camera: cameraPort, + query, + async capture({ type = 'png' } = {}) { + scene.render(widget.clock.currentTime); + return new Promise((resolve) => + scene.canvas.toBlob(resolve, `image/${type}`), + ); + }, + whenSettled, + resize: () => widget.resize(), + destroy() { + for (const off of removers) off(); + widget.destroy(); + }, + }; +} diff --git a/src/render/conformance/cameraRunner.js b/src/render/conformance/cameraRunner.js new file mode 100644 index 000000000..ba534c675 --- /dev/null +++ b/src/render/conformance/cameraRunner.js @@ -0,0 +1,171 @@ +/** + * @module render/conformance/cameraRunner + * @description Runs a camera script ({@link module:render/conformance/cameraScripts}) + * on a driver and collects what it records, in step order. A driver is + * either a renderer through the contract ({@link contractDriver}) or the + * reference recorder's own. + */ + +/** + * @typedef {object} CameraDriver + * @property {(name: string, args: unknown[]) => import('../contract/camera').Flight|void} command + * @property {() => void|Promise} frame One frame, 1/60 s on the script's clock. + * @property {(threshold: number) => () => number} listen Counts `changed`; returns a reader. + * @property {() => object} pose + * @property {(position: object) => object|null} project + * @property {(screen: {x: number, y: number}) => object|null} pickEllipsoid + * @property {(screen: {x: number, y: number}) => object} pickRay + * @property {(position: object) => boolean} occluded + * @property {() => boolean} flying + * @property {(position: object) => object} toEcef + */ + +/** The parts of a pose a trace keeps. */ +export function tracePose(pose) { + return { + position: pick3(pose.position), + direction: pick3(pose.direction), + up: pick3(pose.up), + right: pick3(pose.right), + heading: pose.heading, + pitch: pose.pitch, + roll: pose.roll, + height: pose.height, + frameIsIdentity: pose.frameIsIdentity, + }; +} + +const pick3 = (v) => (v ? { x: v.x, y: v.y, z: v.z } : null); + +/** + * @param {CameraDriver} driver + * @param {{name: string, steps: object[]}} script + * @returns {Promise<{records: object[], flights: object[]}>} + */ +export async function runCameraScript(driver, script) { + const records = []; + const flights = []; + const listeners = []; + let frame = 0; + for (const [index, step] of script.steps.entries()) { + if (step.do === 'frames') { + for (let i = 0; i < step.count; i++) { + frame += 1; + await driver.frame(); + } + } else if (step.do === 'listen') { + listeners.push({ + threshold: step.threshold, + read: driver.listen(step.threshold), + }); + } else if (step.do) { + const args = resolveArgs(driver, step.do, step.args ?? []); + const flight = driver.command(step.do, args); + if (flight && typeof flight.done?.then === 'function') { + const entry = { + step: index, + startFrame: frame, + outcome: null, + frames: null, + }; + flights.push(entry); + flight.done.then((outcome) => { + entry.outcome = outcome; + entry.frames = frame - entry.startFrame; + }); + } + } else if (step.record) { + // Let flight outcomes that settled this frame land first. + await Promise.resolve(); + records.push({ + step: index, + kind: step.record, + value: record(driver, step), + }); + } + } + await Promise.resolve(); + return { records, flights: flights.map(({ startFrame, ...rest }) => rest) }; + + function record(d, step) { + switch (step.record) { + case 'pose': + return tracePose(d.pose()); + case 'project': + return step.points.map((p) => d.project(d.toEcef(p))); + case 'pickEllipsoid': + return step.screen.map((s) => pick3(d.pickEllipsoid(s))); + case 'pickRay': + return step.screen.map((s) => { + const ray = d.pickRay(s); + return { origin: pick3(ray.origin), direction: pick3(ray.direction) }; + }); + case 'occluded': + return step.points.map((p) => d.occluded(d.toEcef(p))); + case 'changed': + return listeners.map((l) => ({ + threshold: l.threshold, + count: l.read(), + })); + case 'flying': + return { flying: d.flying(), pose: tracePose(d.pose()) }; + case 'viewRectangle': + return d.viewRectangle(); + case 'matrices': { + const m = d.matrices(); + const { near, far } = d.pose(); + return { + view: [...m.view], + projection: [...m.projection], + near, + // JSON has no Infinity: an infinite far plane is null. Anything + // else that is not a number stays as it is, to fail the check. + far: far === Infinity ? null : far, + }; + } + default: + throw new Error(`unknown record ${step.record}`); + } + } +} + +/** Geodetic positions in arguments become ECEF where the command takes ECEF. */ +function resolveArgs(driver, name, args) { + if (name === 'lookAt') return [driver.toEcef(args[0]), args[1]]; + if (name === 'flyToBounds') + return [ + { ...args[0], center: driver.toEcef(args[0].center) }, + ...args.slice(1), + ]; + return args; +} + +/** + * A driver over a renderer, through the contract only. + * @param {import('../contract/renderer').Renderer} renderer + * @param {{frame: () => void|Promise, toEcef: (p: object) => object}} hooks + * @returns {CameraDriver} + */ +export function contractDriver(renderer, hooks) { + const camera = renderer.camera; + return { + command(name, args) { + return camera[name](...args); + }, + frame: hooks.frame, + listen(threshold) { + let count = 0; + camera.on('changed', () => (count += 1), { threshold }); + return () => count; + }, + pose: () => camera.pose(), + project: (p) => renderer.query.project(p), + pickEllipsoid: (s) => renderer.query.pickEllipsoid(s.x, s.y), + pickRay: (s) => renderer.query.pickRay(s.x, s.y), + occluded: (p) => renderer.query.isOccluded(p), + flying: () => camera.flying, + matrices: () => camera.matrices(), + viewRectangle: () => camera.viewRectangle(), + toEcef: hooks.toEcef, + }; +} diff --git a/src/render/conformance/cameraScripts.js b/src/render/conformance/cameraScripts.js new file mode 100644 index 000000000..6c1da780c --- /dev/null +++ b/src/render/conformance/cameraScripts.js @@ -0,0 +1,422 @@ +/** + * @module render/conformance/cameraScripts + * @description Camera scripts in the contract's own terms, each a list of + * steps run from a fixed start: commands, frames (1/60 s each, on a clock + * that restarts per script) and records of what the camera reports. + * + * The same scripts are run on the default renderer to make the reference + * traces (`goldens/camera.json`, by `scripts/record-camera-goldens.mjs`) and + * on a renderer under test by the conformance suite, which compares the two. + * + * Step kinds: + * - `{ do: 'setView' | 'lookAt' | 'release' | 'move' | 'look' | 'rotateAround' | 'zoom', args }` + * - `{ do: 'flyTo' | 'flyToBounds', args }`: starts a flight; its outcome and + * the frame it ended on are recorded when it ends. + * - `{ do: 'cancelFlight' }` + * - `{ do: 'listen', threshold }`: counts `changed` with that threshold. + * - `{ do: 'frames', count }` + * - `{ record: 'pose' }`, `{ record: 'project', points }`, + * `{ record: 'pickEllipsoid', screen }`, `{ record: 'pickRay', screen }`, + * `{ record: 'occluded', points }`, `{ record: 'changed' }`, + * `{ record: 'flying' }`, `{ record: 'matrices' }`, `{ record: 'viewRectangle' }`. + */ + +/** Where the scripts, scenes and checks look: downtown Austin, degrees. */ +export const AUSTIN = Object.freeze({ lon: -97.7431, lat: 30.2672 }); +const START = { + do: 'setView', + args: [ + { + destination: { ...AUSTIN, height: 15000000 }, + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }, + ], +}; + +const city = (height, heading, pitch, roll = 0) => ({ + do: 'setView', + args: [ + { + destination: { ...AUSTIN, height }, + orientation: { heading, pitch, roll }, + }, + ], +}); + +const AUSTIN_GROUND = [ + { lon: -97.7431, lat: 30.2672, height: 0 }, + { lon: -97.75, lat: 30.27, height: 150 }, + { lon: -97.735, lat: 30.26, height: 20 }, + { lon: -97.7431, lat: 30.28, height: 0 }, + { lon: -97.72, lat: 30.25, height: 300 }, +]; + +const SCREEN_GRID = [ + { x: 640, y: 360 }, + { x: 10, y: 10 }, + { x: 1270, y: 10 }, + { x: 10, y: 710 }, + { x: 1270, y: 710 }, + { x: 320, y: 540 }, +]; + +/** The viewport every script runs in, CSS pixels. */ +export const SCRIPT_VIEWPORT = Object.freeze({ width: 1280, height: 720 }); + +/** @type {ReadonlyArray<{name: string, steps: object[]}>} */ +export const CAMERA_SCRIPTS = Object.freeze([ + { + name: 'setView angles', + steps: [ + START, + { record: 'pose' }, + city(600, 0.3, -0.6), + { record: 'pose' }, + city(600, 5.9, -0.2, 0.15), + { record: 'pose' }, + city(25000, 3.1, -1.2), + { record: 'pose' }, + { + do: 'setView', + args: [ + { + destination: { lon: 12.5, lat: 89.95, height: 40000 }, + orientation: { heading: 1.0, pitch: -0.7, roll: 0 }, + }, + ], + }, + { record: 'pose' }, + { + do: 'setView', + args: [ + { + destination: { lon: 151.2, lat: -33.87, height: 2000 }, + orientation: { heading: 0.0 }, + }, + ], + }, + { record: 'pose' }, + ], + }, + { + name: 'view rectangles', + steps: [ + START, + { record: 'viewRectangle' }, + ...[ + [5000, 0, -Math.PI / 2], + [2000, 0, -0.3], + [600, 0.3, -0.6], + [2000, 0, 1.2], + [15000000, 0, Math.PI / 2], + ].flatMap(([height, heading, pitch]) => [ + city(height, heading, pitch), + { record: 'viewRectangle' }, + ]), + ...[ + { lon: 0, lat: 89.9, height: 1000000 }, + { lon: 180, lat: 10, height: 2000000 }, + ].flatMap((destination) => [ + { do: 'setView', args: [{ destination }] }, + { record: 'viewRectangle' }, + ]), + ], + }, + { + name: 'view and projection matrices', + steps: [ + START, + { record: 'matrices' }, + city(600, 0.3, -0.6, 0.1), + { record: 'matrices' }, + city(25000, 3.1, -1.2), + { record: 'matrices' }, + ], + }, + { + name: 'setView keeps the position when only orientation is given', + steps: [ + START, + city(800, 1.0, -0.5), + { + do: 'setView', + args: [{ orientation: { heading: 2.0, pitch: -0.3, roll: 0 } }], + }, + { record: 'pose' }, + ], + }, + { + name: 'lookAt sweep and release', + steps: [ + START, + ...Array.from({ length: 12 }, (_, i) => [ + { + do: 'lookAt', + args: [ + { ...AUSTIN, height: 0 }, + { heading: (i * Math.PI) / 6, pitch: -0.4, range: 2000 }, + ], + }, + { record: 'pose' }, + ]).flat(), + { do: 'release' }, + { record: 'pose' }, + ], + }, + { + name: 'move, look, rotate and zoom', + steps: [ + START, + city(600, 0.3, -0.6), + { do: 'move', args: ['forward', 100] }, + { record: 'pose' }, + { do: 'move', args: ['right', 50] }, + { do: 'move', args: ['up', 20] }, + { record: 'pose' }, + { do: 'look', args: ['left', 0.1] }, + { record: 'pose' }, + { do: 'look', args: ['up', 0.05] }, + { record: 'pose' }, + { do: 'rotateAround', args: [{ x: 0, y: 0, z: 1 }, 0.01] }, + { record: 'pose' }, + { do: 'zoom', args: [200] }, + { record: 'pose' }, + { do: 'move', args: ['back', 300] }, + { do: 'look', args: ['down', 0.2] }, + { do: 'look', args: ['right', 0.3] }, + { record: 'pose' }, + ], + }, + { + name: 'commands inside a lookAt frame', + steps: [ + START, + { + do: 'lookAt', + args: [ + { ...AUSTIN, height: 0 }, + { heading: 0.7, pitch: -0.5, range: 3000 }, + ], + }, + { do: 'move', args: ['forward', 500] }, + { record: 'pose' }, + { do: 'rotateAround', args: [{ x: 0, y: 0, z: 1 }, 0.4] }, + { record: 'pose' }, + { do: 'zoom', args: [-1000] }, + { record: 'pose' }, + { do: 'release' }, + { record: 'pose' }, + ], + }, + { + name: 'flyTo from orbit, default orientation and duration', + steps: [ + START, + { do: 'flyTo', args: [{ ...AUSTIN, height: 600 }] }, + { do: 'frames', count: 45 }, + { record: 'flying' }, + { do: 'frames', count: 60 }, + { record: 'flying' }, + { do: 'frames', count: 135 }, + { record: 'pose' }, + ], + }, + { + name: 'flyTo with duration and orientation', + steps: [ + START, + city(5000, 0, -1.0), + { + do: 'flyTo', + args: [ + { lon: -97.7, lat: 30.3, height: 1200 }, + { + durationS: 2, + orientation: { heading: 1.2, pitch: -0.35, roll: 0 }, + }, + ], + }, + { do: 'frames', count: 40 }, + { record: 'flying' }, + { do: 'frames', count: 110 }, + { record: 'pose' }, + ], + }, + { + name: 'flyTo with a height cap, pitch adjustment and named easings', + steps: [ + START, + city(3000, 0.5, -0.4), + { + do: 'flyTo', + args: [ + { lon: 2.35, lat: 48.86, height: 2000 }, + { + durationS: 3, + easing: 'linear', + pitchAdjustHeight: 500000, + // Not straight down: there heading is ill-conditioned, and the + // next flight would start from a last-bit difference in it. + orientation: { heading: 0.2, pitch: -0.6, roll: 0 }, + }, + ], + }, + { do: 'frames', count: 50 }, + { record: 'flying' }, + { do: 'frames', count: 50 }, + { record: 'flying' }, + { do: 'frames', count: 100 }, + { record: 'pose' }, + { + do: 'flyTo', + args: [ + { lon: 2.4, lat: 48.9, height: 800 }, + { durationS: 1, easing: 'cubicOut', maxHeight: 50000 }, + ], + }, + { do: 'frames', count: 20 }, + { record: 'flying' }, + { do: 'frames', count: 50 }, + { record: 'pose' }, + { + do: 'flyTo', + args: [ + { lon: 2.3, lat: 48.8, height: 1500 }, + { durationS: 1, easing: 'quadraticInOut' }, + ], + }, + { do: 'frames', count: 25 }, + { record: 'flying' }, + { do: 'frames', count: 50 }, + { record: 'pose' }, + ], + }, + { + name: 'flyTo with a duration of 0 completes inside the call', + steps: [ + START, + { do: 'flyTo', args: [{ ...AUSTIN, height: 900 }, { durationS: 0 }] }, + { record: 'flying' }, + { do: 'frames', count: 3 }, + { record: 'pose' }, + ], + }, + { + name: 'flyTo where the camera already is completes inside the call', + steps: [ + city(600, 0.3, -0.6), + { + do: 'flyTo', + args: [ + { ...AUSTIN, height: 600 }, + { orientation: { heading: 0.3, pitch: -0.6, roll: 0 } }, + ], + }, + { record: 'flying' }, + ], + }, + { + name: 'rectangle destinations', + steps: [ + START, + ...[ + { west: -98, south: 30, east: -97.5, north: 30.4 }, + { west: -10, south: 0, east: 40, north: 60 }, + { west: -60, south: -20, east: 10, north: 60 }, + { west: 170, south: -50, east: -170, north: -30 }, + { west: -30, south: -89.5, east: 30, north: 89.5 }, + ].flatMap((rect) => [ + { do: 'setView', args: [{ destination: { rect } }] }, + { record: 'pose' }, + ]), + { + do: 'setView', + args: [ + { + destination: { rect: { west: 2, south: 48, east: 3, north: 49 } }, + orientation: { heading: 0.5, pitch: -0.9, roll: 0 }, + }, + ], + }, + { record: 'pose' }, + { + do: 'flyTo', + args: [ + { rect: { west: -10, south: 0, east: 40, north: 60 } }, + { durationS: 1 }, + ], + }, + { do: 'frames', count: 70 }, + { record: 'pose' }, + ], + }, + { + name: 'flyToBounds default offset and explicit offset', + steps: [ + START, + { + do: 'flyToBounds', + args: [{ center: { ...AUSTIN, height: 0 }, radius: 500 }], + }, + { do: 'frames', count: 240 }, + { record: 'pose' }, + { + do: 'flyToBounds', + args: [ + { center: { ...AUSTIN, height: 0 }, radius: 500 }, + { offset: { heading: 1.0, pitch: -0.3, range: 3000 }, durationS: 1 }, + ], + }, + { do: 'frames', count: 90 }, + { record: 'pose' }, + ], + }, + { + name: 'a cancelled flight stops where it was', + steps: [ + START, + { do: 'flyTo', args: [{ ...AUSTIN, height: 600 }, { durationS: 3 }] }, + { do: 'frames', count: 30 }, + { do: 'cancelFlight' }, + { do: 'frames', count: 5 }, + { record: 'flying' }, + ], + }, + { + name: 'changed fires by each listener’s threshold', + steps: [ + START, + city(2000, 0, -0.5), + { do: 'listen', threshold: 0.5 }, + { do: 'listen', threshold: 0.1 }, + { do: 'frames', count: 1 }, + ...Array.from({ length: 24 }, () => [ + { do: 'look', args: ['right', 0.05] }, + { do: 'frames', count: 1 }, + ]).flat(), + { record: 'changed' }, + ], + }, + { + name: 'projection, picks and the horizon', + steps: [ + START, + city(600, 0.3, -0.6), + { record: 'project', points: AUSTIN_GROUND }, + { record: 'pickEllipsoid', screen: SCREEN_GRID }, + { record: 'pickRay', screen: SCREEN_GRID }, + START, + { + record: 'occluded', + points: [ + { lon: -97.7431, lat: 30.2672, height: 0 }, + { lon: 82.26, lat: -30.27, height: 0 }, + { lon: -97.7431, lat: -55, height: 0 }, + { lon: -10, lat: 30, height: 0 }, + { lon: -10, lat: 30, height: 2000000 }, + { lon: 170, lat: 10, height: 30000000 }, + ], + }, + ], + }, +]); diff --git a/src/render/conformance/checks/camera.js b/src/render/conformance/checks/camera.js new file mode 100644 index 000000000..622189a07 --- /dev/null +++ b/src/render/conformance/checks/camera.js @@ -0,0 +1,505 @@ +/** + * Camera checks: the reference traces, then flight, hold, event, follow and + * view rules the traces cannot show. + */ +import goldens from '../goldens/camera.json' with { type: 'json' }; +import { CAMERA_SCRIPTS, SCRIPT_VIEWPORT } from '../cameraScripts.js'; +import { contractDriver, runCameraScript } from '../cameraRunner.js'; +import { deepEqual, equal, near, nearAngle, nearVec, ok } from '../expect.js'; + +const lonLatToEcef = (h) => h.toEcef; + +function comparePose(actual, expected, tol, where) { + nearVec(actual.position, expected.position, tol.metres, `${where} position`); + nearVec(actual.direction, expected.direction, tol.unit, `${where} direction`); + nearVec(actual.up, expected.up, tol.unit, `${where} up`); + nearVec(actual.right, expected.right, tol.unit, `${where} right`); + nearAngle(actual.heading, expected.heading, tol.radians, `${where} heading`); + // Pitch is compared by its sine: straight up or down, the angle itself + // turns a last-bit difference in direction into ~1e-8 rad. + near( + Math.sin(actual.pitch), + Math.sin(expected.pitch), + tol.radians, + `${where} sin(pitch)`, + ); + nearAngle(actual.roll, expected.roll, tol.radians, `${where} roll`); + near(actual.height, expected.height, tol.metres, `${where} height`); + equal( + actual.frameIsIdentity, + expected.frameIsIdentity, + `${where} frameIsIdentity`, + ); +} + +function compareRecord(actual, expected, tol, where, flightPaths) { + equal(actual.kind, expected.kind, `${where} kind`); + const a = actual.value; + const e = expected.value; + switch (expected.kind) { + case 'pose': + comparePose(a, e, tol, where); + break; + case 'project': + e.forEach((s, i) => nearVec(a[i], s, tol.pixels, `${where} point ${i}`)); + break; + case 'pickEllipsoid': + e.forEach((p, i) => nearVec(a[i], p, tol.metres, `${where} pick ${i}`)); + break; + case 'pickRay': + e.forEach((r, i) => { + nearVec(a[i].origin, r.origin, tol.metres, `${where} ray ${i} origin`); + nearVec( + a[i].direction, + r.direction, + tol.unit, + `${where} ray ${i} direction`, + ); + }); + break; + case 'occluded': + deepEqual(a, e, `${where} occluded`); + break; + case 'changed': + e.forEach((c, i) => + near( + a[i].count, + c.count, + tol.changedCount, + `${where} changed at ${c.threshold}`, + ), + ); + break; + case 'viewRectangle': + equal(a === null, e === null, `${where} viewRectangle null`); + if (e) + for (const side of ['west', 'south', 'east', 'north']) + near( + a[side], + e[side], + (tol.radians * 180) / Math.PI, + `${where} viewRectangle ${side}`, + ); + break; + case 'matrices': { + // The view's last column is a translation in metres; the rest is unit. + e.view.forEach((v, i) => + near( + a.view[i], + v, + i >= 12 ? tol.metres : tol.unit, + `${where} view[${i}]`, + ), + ); + // Depth terms come from the renderer's own near and far planes (the + // contract fixes neither, and allows an infinite far plane). + const n = a.near; + const f = a.far; + ok(Number.isFinite(n) && n > 0, `${where} near is a positive number`); + ok( + f === null || (Number.isFinite(f) && f > n), + `${where} far is beyond near, or infinite`, + ); + const depth = + f === null + ? { 10: -1, 14: -2 * n } + : { 10: -(f + n) / (f - n), 14: (-2 * f * n) / (f - n) }; + e.projection.forEach((v, i) => + near( + a.projection[i], + depth[i] ?? v, + tol.unit * Math.max(1, Math.abs(depth[i] ?? v)), + `${where} projection[${i}]`, + ), + ); + break; + } + case 'flying': + equal(a.flying, e.flying, `${where} flying`); + if (flightPaths === 'trace') comparePose(a.pose, e.pose, tol, where); + break; + default: + throw new Error(`unknown record ${expected.kind}`); + } +} + +/** @param {import('../index.js').ConformanceHarness} h */ +async function fresh(h) { + return h.create({ viewport: SCRIPT_VIEWPORT }); +} + +export function cameraChecks() { + const traceChecks = CAMERA_SCRIPTS.map((script) => ({ + id: `camera.trace.${script.name}`, + port: 'camera', + title: `reference trace: ${script.name}`, + async run(h) { + const expected = goldens.scripts[script.name]; + ok(expected, `no reference trace for ${script.name}`); + const r = await fresh(h); + try { + const run = await runCameraScript( + contractDriver(r, { + frame: () => h.frame(r), + toEcef: lonLatToEcef(h), + }), + script, + ); + equal(run.records.length, expected.records.length, 'record count'); + run.records.forEach((record, i) => + compareRecord( + record, + expected.records[i], + h.tolerance, + `${script.name} step ${expected.records[i].step}`, + h.flightPaths, + ), + ); + equal(run.flights.length, expected.flights.length, 'flight count'); + run.flights.forEach((f, i) => { + equal(f.outcome, expected.flights[i].outcome, `flight ${i} outcome`); + near(f.frames, expected.flights[i].frames, 1, `flight ${i} frames`); + }); + } finally { + r.destroy(); + } + }, + })); + + return [ + ...traceChecks, + { + id: 'camera.flight.callbacks', + port: 'camera', + title: 'flight callbacks run synchronously; a new flight cancels the old', + async run(h) { + const r = await fresh(h); + const events = []; + const first = r.camera.flyTo( + { lon: 0, lat: 0, height: 1e6 }, + { + durationS: 1, + onCancel: () => events.push('cancel1'), + onComplete: () => events.push('done1'), + }, + ); + ok(r.camera.flying, 'flying after flyTo'); + r.camera.flyTo( + { lon: 1, lat: 1, height: 1e6 }, + { durationS: 0.5, onComplete: () => events.push('done2') }, + ); + deepEqual( + events, + ['cancel1'], + 'the first flight was cancelled inside the second flyTo', + ); + equal(await first.done, 'cancelled', 'first outcome'); + for (let i = 0; i < 40 && r.camera.flying; i++) await h.frame(r); + deepEqual(events, ['cancel1', 'done2'], 'the second completed'); + ok(!r.camera.flying, 'not flying after completion'); + const third = r.camera.flyTo( + { lon: 2, lat: 2, height: 1e6 }, + { durationS: 1, onCancel: () => events.push('cancel3') }, + ); + r.camera.cancelFlight(); + equal( + events.at(-1), + 'cancel3', + 'cancelFlight runs onCancel inside the call', + ); + equal(await third.done, 'cancelled', 'third outcome'); + r.destroy(); + }, + }, + { + id: 'camera.controls.holds', + port: 'camera', + title: + 'camera input holds are counted; a flight ending gives back only its own', + async run(h) { + const r = await fresh(h); + ok(r.camera.controls.enabled, 'enabled at start'); + const release = r.input.suspendCameraControls('drag'); + ok(!r.camera.controls.enabled, 'a hold disables input'); + r.camera.flyTo({ lon: 0, lat: 0, height: 1e6 }, { durationS: 0.1 }); + for (let i = 0; i < 20 && r.camera.flying; i++) await h.frame(r); + ok(!r.camera.flying, 'the flight ended'); + ok(!r.camera.controls.enabled, 'the drag hold survives the flight end'); + const second = r.camera.controls.suspend('cockpit'); + release(); + release(); + ok(!r.camera.controls.enabled, 'one hold still out'); + second(); + ok(r.camera.controls.enabled, 'enabled with no holds out'); + const [min, max] = r.camera.controls.pitchRange; + ok( + -Math.PI / 2 <= min && min <= max && max <= Math.PI / 2, + 'pitchRange is an ordered range of pitches', + ); + r.camera.controls.pitchRange = [-1.2, -0.1]; + deepEqual( + [...r.camera.controls.pitchRange], + [-1.2, -0.1], + 'pitchRange is settable', + ); + r.destroy(); + }, + }, + { + id: 'camera.onCommand', + port: 'camera', + title: 'every camera command is announced before it runs', + async run(h) { + const r = await fresh(h); + const seen = []; + const off = r.camera.onCommand((c) => seen.push(c.kind)); + const target = h.toEcef({ lon: 0, lat: 0, height: 0 }); + r.camera.setView({ destination: { lon: 0, lat: 0, height: 1000 } }); + r.camera.lookAt(target, { heading: 0, pitch: -0.5, range: 1000 }); + r.camera.release(); + r.camera.flyTo({ lon: 1, lat: 1, height: 1000 }, { durationS: 1 }); + r.camera.cancelFlight(); + r.camera.flyToBounds({ center: target, radius: 10 }, { durationS: 1 }); + r.camera.cancelFlight(); + r.camera.follow({ + key: 'k', + position: () => target, + frame: 'enu', + viewFrom: { x: 0, y: -100, z: 100 }, + }); + off(); + r.camera.follow(null); + deepEqual( + seen, + [ + 'setView', + 'lookAt', + 'release', + 'flyTo', + 'cancelFlight', + 'flyToBounds', + 'cancelFlight', + 'follow', + ], + 'commands', + ); + r.destroy(); + }, + }, + { + id: 'camera.moveEnd', + port: 'camera', + title: 'moveStart on movement; moveEnd 500 ms after the last change', + async run(h) { + const r = await fresh(h); + const events = []; + r.camera.on('moveStart', () => events.push(['start', h.now(r)])); + r.camera.on('moveEnd', () => events.push(['end', h.now(r)])); + await h.frame(r); + r.camera.move('forward', 1000); + await h.frame(r); + equal(events[0]?.[0], 'start', 'moveStart after the move'); + const movedAt = h.now(r); + for (let i = 0; i < 45; i++) await h.frame(r); + const end = events.find(([kind]) => kind === 'end'); + ok(end, 'moveEnd fired'); + ok( + end[1] - movedAt >= 500 - 1e-6, + `moveEnd waited 500 ms (${end[1] - movedAt})`, + ); + ok( + end[1] - movedAt < 500 + 2 * (1000 / 60) + 1e-6, + 'moveEnd came within two frames of 500 ms', + ); + r.destroy(); + }, + }, + { + id: 'camera.follow', + port: 'camera', + title: 'a follow keeps the eye at its offset from a moving target', + async run(h) { + const r = await fresh(h); + const changes = []; + r.camera.on('followChanged', () => + changes.push(r.camera.followed?.key ?? null), + ); + const base = h.toEcef({ lon: -97.74, lat: 30.27, height: 1000 }); + const start = r.frame.time(); + const target = { + key: 'test:mover', + position: (t) => ({ + x: base.x + (t - start) * 0.2, + y: base.y, + z: base.z, + }), + frame: 'enu', + viewFrom: { x: 0, y: -800, z: 400 }, + }; + r.camera.follow(target); + equal(r.camera.followed, target, 'followed'); + await h.frame(r); + ok(r.camera.followFramed, 'framed after a frame'); + const range0 = dist( + r.camera.pose().position, + target.position(r.frame.time()), + ); + near(range0, Math.hypot(800, 400), 1, 'starting range'); + for (let i = 0; i < 30; i++) { + r.frame.setTime(r.frame.time() + 1000); + await h.frame(r); + near( + dist(r.camera.pose().position, target.position(r.frame.time())), + range0, + 1e-3 * range0, + `range at frame ${i}`, + ); + } + r.camera.follow(null); + equal(r.camera.followed, null, 'cleared'); + ok(r.camera.pose().frameIsIdentity, 'back in the world frame'); + deepEqual(changes, ['test:mover', null], 'followChanged'); + r.destroy(); + }, + }, + { + id: 'camera.rect', + port: 'camera', + title: + 'a rectangle destination looks straight down and keeps the whole rectangle in view', + async run(h) { + const r = await fresh(h); + const rect = { west: -98, south: 30, east: -97.5, north: 30.4 }; + r.camera.setView({ destination: { rect } }); + await h.frame(r); + const pose = r.camera.pose(); + near(pose.pitch, -Math.PI / 2, 1e-3, 'pitch'); + for (const [lon, lat] of [ + [rect.west, rect.north], + [rect.east, rect.north], + [rect.west, rect.south], + [rect.east, rect.south], + ]) { + const s = r.query.project(h.toEcef({ lon, lat, height: 0 })); + ok( + s && + s.x >= -1 && + s.x <= SCRIPT_VIEWPORT.width + 1 && + s.y >= -1 && + s.y <= SCRIPT_VIEWPORT.height + 1, + `corner ${lon},${lat} in view (${JSON.stringify(s)})`, + ); + } + r.destroy(); + }, + }, + { + id: 'camera.viewRectangle', + port: 'camera', + title: + 'viewRectangle holds the ground in view, is the whole globe from afar, null facing away', + async run(h) { + const r = await fresh(h); + const at = async (height, pitch = -Math.PI / 2) => { + r.camera.setView({ + destination: { lon: -97.74, lat: 30.27, height }, + orientation: { heading: 0, pitch, roll: 0 }, + }); + await h.frame(r); + return r.camera.viewRectangle(); + }; + const down = await at(5000); + ok( + down && + down.west < -97.74 && + down.east > -97.74 && + down.south < 30.27 && + down.north > 30.27 && + down.east - down.west < 0.2, + `straight down holds the centre (${JSON.stringify(down)})`, + ); + const tilted = await at(2000, -0.3); + ok(tilted && tilted.north > 30.3, 'tilted: cut at the horizon'); + deepEqual( + await at(15000000), + { west: -180, south: -90, east: 180, north: 90 }, + 'from afar: the whole globe', + ); + equal(await at(15000000, Math.PI / 2), null, 'facing away: null'); + r.destroy(); + }, + }, + { + id: 'camera.followItem', + port: 'camera', + title: 'following a drawn item tracks where the renderer draws it', + async run(h) { + const r = await fresh(h); + if (!r.capabilities.has('motionSegments')) { + r.destroy(); + return { skip: 'no motionSegments' }; + } + const t0 = r.frame.time(); + const from = h.toEcef({ lon: -97.74, lat: 30.27, height: 1000 }); + const to = h.toEcef({ lon: -97.7, lat: 30.27, height: 1000 }); + const set = r.draw.icons({ + id: 'conf:follow', + motionClock: { kind: 'scene' }, + }); + set.set([ + { + id: 'm', + pos: from, + image: 'data:,', + size: [16, 16], + motion: { kind: 'lerp', from, to, t0, t1: t0 + 60000 }, + }, + ]); + r.camera.follow({ + key: 'conf:follow:m', + // Deliberately stale: the drawn item is the answer. + position: () => from, + item: { set: 'conf:follow', id: 'm' }, + frame: 'enu', + viewFrom: { x: 0, y: -500, z: 300 }, + }); + await h.frame(r); + const range0 = dist(r.camera.pose().position, from); + r.frame.setTime(t0 + 30000); + await h.frame(r); + const mid = { + x: (from.x + to.x) / 2, + y: (from.y + to.y) / 2, + z: (from.z + to.z) / 2, + }; + near( + dist(r.camera.pose().position, mid), + range0, + 1, + 'the eye keeps its range from the drawn position', + ); + set.dispose(); + r.destroy(); + }, + }, + { + id: 'camera.fov', + port: 'camera', + title: 'setFov sets the vertical field of view', + async run(h) { + const r = await fresh(h); + r.camera.setFov({ vertical: 0.5 }); + near(r.camera.pose().fovy, 0.5, 1e-12, 'fovy'); + near( + r.camera.pose().aspectRatio, + SCRIPT_VIEWPORT.width / SCRIPT_VIEWPORT.height, + 1e-9, + 'aspect', + ); + r.destroy(); + }, + }, + ]; +} + +const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); diff --git a/src/render/conformance/checks/ports.js b/src/render/conformance/checks/ports.js new file mode 100644 index 000000000..7bf3f055a --- /dev/null +++ b/src/render/conformance/checks/ports.js @@ -0,0 +1,1163 @@ +/** + * Checks for every port but the camera: shape, the frame loop, draw sets, + * queries, input, the globe, effects, credits, quality and the renderer. + * Each check reads and drives the renderer through the contract alone. + */ +import { + CAPABILITIES, + CONTRACT_VERSION, + QUALITY_KEYS, + checkPortShape, + checkRendererShape, +} from '../../contract/index.js'; +import { AUSTIN, SCRIPT_VIEWPORT } from '../cameraScripts.js'; +import { deepEqual, equal, near, nearVec, ok, throws } from '../expect.js'; + +const DOT = { + svg: '', +}; + +async function fresh(h) { + return h.create({ viewport: SCRIPT_VIEWPORT }); +} + +/** Looks straight down on Austin from `height` metres. */ +async function overAustin(h, r, height = 2000) { + r.camera.setView({ + destination: { ...AUSTIN, height }, + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + await h.frame(r); +} + +async function drawn(h, r, frames = 2) { + for (let i = 0; i < frames; i++) { + r.frame.requestRender('conformance'); + await h.frame(r); + } +} + +/** + * The listener rules on one subscription list: subscription order, one + * removed during delivery does not run, one added during delivery waits for + * the next event, and a throw goes to `error` while the rest still run. + * `subscribe(cb)` returns its Off; `fire()` raises one event. + */ +async function listenerRules(r, where, subscribe, fire) { + const errors = []; + const offError = r.on('error', (e) => errors.push(e)); + const seen = []; + const offs = []; + let offRemoved = () => {}; + let added = false; + offs.push( + subscribe(() => { + seen.push('first'); + offRemoved(); + if (!added) { + added = true; + offs.push(subscribe(() => seen.push('added'))); + } + }), + ); + offs.push( + subscribe(() => { + throw new Error('listener failure'); + }), + ); + offRemoved = subscribe(() => seen.push('removed')); + offs.push(offRemoved); + offs.push(subscribe(() => seen.push('last'))); + await fire(); + deepEqual( + seen, + ['first', 'last'], + `${where}: in order; removed during delivery does not run; added waits`, + ); + ok(errors.length >= 1, `${where}: the throw went to error`); + seen.length = 0; + await fire(); + deepEqual( + seen, + ['first', 'last', 'added'], + `${where}: one added during delivery runs from the next event`, + ); + for (const off of offs) off(); + offError(); +} + +/** A check over a fresh renderer, destroyed afterwards. */ +function check(id, title, run, extra = {}) { + return { + id, + port: id.split('.')[0], + title, + ...extra, + async run(h) { + const r = await fresh(h); + try { + if (extra.capability && !r.capabilities.has(extra.capability)) + return { skip: `no ${extra.capability}` }; + return await run(h, r); + } finally { + r.destroy(); + } + }, + }; +} + +const POINTER_TYPES = [ + 'down', + 'move', + 'up', + 'click', + 'dblclick', + 'wheel', + 'pinch', +]; + +const SET_KINDS = [ + ['icons', null], + ['points', null], + ['lines', null], + ['shapes', null], + ['models', 'models'], + ['quads', 'quads'], + ['meshes', 'meshes'], + ['agents', 'agents'], +]; + +export function portChecks() { + return [ + // --- Shape ------------------------------------------------------------- + check( + 'renderer.shape', + 'every port has exactly the members its capabilities allow', + (h, r) => { + deepEqual(checkRendererShape(r), [], 'renderer shape'); + equal(r.contractVersion, CONTRACT_VERSION, 'contract version'); + for (const c of r.capabilities) + ok(CAPABILITIES.includes(c), `known capability ${c}`); + for (const [kind, capability] of SET_KINDS) { + if (capability && !r.capabilities.has(capability)) continue; + const set = r.draw[kind]({ id: `shape:${kind}` }); + deepEqual( + checkPortShape(set, 'drawSet', r.capabilities), + [], + `${kind} set shape`, + ); + set.dispose(); + } + const layer = r.globe.imagery.add({ + kind: 'xyz', + url: 'https://example.invalid/{z}/{x}/{y}.png', + }); + deepEqual( + checkPortShape(layer, 'imageryLayer', r.capabilities), + [], + 'imagery layer shape', + ); + layer.remove(); + const flight = r.camera.flyTo( + { ...AUSTIN, height: 1e6 }, + { durationS: 1 }, + ); + deepEqual( + checkPortShape(flight, 'flight', r.capabilities), + [], + 'flight shape', + ); + r.camera.cancelFlight(); + const lease = r.input.claimPointer('conformance'); + deepEqual( + checkPortShape(lease, 'lease', r.capabilities), + [], + 'lease shape', + ); + lease.release(); + }, + ), + check( + 'renderer.listeners', + 'listener order, removal during delivery and a throwing listener', + async (h, r) => { + const errors = []; + r.on('error', (e) => errors.push(e)); + const seen = []; + let offB = () => {}; + r.frame.on('preUpdate', () => { + seen.push('a'); + offB(); + r.frame.on('preUpdate', () => seen.push('late')); + }); + offB = r.frame.on('preUpdate', () => seen.push('b')); + r.frame.on('preUpdate', () => { + throw new Error('listener failure'); + }); + r.frame.on('preUpdate', () => seen.push('c')); + await h.frame(r); + deepEqual( + seen, + ['a', 'c'], + 'removed during delivery does not run; added during delivery waits', + ); + equal(errors.length, 1, 'the throw went to error'); + // The error list keeps the rules too. While an error is delivered, + // A removes B and subscribes the same callback again: B does not run + // for that error, and runs from the next one. + const errorSeen = []; + const b = () => errorSeen.push('b'); + let offErrorB = () => {}; + let resubscribed = false; + r.on('error', () => { + errorSeen.push('a'); + if (resubscribed) return; + resubscribed = true; + offErrorB(); + offErrorB = r.on('error', b); + }); + offErrorB = r.on('error', b); + r.frame.on('preUpdate', () => { + throw new Error('another failure'); + }); + seen.length = 0; + await h.frame(r); + ok( + seen.includes('late'), + 'a listener added during delivery runs from the next frame', + ); + deepEqual( + errorSeen, + ['a', 'a', 'b'], + 'an error listener removed during delivery does not run; subscribed again, it waits', + ); + }, + ), + + check( + 'renderer.listenerRules', + 'every subscription list follows the listener rules', + async (h, r) => { + await overAustin(h, r); + const credits = []; + const lists = [ + [ + 'frame.on(postRender)', + (cb) => r.frame.on('postRender', cb), + () => drawn(h, r, 1), + ], + [ + 'camera.on(flightEnd)', + (cb) => r.camera.on('flightEnd', cb), + () => r.camera.flyTo({ ...AUSTIN, height: 2000 }, { durationS: 0 }), + ], + [ + 'camera.onCommand', + (cb) => r.camera.onCommand(cb), + () => + r.camera.setView({ destination: { ...AUSTIN, height: 2000 } }), + ], + [ + 'credits.on(change)', + (cb) => r.credits.on('change', cb), + () => { + credits.push(r.credits.addStatic({ html: 'Conformance data' })); + }, + ], + [ + 'quality.signals.on(frame)', + (cb) => r.quality.signals.on('frame', cb), + () => drawn(h, r, 1), + ], + ]; + // A move between two far-apart views raises one of each camera event. + let flip = false; + const moveCamera = async () => { + await h.frame(r); + flip = !flip; + r.camera.setView({ + destination: { ...AUSTIN, height: flip ? 400000 : 2000 }, + }); + for (let i = 0; i < 40; i++) await h.frame(r); + }; + for (const event of ['changed', 'moveStart', 'moveEnd']) + lists.push([ + `camera.on(${event})`, + (cb) => r.camera.on(event, cb), + moveCamera, + ]); + if (h.pointer) + lists.push([ + 'input.on(move)', + (cb) => r.input.on('move', cb), + () => h.pointer(r, 'move', 10, 10), + ]); + for (const [where, subscribe, fire] of lists) + await listenerRules(r, where, subscribe, fire); + for (const off of credits) off(); + }, + ), + + // --- Frame ------------------------------------------------------------- + check( + 'frame.order', + 'phases run preUpdate → preRender → postRender in a drawing frame', + async (h, r) => { + const seen = []; + r.frame.on('postRender', () => seen.push('postRender')); + r.frame.on('preRender', () => seen.push('preRender')); + r.frame.on('preUpdate', () => seen.push('preUpdate')); + r.frame.requestRender('order'); + await h.frame(r); + deepEqual( + seen, + ['preUpdate', 'preRender', 'postRender'], + 'phase order', + ); + }, + ), + check( + 'frame.demand', + 'on demand: preUpdate every tick, drawing only when asked or held', + async (h, r) => { + if (r.frame.mode() === 'continuous') + return { skip: 'continuous engine' }; + await drawn(h, r, 3); + const seen = []; + r.frame.on('preUpdate', (t) => seen.push(['u', t.rendering])); + r.frame.on('postRender', () => seen.push(['r'])); + await h.frame(r); + deepEqual( + seen, + [['u', false]], + 'an idle tick runs preUpdate and draws nothing', + ); + seen.length = 0; + r.frame.requestRender('one'); + ok( + r.frame.diagnostics().pendingReasons.includes('one'), + 'the reason is pending', + ); + await h.frame(r); + deepEqual(seen, [['u', true], ['r']], 'a request draws one frame'); + seen.length = 0; + await h.frame(r); + deepEqual(seen, [['u', false]], 'and only one'); + const a = r.frame.holdContinuous('a'); + const b = r.frame.holdContinuous('b'); + deepEqual( + [...r.frame.diagnostics().holds].sort(), + ['a', 'b'], + 'holds listed', + ); + seen.length = 0; + await h.frame(r); + await h.frame(r); + equal( + seen.filter(([k]) => k === 'r').length, + 2, + 'a hold draws every frame', + ); + a(); + a(); + seen.length = 0; + await h.frame(r); + equal(seen.filter(([k]) => k === 'r').length, 1, 'one hold still out'); + b(); + seen.length = 0; + await h.frame(r); + await h.frame(r); + equal(seen.filter(([k]) => k === 'r').length, 0, 'no holds, no frames'); + }, + ), + check( + 'frame.time', + 'scene time is set and reported in FrameTime', + async (h, r) => { + const t0 = Date.UTC(2026, 5, 21, 12); + r.frame.setTime(t0); + equal(r.frame.time(), t0, 'time()'); + let seen = null; + r.frame.on('preUpdate', (t) => (seen = t)); + await h.frame(r); + equal(seen.time, t0, 'FrameTime.time'); + ok(seen.dt > 0, 'dt is positive'); + ok(Number.isInteger(seen.frame), 'frame is a count'); + }, + ), + check( + 'frame.renderOnce', + 'renderOnce resolves after a drawn frame; renderNow draws now', + async (h, r) => { + let drawnFrames = 0; + r.frame.on('postRender', () => (drawnFrames += 1)); + const once = r.frame.renderOnce(); + await h.frame(r); + await once; + ok(drawnFrames >= 1, 'renderOnce drew a frame'); + const before = drawnFrames; + r.frame.renderNow(); + equal(drawnFrames, before + 1, 'renderNow drew synchronously'); + }, + ), + check('frame.suspend', 'a suspended loop does not tick', async (h, r) => { + let ticks = 0; + r.frame.on('preUpdate', () => (ticks += 1)); + r.frame.suspend(true); + ok(r.frame.diagnostics().suspended, 'suspended'); + await h.frame(r); + equal(ticks, 0, 'no ticks while hidden'); + r.frame.suspend(false); + await h.frame(r); + equal(ticks, 1, 'ticking again'); + }), + + // --- Draw -------------------------------------------------------------- + check('draw.sets', 'set, upsert, remove, clear and size', (h, r) => { + const set = r.draw.points({ id: 'conf:points' }); + const p = (id, lon) => ({ + id, + pos: { lon, lat: AUSTIN.lat, height: 0 }, + pixelSize: 8, + color: '#ff0000', + }); + set.set([p('a', -97.74), p('b', -97.73)]); + equal(set.size(), 2, 'set'); + set.upsert([p('b', -97.72), p('c', -97.71)]); + equal(set.size(), 3, 'upsert adds and replaces'); + set.remove(['a', 'missing']); + equal(set.size(), 2, 'remove ignores unknown ids'); + set.set([p('d', -97.7)]); + equal(set.size(), 1, 'set replaces every item'); + set.clear(); + equal(set.size(), 0, 'clear'); + set.dispose(); + }), + check( + 'draw.ids', + 'set ids are unique among live sets and free again once disposed', + (h, r) => { + const a = r.draw.icons({ id: 'conf:dup' }); + throws( + () => r.draw.lines({ id: 'conf:dup' }), + 'a second live set with the id', + ); + a.dispose(); + const b = r.draw.lines({ id: 'conf:dup' }); + equal(b.id, 'conf:dup', 'the id is free after dispose'); + a.dispose(); + throws( + () => r.draw.points({ id: 'conf:dup' }), + 'disposing the old handle again leaves the newer set live', + ); + b.dispose(); + }, + ), + check( + 'draw.pick', + 'a drawn icon is picked under its projection with its set, id and meta', + async (h, r) => { + await overAustin(h, r); + const set = r.draw.icons({ id: 'conf:pick' }); + const pos = h.toEcef({ ...AUSTIN, height: 0 }); + set.set([ + { id: 'one', pos, image: DOT, size: [24, 24], meta: { n: 1 } }, + ]); + await drawn(h, r); + const s = r.query.project(pos); + ok(s, 'the icon projects'); + const hit = r.query.pick(s.x, s.y); + ok(hit, 'a hit'); + equal(hit.set, 'conf:pick', 'Hit.set'); + equal(hit.id, 'one', 'Hit.id'); + deepEqual(hit.meta, { n: 1 }, 'Hit.meta'); + equal(hit.surface, false, 'not the surface'); + for (const purpose of ['hover', 'select']) + equal( + r.query.pick(s.x, s.y, { purpose })?.id, + 'one', + `a ${purpose} pick finds an icon too`, + ); + set.setOptions({ pickable: false }); + await drawn(h, r); + const after = r.query.pick(s.x, s.y); + ok( + !after || after.set !== 'conf:pick', + 'an unpickable set is not picked', + ); + set.setOptions({ pickable: true }); + set.show = false; + await drawn(h, r); + const hidden = r.query.pick(s.x, s.y); + ok(!hidden || hidden.set !== 'conf:pick', 'a hidden set is not picked'); + set.dispose(); + }, + ), + check( + 'draw.drillPick', + 'drillPick returns every item under the pointer', + async (h, r) => { + await overAustin(h, r); + const set = r.draw.points({ id: 'conf:drill' }); + const pos = h.toEcef({ ...AUSTIN, height: 0 }); + set.set([ + { id: 'a', pos, pixelSize: 20, color: '#f00' }, + { id: 'b', pos, pixelSize: 20, color: '#0f0' }, + ]); + await drawn(h, r); + const s = r.query.project(pos); + const ids = r.query + .drillPick(s.x, s.y) + .filter((x) => x.set === 'conf:drill') + .map((x) => x.id) + .sort(); + deepEqual(ids, ['a', 'b'], 'both hits'); + equal(r.query.drillPick(s.x, s.y, { limit: 1 }).length, 1, 'limit'); + set.dispose(); + }, + ), + check( + 'draw.motion', + 'icons move by their segments on the set\u2019s motion clock', + async (h, r) => { + await overAustin(h, r, 8000); + const t0 = r.frame.time(); + const from = h.toEcef({ + lon: AUSTIN.lon - 0.02, + lat: AUSTIN.lat, + height: 0, + }); + const to = h.toEcef({ + lon: AUSTIN.lon + 0.02, + lat: AUSTIN.lat, + height: 0, + }); + const set = r.draw.icons({ + id: 'conf:motion', + motionClock: { kind: 'scene' }, + }); + set.set([ + { + id: 'mover', + pos: from, + image: DOT, + size: [16, 16], + motion: { kind: 'lerp', from, to, t0, t1: t0 + 10000 }, + }, + ]); + const pickAt = (p) => { + const s = r.query.project(p); + return r.query + .drillPick(s.x, s.y) + .some((x) => x.set === 'conf:motion'); + }; + r.frame.setTime(t0 + 5000); + await drawn(h, r); + const mid = { + x: (from.x + to.x) / 2, + y: (from.y + to.y) / 2, + z: (from.z + to.z) / 2, + }; + ok(pickAt(mid), 'picked halfway at half time'); + ok(!pickAt(from), 'not still at the start'); + r.frame.setTime(t0 + 60000); + await drawn(h, r); + ok(pickAt(to), 'holds at the end'); + set.dispose(); + }, + { capability: 'motionSegments' }, + ), + check( + 'draw.motion.arc', + 'an arc turns in the fix\u2019s tangent plane and coasts no longer than maxDtS', + async (h, r) => { + await overAustin(h, r, 8000); + const t0 = r.frame.time(); + const fixAt = { ...AUSTIN, height: 0 }; + const fix = h.toEcef(fixAt); + const segment = { + kind: 'arc', + fix, + t0, + speedMps: 50, + trackDeg: 90, + turnRateDps: 3, + minDtS: 0, + maxDtS: 20, + }; + const set = r.draw.icons({ + id: 'conf:arc', + motionClock: { kind: 'scene' }, + }); + set.set([ + { + id: 'turner', + pos: fix, + image: DOT, + size: [16, 16], + motion: segment, + }, + ]); + // The contract's formula, on the fix's own east and north axes. + const lon = (fixAt.lon * Math.PI) / 180; + const lat = (fixAt.lat * Math.PI) / 180; + const east = { x: -Math.sin(lon), y: Math.cos(lon), z: 0 }; + const north = { + x: -Math.sin(lat) * Math.cos(lon), + y: -Math.sin(lat) * Math.sin(lon), + z: Math.cos(lat), + }; + const at = (dtS) => { + const track = (segment.trackDeg * Math.PI) / 180; + const w = (segment.turnRateDps * Math.PI) / 180; + const v = segment.speedMps; + const e = (v / w) * (Math.cos(track) - Math.cos(track + w * dtS)); + const n = (v / w) * (Math.sin(track + w * dtS) - Math.sin(track)); + return { + x: fix.x + east.x * e + north.x * n, + y: fix.y + east.y * e + north.y * n, + z: fix.z + east.z * e + north.z * n, + }; + }; + const pickAt = (p) => { + const s = r.query.project(p); + return r.query.drillPick(s.x, s.y).some((x) => x.set === 'conf:arc'); + }; + r.frame.setTime(t0 + 20000); + await drawn(h, r); + ok(pickAt(at(20)), 'on the arc at 20 s'); + const straight = { + x: fix.x + east.x * 1000, + y: fix.y + east.y * 1000, + z: fix.z + east.z * 1000, + }; + ok(!pickAt(straight), 'not where a straight track would be'); + r.frame.setTime(t0 + 60000); + await drawn(h, r); + ok(pickAt(at(20)), 'coasts no further than maxDtS'); + set.dispose(); + }, + { capability: 'motionSegments' }, + ), + check( + 'draw.motion.live', + 'a live motion clock runs delayMs behind wall time', + async (h, r) => { + if (!h.wallTime) return { skip: 'no wall time in this harness' }; + await overAustin(h, r, 8000); + const w = h.wallTime(r); + const from = h.toEcef({ ...AUSTIN, lon: AUSTIN.lon - 0.02, height: 0 }); + const to = h.toEcef({ ...AUSTIN, lon: AUSTIN.lon + 0.02, height: 0 }); + const along = (f) => ({ + x: from.x + (to.x - from.x) * f, + y: from.y + (to.y - from.y) * f, + z: from.z + (to.z - from.z) * f, + }); + const set = r.draw.icons({ + id: 'conf:live', + motionClock: { kind: 'live', delayMs: 2000 }, + }); + // Halfway at wall time minus the delay; 70% at wall time itself. + set.set([ + { + id: 'mover', + pos: from, + image: DOT, + size: [16, 16], + motion: { kind: 'lerp', from, to, t0: w - 7000, t1: w + 3000 }, + }, + ]); + // Scene time an hour away: a live set must not read it. + r.frame.setTime(w - 3600000); + await drawn(h, r, 1); + const pickAt = (p) => { + const s = r.query.project(p); + return r.query.drillPick(s.x, s.y).some((x) => x.set === 'conf:live'); + }; + ok(pickAt(along(0.5)), 'drawn delayMs behind wall time'); + ok(!pickAt(along(0.7)), 'not at wall time'); + ok(!pickAt(from), 'not held at the start by the scene clock'); + // A second of frames later it has moved on by the wall time passed. + await drawn(h, r, 60); + const passed = h.wallTime(r) - w; + ok(passed > 0, 'wall time passed'); + // The segment holds at its end, however much wall time passed. + ok( + pickAt(along(Math.min(1, 0.5 + passed / 10000))), + 'moves on with wall time', + ); + set.dispose(); + }, + { capability: 'motionSegments' }, + ), + check( + 'draw.columns', + 'column updates are rows: sized, picked, removed', + async (h, r) => { + await overAustin(h, r); + const set = r.draw.icons({ id: 'conf:columns' }); + const a = h.toEcef({ + lon: AUSTIN.lon - 0.003, + lat: AUSTIN.lat, + height: 0, + }); + const b = h.toEcef({ + lon: AUSTIN.lon + 0.003, + lat: AUSTIN.lat, + height: 0, + }); + set.set([ + { id: 'a', pos: a, image: DOT, size: [16, 16] }, + { id: 'b', pos: b, image: DOT, size: [16, 16] }, + ]); + set.setColumns({ + ids: ['a', 'b'], + positions: new Float64Array([b.x, b.y, b.z, a.x, a.y, a.z]), + meta: [{ k: 'a' }, { k: 'b' }], + }); + await drawn(h, r); + equal(set.size(), 2, 'size'); + const sa = r.query.project(a); + const hit = r.query + .drillPick(sa.x, sa.y) + .find((x) => x.set === 'conf:columns'); + equal(hit?.id, 'b', 'b moved to a’s place'); + deepEqual(hit?.meta, { k: 'b' }, 'meta from the columns'); + set.remove(['a']); + equal(set.size(), 1, 'removed'); + set.dispose(); + }, + { capability: 'columns' }, + ), + check( + 'draw.models', + 'a model set says when a model is ready', + async (h, r) => { + if (!h.assets?.model) return { skip: 'no model asset in this harness' }; + const set = r.draw.models({ id: 'conf:models' }); + const ready = []; + set.on('ready', (id) => ready.push(id)); + set.set([ + { + id: 'm', + pos: { ...AUSTIN, height: 0 }, + url: h.assets.model, + orientation: { heading: 0, pitch: 0, roll: 0 }, + }, + ]); + for (let i = 0; i < 600 && !ready.length; i++) await drawn(h, r, 1); + deepEqual(ready, ['m'], 'ready'); + set.dispose(); + }, + { capability: 'models' }, + ), + + // --- Query ------------------------------------------------------------- + check( + 'query.projectMany', + 'projectMany agrees with project; NaN behind the eye', + async (h, r) => { + await overAustin(h, r, 5000); + const points = [ + h.toEcef({ ...AUSTIN, height: 0 }), + h.toEcef({ lon: AUSTIN.lon + 0.01, lat: AUSTIN.lat, height: 0 }), + h.toEcef({ ...AUSTIN, height: 1e6 }), + ]; + const source = new Float64Array(points.flatMap((p) => [p.x, p.y, p.z])); + const out = new Float32Array(points.length * 2); + r.query.projectMany(source, out); + for (let i = 0; i < 2; i++) { + const s = r.query.project(points[i]); + near(out[i * 2], s.x, 0.01, `x ${i}`); + near(out[i * 2 + 1], s.y, 0.01, `y ${i}`); + } + equal(r.query.project(points[2]), null, 'project behind the eye'); + ok( + Number.isNaN(out[4]) && Number.isNaN(out[5]), + 'projectMany behind the eye', + ); + }, + ), + check( + 'query.rays', + 'pickRay starts at the eye; pickEllipsoid lies on its ray; pickGlobe agrees', + async (h, r) => { + await overAustin(h, r, 5000); + const eye = r.camera.pose().position; + const ray = r.query.pickRay(640, 360); + nearVec(ray.origin, eye, 1e-3, 'ray origin'); + near( + Math.hypot(ray.direction.x, ray.direction.y, ray.direction.z), + 1, + 1e-9, + 'unit direction', + ); + const ground = r.query.pickEllipsoid(640, 360); + ok(ground, 'center of the view meets the ellipsoid'); + const t = + (ground.x - eye.x) * ray.direction.x + + (ground.y - eye.y) * ray.direction.y + + (ground.z - eye.z) * ray.direction.z; + nearVec( + ground, + { + x: eye.x + ray.direction.x * t, + y: eye.y + ray.direction.y * t, + z: eye.z + ray.direction.z * t, + }, + 0.01, + 'on the ray', + ); + const s = r.query.project(ground); + nearVec(s, { x: 640, y: 360 }, 0.5, 'projects back to the pixel'); + // A bare globe: the globe surface is the ellipsoid. + nearVec( + r.query.pickGlobe(ray), + ground, + 0.5, + 'pickGlobe meets the globe there', + ); + const up = { origin: eye, direction: ray.direction }; + up.direction = { + x: -ray.direction.x, + y: -ray.direction.y, + z: -ray.direction.z, + }; + equal(r.query.pickGlobe(up), null, 'a ray away from the globe misses'); + }, + ), + check( + 'query.heights', + 'heights: a cached height now, async samples per point, misses as null', + async (h, r) => { + if (!h.scriptHeights) + return { skip: 'no scripted heights in this harness' }; + h.scriptHeights(r, (point) => (point.lon > 0 ? null : 123.5)); + const [hit, miss] = await r.query.sampleHeight([ + { ...AUSTIN }, + { lon: 10, lat: 10 }, + ]); + near(hit.height, 123.5, 0.01, 'sampled height'); + ok(hit.confidence > 0 && hit.confidence <= 1, 'confidence in (0, 1]'); + ok(['tiles', 'terrain', 'ellipsoid'].includes(hit.source), 'source'); + ok(['visible', 'finest'].includes(hit.lod), 'lod'); + equal(miss, null, 'a miss is null'); + h.scriptHeights(r, (point, against) => + against === 'tiles' ? 300 : 123.5, + ); + const there = { lon: AUSTIN.lon + 0.5, lat: AUSTIN.lat }; + const [terrain] = await r.query.sampleHeight([there], { + against: 'terrain', + }); + const [tiles] = await r.query.sampleHeight([there], { + against: 'tiles', + }); + near(terrain.height, 123.5, 0.01, 'terrain'); + near(tiles.height, 300, 0.01, 'tiles, not the terrain answer'); + h.scriptHeights(r, (point) => (point.lon > 0 ? null : 123.5)); + const out = new Float64Array(2); + r.query.sampleHeightNowMany( + new Float64Array([AUSTIN.lon, AUSTIN.lat, 10, 10]), + out, + ); + near(out[0], 123.5, 0.01, 'a batched cached height'); + ok(Number.isNaN(out[1]), 'a batched miss is NaN'); + const [plain] = await r.query.sampleHeight([{ ...AUSTIN }], { + mostDetailed: true, + }); + equal( + plain.lod === 'finest', + r.capabilities.has('finestHeights'), + 'finest only with finestHeights', + ); + const [clamped] = await r.query.clampToSurface([ + h.toEcef({ ...AUSTIN, height: 5000 }), + ]); + nearVec( + clamped, + h.toEcef({ ...AUSTIN, height: 123.5 }), + 0.05, + 'clamped onto the surface', + ); + }, + { capability: 'sampleHeight' }, + ), + check( + 'query.withoutCapability', + 'without sampleHeight the height members answer null', + async (h) => { + const r = await h.create({ + viewport: SCRIPT_VIEWPORT, + capabilities: CAPABILITIES.filter( + (c) => !['sampleHeight', 'pickPosition', 'rayCast'].includes(c), + ), + }); + if (!r) return { skip: 'this harness cannot drop capabilities' }; + try { + equal( + r.query.sampleHeightNow({ ...AUSTIN }), + null, + 'sampleHeightNow', + ); + deepEqual( + await r.query.sampleHeight([{ ...AUSTIN }]), + [null], + 'sampleHeight', + ); + equal(r.query.pickPosition(640, 360), null, 'pickPosition'); + equal( + await r.query.rayCast(r.query.pickRay(640, 360)), + null, + 'rayCast', + ); + } finally { + r.destroy(); + } + }, + ), + + // --- Input ------------------------------------------------------------- + check( + 'input.lease', + 'a lease leaves ambient subscribers only move and up; a new claim ends the old', + async (h, r) => { + if (!h.pointer) return { skip: 'no pointer in this harness' }; + const ambient = []; + for (const type of POINTER_TYPES) + r.input.on(type, () => ambient.push(type)); + const seen = []; + r.input.on('click', () => seen.push('ambient')); + const lease = r.input.claimPointer('tool'); + for (const type of POINTER_TYPES) await h.pointer(r, type, 640, 360); + deepEqual(ambient, ['move', 'up'], 'ambient under a lease'); + seen.length = 0; + r.input.on('click', () => seen.push('tool'), { lease }); + ok(!r.input.isPointerFree(), 'taken'); + await h.pointer(r, 'click', 640, 360); + deepEqual(seen, ['tool'], 'only the lease holder hears the click'); + const next = r.input.claimPointer('other'); + ok(!lease.current, 'the old lease ended'); + ok(next.current, 'the new one is current'); + seen.length = 0; + await h.pointer(r, 'click', 640, 360); + deepEqual(seen, [], 'neither the ambient nor the ended lease'); + next.release(); + ok(r.input.isPointerFree(), 'free again'); + await h.pointer(r, 'click', 640, 360); + deepEqual(seen, ['ambient'], 'ambient again'); + }, + ), + check( + 'input.clickPick', + 'a click carries one pick shared by every subscriber', + async (h, r) => { + if (!h.pointer) return { skip: 'no pointer in this harness' }; + await overAustin(h, r); + const set = r.draw.icons({ id: 'conf:click' }); + const pos = h.toEcef({ ...AUSTIN, height: 0 }); + set.set([{ id: 'x', pos, image: DOT, size: [24, 24] }]); + await drawn(h, r); + const s = r.query.project(pos); + const hits = []; + r.input.on('click', (ev) => hits.push(ev.pick())); + r.input.on('click', (ev) => hits.push(ev.pick())); + await h.pointer(r, 'click', s.x, s.y); + equal(hits.length, 2, 'both subscribers'); + equal(hits[0]?.id, 'x', 'the hit'); + equal(hits[0], hits[1], 'the same pick object'); + set.dispose(); + }, + ), + check( + 'input.behaviours', + 'stock behaviours are listed and suppression is counted', + (h, r) => { + for (const name of ['select', 'dblclickFollow']) { + const b = r.input.behaviours[name]; + ok(b && typeof b.suppress === 'function', `${name} listed`); + if (!b.enabled) continue; + const one = b.suppress(); + const two = b.suppress(); + ok(!b.enabled, `${name} suppressed`); + one(); + one(); + ok(!b.enabled, `${name} still suppressed`); + two(); + ok(b.enabled, `${name} back`); + } + }, + ), + + // --- Globe ------------------------------------------------------------- + check( + 'globe.imagery', + 'imagery layers keep order, raise and lower, and leave on remove', + (h, r) => { + const a = r.globe.imagery.add({ + kind: 'xyz', + url: 'https://a.invalid/{z}/{x}/{y}.png', + }); + const b = r.globe.imagery.add({ + kind: 'xyz', + url: 'https://b.invalid/{z}/{x}/{y}.png', + }); + const ours = () => + r.globe.imagery + .list() + .filter((l) => l === a || l === b) + .map((l) => (l === a ? 'a' : 'b')); + deepEqual(ours(), ['a', 'b'], 'added in order'); + a.raise(); + deepEqual(ours(), ['b', 'a'], 'raised'); + a.lower(); + deepEqual(ours(), ['a', 'b'], 'lowered'); + a.alpha = 0.5; + equal(a.alpha, 0.5, 'alpha'); + a.remove(); + deepEqual(ours(), ['b'], 'the removed layer is the one gone'); + b.remove(); + deepEqual(ours(), [], 'both removed'); + ok(['globe', 'tiles', 'both'].includes(r.globe.imagery.host()), 'host'); + }, + ), + check( + 'globe.split', + 'the split position round-trips', + (h, r) => { + r.globe.imagery.setSplit(0.3); + near(r.globe.imagery.getSplit(), 0.3, 1e-9, 'split'); + }, + { capability: 'splitSwipe' }, + ), + check( + 'globe.sky', + 'sky settings round-trip and keep unchanged keys', + (h, r) => { + const before = r.globe.sky.get(); + r.globe.sky.set({ stars: !before.stars, milkyWay: 0.25 }); + const after = r.globe.sky.get(); + equal(after.stars, !before.stars, 'stars'); + near(after.milkyWay, 0.25, 1e-9, 'milkyWay'); + equal(after.atmosphere, before.atmosphere, 'atmosphere unchanged'); + }, + ), + + // --- Effects, credits, quality --------------------------------------- + check( + 'effects.styles', + 'a style is set, tuned and cleared; diagnostics follow', + (h, r) => { + const styles = r.effects.styles(); + ok(styles.length > 0, 'styles listed'); + const changes = []; + r.effects.on('change', (d) => changes.push(d)); + r.effects.setStyle(styles[0].id); + equal(r.effects.diagnostics().style, styles[0].id, 'style active'); + r.effects.setStyleIntensity(styles[0].id, 0.5); + r.effects.setStyle(null); + equal(r.effects.diagnostics().style, null, 'cleared'); + ok(changes.length >= 2, 'change events'); + }, + { capability: 'styles' }, + ), + check( + 'effects.withoutStyles', + 'without styles, styles() is empty', + async (h) => { + const r = await h.create({ + viewport: SCRIPT_VIEWPORT, + capabilities: CAPABILITIES.filter((c) => c !== 'styles'), + }); + if (!r) return { skip: 'this harness cannot drop capabilities' }; + try { + deepEqual(r.effects.styles(), [], 'styles()'); + } finally { + r.destroy(); + } + }, + ), + check( + 'credits', + 'a static credit is listed until its Off, with change events', + (h, r) => { + const events = []; + r.credits.on('change', (list) => events.push(list.length)); + const base = r.credits.list().length; + const off = r.credits.addStatic({ html: 'Conformance data' }); + ok( + r.credits.list().some((c) => c.html === 'Conformance data'), + 'listed', + ); + off(); + equal(r.credits.list().length, base, 'removed'); + deepEqual(events, [base + 1, base], 'change events'); + }, + ), + check( + 'quality', + 'knobs name real keys; set applies supported keys and returns the rest', + async (h, r) => { + const knobs = r.quality.knobs(); + for (const k of knobs) + ok(QUALITY_KEYS.includes(k.key), `known key ${k.key}`); + const supported = new Set(knobs.map((k) => k.key)); + // A value each knob accepts and that differs from its default. + const valueFor = (k) => { + if (k.kind === 'boolean') return !k.default; + if (k.kind === 'enum') + return k.options.find((o) => o !== k.default) ?? k.default; + if (k.kind === 'number') return k.default === k.min ? k.max : k.min; + return k.default; + }; + const wanted = Object.fromEntries( + knobs.map((k) => [k.key, valueFor(k)]), + ); + const patch = { + ...Object.fromEntries(QUALITY_KEYS.map((k) => [k, 0.5])), + ...wanted, + }; + const ignored = r.quality.set(patch); + await drawn(h, r, 1); + const applied = r.quality.get(); + for (const [key, value] of Object.entries(wanted)) + deepEqual(applied[key], value, `${key} applied`); + deepEqual( + [...ignored].sort(), + QUALITY_KEYS.filter((k) => !supported.has(k)).sort(), + 'ignored keys', + ); + ok( + ['none', 'frame', 'perPass'].includes(r.quality.signals.gpuTiming), + 'gpuTiming', + ); + const facts = r.quality.device(); + ok(facts && typeof facts === 'object', 'device facts'); + }, + ), + check( + 'renderer.whenSettled', + 'whenSettled names what is still loading', + async (h, r) => { + const result = await r.whenSettled({ timeoutMs: 5000 }); + ok(['settled', 'timeout', 'failed'].includes(result.state), 'state'); + ok(Array.isArray(result.blockers), 'blockers'); + equal( + result.state === 'settled', + result.blockers.length === 0, + 'settled exactly when nothing blocks', + ); + }, + ), + check( + 'quality.signals', + 'frame signals arrive for drawn frames', + async (h, r) => { + const signals = []; + r.quality.signals.on('frame', (s) => signals.push(s)); + await drawn(h, r, 2); + ok(signals.length >= 1, 'signals'); + ok(signals[0].width > 0 && signals[0].height > 0, 'size'); + }, + ), + ]; +} diff --git a/src/render/conformance/expect.js b/src/render/conformance/expect.js new file mode 100644 index 000000000..2f29be2e3 --- /dev/null +++ b/src/render/conformance/expect.js @@ -0,0 +1,93 @@ +/** + * @module render/conformance/expect + * @description Assertions for the conformance suite. Plain functions that + * throw, so the suite runs in Node and in a browser page alike. + */ + +export class ConformanceError extends Error { + constructor(message) { + super(message); + this.name = 'ConformanceError'; + } +} + +export function ok(value, message) { + if (!value) throw new ConformanceError(message); +} + +export function equal(actual, expected, message) { + if (!Object.is(actual, expected)) + throw new ConformanceError( + `${message}: expected ${show(expected)}, got ${show(actual)}`, + ); +} + +export function deepEqual(actual, expected, message) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) + throw new ConformanceError( + `${message}: expected ${show(expected)}, got ${show(actual)}`, + ); +} + +export function near(actual, expected, tolerance, message) { + if ( + !(Number.isFinite(actual) && Number.isFinite(expected)) || + Math.abs(actual - expected) > tolerance + ) + throw new ConformanceError( + `${message}: expected ${expected} ± ${tolerance}, got ${actual}`, + ); +} + +/** Angles in radians, compared around the circle. */ +export function nearAngle(actual, expected, tolerance, message) { + const tau = Math.PI * 2; + let d = Math.abs(actual - expected) % tau; + if (d > Math.PI) d = tau - d; + if (!(d <= tolerance)) + throw new ConformanceError( + `${message}: expected ${expected} ± ${tolerance} rad, got ${actual}`, + ); +} + +export function nearVec(actual, expected, tolerance, message) { + if (actual === null || expected === null) { + equal(actual, expected, message); + return; + } + const d = Math.hypot( + actual.x - expected.x, + actual.y - expected.y, + (actual.z ?? 0) - (expected.z ?? 0), + ); + if (!(d <= tolerance)) + throw new ConformanceError( + `${message}: ${show(actual)} is ${d} from ${show(expected)} (± ${tolerance})`, + ); +} + +export async function rejects(promiseOrFn, message) { + try { + await (typeof promiseOrFn === 'function' ? promiseOrFn() : promiseOrFn); + } catch { + return; + } + throw new ConformanceError(`${message}: did not throw`); +} + +export function throws(fn, message) { + try { + fn(); + } catch { + return; + } + throw new ConformanceError(`${message}: did not throw`); +} + +function show(value) { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/src/render/conformance/goldens/camera.json b/src/render/conformance/goldens/camera.json new file mode 100644 index 000000000..f5f12ffab --- /dev/null +++ b/src/render/conformance/goldens/camera.json @@ -0,0 +1,2290 @@ +{ + "cesium": "1.138.0", + "viewport": { + "width": 1280, + "height": 720 + }, + "scripts": { + "setView angles": { + "records": [ + { + "step": 1, + "kind": "pose", + "value": { + "position": { + "x": -2488310.822590764, + "y": -18300255.464286, + "z": 10756490.228710467 + }, + "direction": { + "x": 0.11622572281662877, + "y": 0.8547808415875731, + "z": -0.5058075663832962 + }, + "up": { + "x": 0.06814826350853613, + "y": 0.5011956787437056, + "z": 0.8626463387677521 + }, + "right": { + "x": 0.9908821299915159, + "y": -0.13473160157690067, + "z": -1.5959455978986628e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 15000000.000000002, + "frameIsIdentity": true + } + }, + { + "step": 3, + "kind": "pose", + "value": { + "position": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": 0.36092901926672916, + "y": 0.8441580339915824, + "z": 0.39639305833808886 + }, + "up": { + "x": 0.1059330423425059, + "y": -0.45940299877841734, + "z": 0.8818883575903803 + }, + "right": { + "x": 0.9265573018390272, + "y": -0.2763079773746383, + "z": -0.25523610255588536 + }, + "heading": 0.2999999999999998, + "pitch": -0.6000000000000001, + "roll": 6.283185307179586, + "height": 600.0000000002616, + "frameIsIdentity": true + } + }, + { + "step": 5, + "kind": "pose", + "value": { + "position": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": -0.2782358887254662, + "y": 0.6733761961755564, + "z": 0.6849447340109189 + }, + "up": { + "x": -0.0320358403425136, + "y": -0.7192122583491108, + "z": 0.6940514623382916 + }, + "right": { + "x": 0.9599783826517654, + "y": 0.17116724530268476, + "z": 0.22168283419514367 + }, + "heading": 5.9, + "pitch": -0.19999999999999973, + "roll": 0.15000000000000036, + "height": 600.0000000002616, + "frameIsIdentity": true + } + }, + { + "step": 7, + "kind": "pose", + "value": { + "position": { + "x": -745736.5579576448, + "y": -5484511.5794148045, + "z": 3208591.937820149 + }, + "direction": { + "x": 0.09879959240734887, + "y": 0.6147902506052381, + "z": -0.7824778516360001 + }, + "up": { + "x": -0.06700477886630624, + "y": -0.780430421521237, + "z": -0.621641952230753 + }, + "right": { + "x": -0.9928489311819897, + "y": 0.11384772692035962, + "z": -0.03591232275762872 + }, + "heading": 3.1, + "pitch": -1.1999999999999997, + "roll": 6.283185307179586, + "height": 24999.99999999683, + "frameIsIdentity": true + } + }, + { + "step": 9, + "kind": "pose", + "value": { + "position": { + "x": 5486.397762395267, + "y": 1216.305101059199, + "z": 6396749.862229852 + }, + "direction": { + "x": -0.5432980145993753, + "y": 0.5387723520199353, + "z": -0.6438568319364827 + }, + "up": { + "x": -0.45649972377785625, + "y": 0.45404863330428463, + "z": 0.7651456337098529 + }, + "right": { + "x": 0.7045816272960277, + "y": 0.7096225696054338, + "z": -0.0007342911295312447 + }, + "heading": 1, + "pitch": -0.6999999999999997, + "roll": 6.283185307179586, + "height": 40000.000000676286, + "frameIsIdentity": true + } + }, + { + "step": 11, + "kind": "pose", + "value": { + "position": { + "x": -4647026.984185624, + "y": 2554724.7034213874, + "z": -3535597.5264087743 + }, + "direction": { + "x": 0.72760064521333, + "y": -0.40000183964449, + "z": 0.5573112499906759 + }, + "up": { + "x": -0.48837557123042497, + "y": 0.26848674230122765, + "z": 0.8303036617008445 + }, + "right": { + "x": -0.4817536741017157, + "y": -0.8763066800438634, + "z": -3.3306690738754696e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 1999.999999998896, + "frameIsIdentity": true + } + } + ], + "flights": [] + }, + "view rectangles": { + "records": [ + { + "step": 1, + "kind": "viewRectangle", + "value": { + "west": -180, + "south": -90, + "east": 180, + "north": 90 + } + }, + { + "step": 3, + "kind": "viewRectangle", + "value": { + "west": -97.77310906593979, + "south": 30.2525462275233, + "east": -97.71309093404976, + "north": 30.28184663602834 + } + }, + { + "step": 5, + "kind": "viewRectangle", + "value": { + "west": -99.42790849855417, + "south": 30.292803841160342, + "east": -96.05829150144581, + "north": 31.693665830637645 + } + }, + { + "step": 7, + "kind": "viewRectangle", + "value": { + "west": -97.74843343187867, + "south": 30.270077376377223, + "east": -97.72522294195059, + "north": 30.287912721763323 + } + }, + { + "step": 9, + "kind": "viewRectangle", + "value": { + "west": -180, + "south": -90, + "east": 180, + "north": 90 + } + }, + { + "step": 11, + "kind": "viewRectangle", + "value": null + }, + { + "step": 13, + "kind": "viewRectangle", + "value": { + "west": -180, + "south": 83.78827933471062, + "east": 180, + "north": 90 + } + }, + { + "step": 15, + "kind": "viewRectangle", + "value": { + "west": 168.24140167102513, + "south": 3.4321936523484062, + "east": -168.24140167102513, + "north": 16.15401098698419 + } + } + ], + "flights": [] + }, + "view and projection matrices": { + "records": [ + { + "step": 1, + "kind": "matrices", + "value": { + "view": [ + 0.9908821299915158, + 0.06814826350853613, + -0.11622572281662873, + 0, + -0.1347316015769006, + 0.5011956787437056, + -0.8547808415875731, + 0, + -1.804112415015879e-16, + 0.8626463387677522, + 0.5058075663832962, + 0, + 7.794660713039725e-11, + 62536.106446899474, + -21372627.636387765, + 1 + ], + "projection": [ + 1.7320508075688774, + 0, + 0, + 0, + 0, + 3.079201435678004, + 0, + 0, + 0, + 0, + -1.000000004, + -1, + 0, + 0, + -2.000000004, + 0 + ], + "near": 1, + "far": 500000000 + } + }, + { + "step": 3, + "kind": "matrices", + "value": { + "view": [ + 0.9113527171457554, + 0.1979051995330235, + -0.3609290192667292, + 0, + -0.22906381740146353, + -0.48469266675382583, + -0.8441580339915825, + 0, + -0.34200291301174285, + 0.8520014969428511, + -0.39639305833808874, + 0, + 518663.2069340985, + -5224404.956541276, + -3613311.623930813, + 1 + ], + "projection": [ + 1.7320508075688774, + 0, + 0, + 0, + 0, + 3.079201435678004, + 0, + 0, + 0, + 0, + -1.000000004, + -1, + 0, + 0, + -2.000000004, + 0 + ], + "near": 1, + "far": 500000000 + } + }, + { + "step": 5, + "kind": "matrices", + "value": { + "view": [ + -0.9928489311819897, + -0.06700477886630624, + -0.09879959240734885, + 0, + 0.1138477269203596, + -0.780430421521237, + -0.6147902506052382, + 0, + -0.03591232275762869, + -0.621641952230753, + 0.7824778516360003, + 0, + -776.5786582942674, + -2335652.2407809016, + -5956154.842607334, + 1 + ], + "projection": [ + 1.7320508075688774, + 0, + 0, + 0, + 0, + 3.079201435678004, + 0, + 0, + 0, + 0, + -1.000000004, + -1, + 0, + 0, + -2.000000004, + 0 + ], + "near": 1, + "far": 500000000 + } + } + ], + "flights": [] + }, + "setView keeps the position when only orientation is given": { + "records": [ + { + "step": 3, + "kind": "pose", + "value": { + "position": { + "x": -742920.5113997735, + "y": -5463800.994973143, + "z": 3196394.3325687605 + }, + "direction": { + "x": 0.8681548011485627, + "y": -0.06268709561301371, + "z": -0.49231856484019 + }, + "up": { + "x": 0.14674593985286488, + "y": -0.9152109275028256, + "z": 0.37530599158035977 + }, + "right": { + "x": -0.4741021729325852, + "y": -0.3980694489947987, + "z": -0.7853456840128291 + }, + "heading": 2, + "pitch": -0.30000000000000004, + "roll": 6.283185307179586, + "height": 799.999999999007, + "frameIsIdentity": true + } + } + ], + "flights": [] + }, + "lookAt sweep and release": { + "records": [ + { + "step": 2, + "kind": "pose", + "value": { + "position": { + "x": -743043.1457683308, + "y": -5464702.908131629, + "z": 3194792.6538335546 + }, + "direction": { + "x": 0.10786340837563654, + "y": 0.7932802890225545, + "z": 0.5992260576626178 + }, + "up": { + "x": -0.08073468645549653, + "y": -0.5937623923631535, + "z": 0.800579872228947 + }, + "right": { + "x": 0.9908821299915159, + "y": -0.13473160157690062, + "z": -1.5254822736301715e-16 + }, + "heading": 6.283185307179586, + "pitch": -0.400289630126315, + "roll": 6.283185307179586, + "height": 779.1037811773191, + "frameIsIdentity": false + } + }, + { + "step": 4, + "kind": "pose", + "value": { + "position": { + "x": -743939.048821212, + "y": -5464455.552141584, + "z": 3195005.808986324 + }, + "direction": { + "x": 0.5558149348159637, + "y": 0.6696022939993188, + "z": 0.49264848127886873 + }, + "up": { + "x": 0.10865618124688217, + "y": -0.6460526099661097, + "z": 0.7555195956643497 + }, + "right": { + "x": 0.8241744915443396, + "y": -0.36639977214352587, + "z": -0.43184211751611457 + }, + "heading": 0.5235148029189771, + "pitch": -0.40028931540080226, + "roll": 6.283184819462324, + "height": 779.1034471738486, + "frameIsIdentity": false + } + }, + { + "step": 6, + "kind": "pose", + "value": { + "position": { + "x": -744561.3757211583, + "y": -5464027.955054963, + "z": 3195588.159693578 + }, + "direction": { + "x": 0.8669783847892886, + "y": 0.4558037506896082, + "z": 0.20147312765091477 + }, + "up": { + "x": 0.24021397781477516, + "y": -0.7364451842535525, + "z": 0.6324126306867643 + }, + "right": { + "x": 0.4366299636655266, + "y": -0.4998914196573456, + "z": -0.7479724883860398 + }, + "heading": 1.0470518895126428, + "pitch": -0.4002887210284125, + "roll": 6.283184961711057, + "height": 779.1027791380377, + "frameIsIdentity": false + } + }, + { + "step": 8, + "kind": "pose", + "value": { + "position": { + "x": -744743.374477902, + "y": -5463534.691165816, + "z": 3196383.665553603 + }, + "direction": { + "x": 0.9579777631611771, + "y": 0.20917180611621072, + "z": -0.1962798023607882 + }, + "up": { + "x": 0.2786878978997977, + "y": -0.8407194979433913, + "z": 0.4642453891445818 + }, + "right": { + "x": -0.06790921036869467, + "y": -0.49943756497073244, + "z": -0.8636842350322286 + }, + "heading": 1.5706277892919154, + "pitch": -0.4002885372172571, + "roll": 3.8862982343346175e-7, + "height": 779.1024450503498, + "frameIsIdentity": false + } + }, + { + "step": 10, + "kind": "pose", + "value": { + "position": { + "x": -744436.2786715502, + "y": -5463107.930134895, + "z": 3197179.171413625 + }, + "direction": { + "x": 0.8044298599851474, + "y": -0.004208709344714679, + "z": -0.5940327323724914 + }, + "up": { + "x": 0.2137688856855085, + "y": -0.9309353328911241, + "z": 0.29607814760239926 + }, + "right": { + "x": -0.5542521663259894, + "y": -0.3651598180804452, + "z": -0.7479724883860396 + }, + "heading": 2.094248848550098, + "pitch": -0.4002890786917592, + "roll": 0.0000010185947569851805, + "height": 779.1027789155619, + "frameIsIdentity": false + } + }, + { + "step": 12, + "kind": "pose", + "value": { + "position": { + "x": -743722.3743754129, + "y": -5462862.022235794, + "z": 3197761.522120881 + }, + "direction": { + "x": 0.44747771191670926, + "y": -0.1271626588948764, + "z": -0.8852080860004453 + }, + "up": { + "x": 0.06285193806815277, + "y": -0.9829194289780064, + "z": 0.1729711826248142 + }, + "right": { + "x": -0.8920837019130344, + "y": -0.13303779282720718, + "z": -0.4318421175161143 + }, + "heading": 2.6179093131524076, + "pitch": -0.4002899348910174, + "roll": 8.763464425243228e-7, + "height": 779.103446782392, + "frameIsIdentity": false + } + }, + { + "step": 14, + "kind": "pose", + "value": { + "position": { + "x": -742792.9516691145, + "y": -5462862.858291493, + "z": 3197974.67727365 + }, + "direction": { + "x": -0.017233641232645276, + "y": -0.1267446310460917, + "z": -0.9917856623841941 + }, + "up": { + "x": -0.13362487071402948, + "y": -0.9827426896382966, + "z": 0.1279109060602167 + }, + "right": { + "x": -0.9908821299915159, + "y": 0.1347316015769004, + "z": -1.2010529177165146e-16 + }, + "heading": 3.141592653589793, + "pitch": -0.40029034545149567, + "roll": 6.283185307179586, + "height": 779.1037807281175, + "frameIsIdentity": false + } + }, + { + "step": 16, + "kind": "pose", + "value": { + "position": { + "x": -741897.0486162337, + "y": -5463110.214281538, + "z": 3197761.5221208814 + }, + "direction": { + "x": -0.46518516767297236, + "y": -0.003066636022856062, + "z": -0.8852080860004454 + }, + "up": { + "x": -0.3230157384164078, + "y": -0.9304524720353406, + "z": 0.1729711826248138 + }, + "right": { + "x": -0.8241744915443401, + "y": 0.3663997721435252, + "z": 0.43184211751611423 + }, + "heading": 3.6652759940271786, + "pitch": -0.4002899348910174, + "roll": 6.283184430833143, + "height": 779.1034467820881, + "frameIsIdentity": false + } + }, + { + "step": 18, + "kind": "pose", + "value": { + "position": { + "x": -741274.7217162868, + "y": -5463537.811368157, + "z": 3197179.1714136247 + }, + "direction": { + "x": -0.7763486176462973, + "y": 0.2107319072868545, + "z": -0.5940327323724914 + }, + "up": { + "x": -0.4545735349843011, + "y": -0.8400598977478978, + "z": 0.29607814760239926 + }, + "right": { + "x": -0.4366299636655271, + "y": 0.49989141965734557, + "z": 0.7479724883860396 + }, + "heading": 4.188936458629488, + "pitch": -0.40028907869175945, + "roll": 6.283184288584828, + "height": 779.1027789133159, + "frameIsIdentity": false + } + }, + { + "step": 20, + "kind": "pose", + "value": { + "position": { + "x": -741092.7229595436, + "y": -5464031.075257304, + "z": 3196383.665553603 + }, + "direction": { + "x": -0.8673479960181861, + "y": 0.4573638518602519, + "z": -0.19627980236078835 + }, + "up": { + "x": -0.49304745506932357, + "y": -0.7357855840580592, + "z": 0.4642453891445817 + }, + "right": { + "x": 0.06790921036869442, + "y": 0.4994375649707325, + "z": 0.8636842350322286 + }, + "heading": 4.712557517887671, + "pitch": -0.4002885372172571, + "roll": 6.283184918549763, + "height": 779.1024450525605, + "frameIsIdentity": false + } + }, + { + "step": 22, + "kind": "pose", + "value": { + "position": { + "x": -741399.8187658956, + "y": -5464457.836288226, + "z": 3195588.159693578 + }, + "direction": { + "x": -0.7138000928421564, + "y": 0.6707443673211778, + "z": 0.20147312765091507 + }, + "up": { + "x": -0.42812844285503454, + "y": -0.645569749110326, + "z": 0.6324126306867646 + }, + "right": { + "x": 0.5542521663259896, + "y": 0.365159818080445, + "z": 0.7479724883860396 + }, + "heading": 5.236133417666943, + "pitch": -0.4002887210284125, + "roll": 3.454685293391435e-7, + "height": 779.1027791374372, + "frameIsIdentity": false + } + }, + { + "step": 24, + "kind": "pose", + "value": { + "position": { + "x": -742113.723062032, + "y": -5464703.744187326, + "z": 3195005.8089863225 + }, + "direction": { + "x": -0.35684794477371834, + "y": 0.7936983168713397, + "z": 0.4926484812788687 + }, + "up": { + "x": -0.2772114952376788, + "y": -0.5935856530234439, + "z": 0.7555195956643495 + }, + "right": { + "x": 0.8920837019130344, + "y": 0.133037792827207, + "z": 0.4318421175161145 + }, + "heading": 5.759670504260609, + "pitch": -0.4002893154008027, + "roll": 4.877172621320369e-7, + "height": 779.1034471727313, + "frameIsIdentity": false + } + }, + { + "step": 26, + "kind": "pose", + "value": { + "position": { + "x": -742113.723062032, + "y": -5464703.744187326, + "z": 3195005.8089863225 + }, + "direction": { + "x": -0.3568479447737182, + "y": 0.7936983168713396, + "z": 0.4926484812788689 + }, + "up": { + "x": -0.27721149523767874, + "y": -0.5935856530234442, + "z": 0.7555195956643492 + }, + "right": { + "x": 0.8920837019130345, + "y": 0.13303779282720674, + "z": 0.43184211751611445 + }, + "heading": 5.759670504260609, + "pitch": -0.4002893154008027, + "roll": 4.877172621320369e-7, + "height": 779.1034471727313, + "frameIsIdentity": true + } + } + ], + "flights": [] + }, + "move, look, rotate and zoom": { + "records": [ + { + "step": 3, + "kind": "pose", + "value": { + "position": { + "x": -742861.1453857983, + "y": -5463545.417314854, + "z": 3196333.165219624 + }, + "direction": { + "x": 0.36092901926672927, + "y": 0.8441580339915824, + "z": 0.39639305833808874 + }, + "up": { + "x": 0.10593304234250588, + "y": -0.45940299877841756, + "z": 0.8818883575903802 + }, + "right": { + "x": 0.9265573018390272, + "y": -0.2763079773746384, + "z": -0.25523610255588547 + }, + "heading": 0.3000022223061638, + "pitch": -0.599987037510501, + "roll": 1.2940786220383416e-8, + "height": 543.5362668615833, + "frameIsIdentity": true + } + }, + { + "step": 6, + "kind": "pose", + "value": { + "position": { + "x": -742812.6988598595, + "y": -5463568.420773699, + "z": 3196338.041181648 + }, + "direction": { + "x": 0.3609290192667293, + "y": 0.8441580339915825, + "z": 0.3963930583380886 + }, + "up": { + "x": 0.1059330423425058, + "y": -0.45940299877841795, + "z": 0.88188835759038 + }, + "right": { + "x": 0.926557301839027, + "y": -0.2763079773746384, + "z": -0.2552361025558856 + }, + "heading": 0.3000122509964944, + "pitch": -0.5999852643816217, + "roll": 6.28317583242914, + "height": 560.0433317967465, + "frameIsIdentity": true + } + }, + { + "step": 8, + "kind": "pose", + "value": { + "position": { + "x": -742812.6988598595, + "y": -5463568.420773699, + "z": 3196338.041181648 + }, + "direction": { + "x": 0.266624496378452, + "y": 0.8675255294026185, + "z": 0.41989383630346794 + }, + "up": { + "x": 0.10593304234250578, + "y": -0.45940299877841795, + "z": 0.88188835759038 + }, + "right": { + "x": 0.9579611518589873, + "y": -0.19065240766406277, + "z": -0.2143877118234586 + }, + "heading": 0.1790386764969023, + "pitch": -0.5965724613208501, + "roll": 0.06818227538084543, + "height": 560.0433317967465, + "frameIsIdentity": true + } + }, + { + "step": 10, + "kind": "pose", + "value": { + "position": { + "x": -742812.6988598595, + "y": -5463568.420773699, + "z": 3196338.041181648 + }, + "direction": { + "x": 0.27158573064024977, + "y": 0.8434807681507424, + "z": 0.46344512584818004 + }, + "up": { + "x": 0.09247498278780061, + "y": -0.5021870699368215, + "z": 0.8598002816623556 + }, + "right": { + "x": 0.9579611518589873, + "y": -0.19065240766406283, + "z": -0.21438771182345862 + }, + "heading": 0.18302469055574022, + "pitch": -0.546684916077532, + "roll": 0.06602515864794256, + "height": 560.0433317967465, + "frameIsIdentity": true + } + }, + { + "step": 12, + "kind": "pose", + "value": { + "position": { + "x": -797410.332151974, + "y": -5455867.241442038, + "z": 3196338.041181648 + }, + "direction": { + "x": 0.28000681856896037, + "y": 0.8407227824214436, + "z": 0.46344512584818004 + }, + "up": { + "x": 0.08744857207525077, + "y": -0.5030866952080262, + "z": 0.8598002816623558 + }, + "right": { + "x": 0.9560067618991454, + "y": -0.20022232698231404, + "z": -0.21438771182345862 + }, + "heading": 0.18302469055574022, + "pitch": -0.546684916077532, + "roll": 0.06602515864794256, + "height": 560.0433317972171, + "frameIsIdentity": true + } + }, + { + "step": 14, + "kind": "pose", + "value": { + "position": { + "x": -797354.3307882602, + "y": -5455699.096885554, + "z": 3196430.730206818 + }, + "direction": { + "x": 0.28000681856896037, + "y": 0.8407227824214436, + "z": 0.46344512584818004 + }, + "up": { + "x": 0.0874485720752508, + "y": -0.503086695208026, + "z": 0.859800281662356 + }, + "right": { + "x": 0.9560067618991454, + "y": -0.20022232698231407, + "z": -0.2143877118234586 + }, + "heading": 0.18302752393315913, + "pitch": -0.5466580707844679, + "roll": 0.06602517665331131, + "height": 456.0739515034903, + "frameIsIdentity": true + } + }, + { + "step": 18, + "kind": "pose", + "value": { + "position": { + "x": -797438.3328338308, + "y": -5455951.31372028, + "z": 3196291.6966690635 + }, + "direction": { + "x": 0.5280904473124226, + "y": 0.8234772914284811, + "z": 0.2073779881254939 + }, + "up": { + "x": 0.14133419003404374, + "y": -0.3260326231621596, + "z": 0.9347338526882517 + }, + "right": { + "x": 0.8373440906728764, + "y": -0.46431441840159676, + "z": -0.28856020979961994 + }, + "heading": 0.5708170714247824, + "pitch": -0.7277421048485122, + "roll": 6.085332686616436, + "height": 612.0297448891034, + "frameIsIdentity": true + } + } + ], + "flights": [] + }, + "commands inside a lookAt frame": { + "records": [ + { + "step": 3, + "kind": "pose", + "value": { + "position": { + "x": -744481.3426810504, + "y": -5464789.733339174, + "z": 3195145.9335938715 + }, + "direction": { + "x": 0.6615694917883854, + "y": 0.6693543142368296, + "z": 0.33806894200355225 + }, + "up": { + "x": 0.22881921132251026, + "y": -0.6095195427272045, + "z": 0.7590307606173572 + }, + "right": { + "x": 0.7141201411979654, + "y": -0.4247949258714883, + "z": -0.5564006603961168 + }, + "heading": 0.6998711329857512, + "pitch": -0.5003442130772937, + "roll": 6.283184741449499, + "height": 1198.941902759257, + "frameIsIdentity": false + } + }, + { + "step": 5, + "kind": "pose", + "value": { + "position": { + "x": -744971.9139187913, + "y": -5464375.678277303, + "z": 3195735.7092693974 + }, + "direction": { + "x": 0.8577979868850266, + "y": 0.50373228948861, + "z": 0.10215867179324013 + }, + "up": { + "x": 0.33601932677210566, + "y": -0.6999992672201266, + "z": 0.6301523926217426 + }, + "right": { + "x": 0.38893910285752537, + "y": -0.5062161657018166, + "z": -0.7697217470297525 + }, + "heading": 1.099821436536688, + "pitch": -0.5003437292588502, + "roll": 6.283185070682873, + "height": 1198.9411841126202, + "frameIsIdentity": false + } + }, + { + "step": 7, + "kind": "pose", + "value": { + "position": { + "x": -745829.7119056764, + "y": -5464879.410566793, + "z": 3195633.5505976044 + }, + "direction": { + "x": 0.8577979868850265, + "y": 0.5037322894886102, + "z": 0.10215867179324012 + }, + "up": { + "x": 0.33601932677210533, + "y": -0.6999992672201268, + "z": 0.6301523926217425 + }, + "right": { + "x": 0.3889391028575255, + "y": -0.5062161657018166, + "z": -0.7697217470297526 + }, + "heading": 1.0997500524431754, + "pitch": -0.5004811848386681, + "roll": 6.283184976084162, + "height": 1678.7289113419563, + "frameIsIdentity": false + } + }, + { + "step": 9, + "kind": "pose", + "value": { + "position": { + "x": -745829.7119056764, + "y": -5464879.410566793, + "z": 3195633.5505976044 + }, + "direction": { + "x": 0.8577979868850266, + "y": 0.5037322894886103, + "z": 0.1021586717932402 + }, + "up": { + "x": 0.33601932677210533, + "y": -0.6999992672201268, + "z": 0.6301523926217425 + }, + "right": { + "x": 0.3889391028575255, + "y": -0.5062161657018165, + "z": -0.7697217470297525 + }, + "heading": 1.0997500524431754, + "pitch": -0.5004811848386681, + "roll": 6.283184976084162, + "height": 1678.7289113419563, + "frameIsIdentity": true + } + } + ], + "flights": [] + }, + "flyTo from orbit, default orientation and duration": { + "records": [ + { + "step": 3, + "kind": "flying", + "value": { + "flying": true, + "pose": { + "position": { + "x": -1495728.3550772713, + "y": -11000318.26996234, + "z": 6457154.663984799 + }, + "direction": { + "x": 0.11626523053581833, + "y": 0.8550714006879107, + "z": -0.5053071302624452 + }, + "up": { + "x": 0.06808083894848728, + "y": 0.5006998055343523, + "z": 0.8629395715262639 + }, + "right": { + "x": 0.9908821299915158, + "y": -0.13473160157690117, + "z": -1.942890293094024e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 6470135.446913586, + "frameIsIdentity": true + } + } + }, + { + "step": 5, + "kind": "flying", + "value": { + "flying": true, + "pose": { + "position": { + "x": -874274.9258019626, + "y": -6429845.637828038, + "z": 3765351.294742126 + }, + "direction": { + "x": 0.11633560127018917, + "y": 0.8555889415049588, + "z": -0.50441410671345 + }, + "up": { + "x": 0.06796052045548531, + "y": 0.4998149244579911, + "z": 0.8634618746351643 + }, + "right": { + "x": 0.9908821299915158, + "y": -0.13473160157690134, + "z": -2.0122792321330965e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 1129608.3358012843, + "frameIsIdentity": true + } + } + }, + { + "step": 7, + "kind": "pose", + "value": { + "position": { + "x": -742897.2382877291, + "y": -5463629.833118254, + "z": 3196293.5259137903 + }, + "direction": { + "x": 0.11636554151780582, + "y": 0.8558091367374329, + "z": -0.5040335129969017 + }, + "up": { + "x": 0.0679092424545046, + "y": 0.49943780094547585, + "z": 0.8636840960536455 + }, + "right": { + "x": 0.9908821299915158, + "y": -0.13473160157690134, + "z": -1.942890293094024e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 600.0000000013205, + "frameIsIdentity": true + } + } + ], + "flights": [ + { + "step": 1, + "outcome": "complete", + "frames": 182 + } + ] + }, + "flyTo with duration and orientation": { + "records": [ + { + "step": 4, + "kind": "flying", + "value": { + "flying": true, + "pose": { + "position": { + "x": -743130.6682365285, + "y": -5467133.7250257, + "z": 3198582.482610983 + }, + "direction": { + "x": 0.17356312231607446, + "y": 0.9816549804370923, + "z": 0.07892618041529287 + }, + "up": { + "x": 0.045628675693830216, + "y": -0.08807209610567275, + "z": 0.995068505100016 + }, + "right": { + "x": 0.9837651480543123, + "y": -0.16910589957362532, + "z": -0.060077684734619274 + }, + "heading": 0.06961743749999982, + "pitch": -0.9622905546874998, + "roll": 6.283185307179586, + "height": 4779.5447812491775, + "frameIsIdentity": true + } + } + }, + { + "step": 6, + "kind": "pose", + "value": { + "position": { + "x": -738610.7909727877, + "y": -5462883.227080723, + "z": 3199736.4715567394 + }, + "direction": { + "x": 0.9303152730880313, + "y": 0.3462649620085369, + "z": 0.12088866259649206 + }, + "up": { + "x": 0.2164423402660448, + "y": -0.7844348714175958, + "z": 0.5812182428694213 + }, + "right": { + "x": 0.2960847952855731, + "y": -0.5145507832947943, + "z": -0.8047181403518961 + }, + "heading": 1.2000000000000002, + "pitch": -0.3500000000000001, + "roll": 6.283185307179586, + "height": 1200.0000000007087, + "frameIsIdentity": true + } + } + ], + "flights": [ + { + "step": 2, + "outcome": "complete", + "frames": 121 + } + ] + }, + "flyTo with a height cap, pitch adjustment and named easings": { + "records": [ + { + "step": 4, + "kind": "flying", + "value": { + "flying": true, + "pose": { + "position": { + "x": 1984882.523799276, + "y": -5603747.12397889, + "z": 4188952.45647366 + }, + "direction": { + "x": -0.27187148869536876, + "y": 0.7701919208321957, + "z": -0.5769664623870064 + }, + "up": { + "x": 0.20699684818048317, + "y": 0.6323184154918451, + "z": 0.7465425147124768 + }, + "right": { + "x": 0.9398075326777873, + "y": 0.08353338562931166, + "z": -0.33133664905560134 + }, + "heading": 0.418333333333333, + "pitch": -1.5686260059206063, + "roll": 0, + "height": 901464.598149477, + "frameIsIdentity": true + } + } + }, + { + "step": 6, + "kind": "flying", + "value": { + "flying": true, + "pose": { + "position": { + "x": 4075000.9440966845, + "y": -3759235.4881838616, + "z": 4706246.105463546 + }, + "direction": { + "x": -0.558765941586562, + "y": 0.5154680462166173, + "z": -0.6496716985158782 + }, + "up": { + "x": -0.22804678090932132, + "y": 0.6576743833361776, + "z": 0.7179547835485706 + }, + "right": { + "x": 0.7973551832400532, + "y": 0.5493242201405304, + "z": -0.24993521746088385 + }, + "heading": 0.33499999999999996, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 903133.1780701573, + "frameIsIdentity": true + } + } + }, + { + "step": 8, + "kind": "pose", + "value": { + "position": { + "x": 4201951.609403296, + "y": 172440.74987437026, + "z": 4781836.313879029 + }, + "direction": { + "x": -0.9865515437306981, + "y": 0.12362053826970447, + "z": 0.10692994940841039 + }, + "up": { + "x": 0.12152411800090618, + "y": 0.11725870089489987, + "z": 0.9856380095189827 + }, + "right": { + "x": 0.10930663432142917, + "y": 0.9853772676403435, + "z": -0.13070462926374277 + }, + "heading": 0.20000000000000018, + "pitch": -0.6000000000000005, + "roll": 6.283185307179586, + "height": 2000.000000000638, + "frameIsIdentity": true + } + }, + { + "step": 11, + "kind": "flying", + "value": { + "flying": true, + "pose": { + "position": { + "x": 4231093.123870897, + "y": 176155.14556551861, + "z": 4819982.018419662 + }, + "direction": { + "x": -0.8555001712159346, + "y": -0.016160829519683742, + "z": -0.5175502725714116 + }, + "up": { + "x": -0.5177618038998317, + "y": 0.0392336788205274, + "z": 0.8546247321886958 + }, + "right": { + "x": 0.006493956567336286, + "y": 0.9990993674482251, + "z": -0.041931879193021285 + }, + "heading": 0.06381574074074159, + "pitch": -1.2610358932808605, + "roll": 6.283185307179586, + "height": 49985.22417669813, + "frameIsIdentity": true + } + } + }, + { + "step": 13, + "kind": "pose", + "value": { + "position": { + "x": 4197662.204280583, + "y": 175934.17309982784, + "z": 4783858.325902303 + }, + "direction": { + "x": -0.6567983013747672, + "y": -0.027528004975699405, + "z": -0.7535636670204306 + }, + "up": { + "x": -0.7529026636531263, + "y": -0.031555971183053186, + "z": 0.6573749308778986 + }, + "right": { + "x": -0.04187565372919943, + "y": 0.9991228300988584, + "z": -1.5959455978986623e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 799.9999999980523, + "frameIsIdentity": true + } + }, + { + "step": 16, + "kind": "flying", + "value": { + "flying": true, + "pose": { + "position": { + "x": 4202886.42370558, + "y": 173801.72952123257, + "z": 4784322.846250303 + }, + "direction": { + "x": -0.6572325177486237, + "y": -0.027178499908544478, + "z": -0.7531976810615699 + }, + "up": { + "x": -0.752554496975918, + "y": -0.03112034443654461, + "z": 0.6577942332093477 + }, + "right": { + "x": -0.04131763177056407, + "y": 0.9991460620474226, + "z": -1.3530843112619095e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 4524.029413470576, + "frameIsIdentity": true + } + } + }, + { + "step": 18, + "kind": "pose", + "value": { + "position": { + "x": 4206791.645386254, + "y": 168962.2063192315, + "z": 4777066.326463452 + }, + "direction": { + "x": -0.6581582273617292, + "y": -0.026434365087739404, + "z": -0.75241542521301 + }, + "up": { + "x": -0.7518092756704922, + "y": -0.030195779742946006, + "z": 0.6586888703337301 + }, + "right": { + "x": -0.04013179253255957, + "y": 0.9991943951144461, + "z": -1.491862189340054e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 1499.9999999976308, + "frameIsIdentity": true + } + } + ], + "flights": [ + { + "step": 2, + "outcome": "complete", + "frames": 182 + }, + { + "step": 9, + "outcome": "complete", + "frames": 62 + }, + { + "step": 14, + "outcome": "complete", + "frames": 61 + } + ] + }, + "flyTo with a duration of 0 completes inside the call": { + "records": [ + { + "step": 2, + "kind": "flying", + "value": { + "flying": false, + "pose": { + "position": { + "x": -742932.1479557978, + "y": -5463886.575900588, + "z": 3196444.7358962456 + }, + "direction": { + "x": 0.11636553215672032, + "y": 0.8558090678914342, + "z": -0.5040336320531377 + }, + "up": { + "x": 0.06790925849514162, + "y": 0.49943791891617306, + "z": 0.8636840265741415 + }, + "right": { + "x": 0.9908821299915159, + "y": -0.1347316015769006, + "z": -1.9428902930940242e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 900.0000000004128, + "frameIsIdentity": true + } + } + }, + { + "step": 4, + "kind": "pose", + "value": { + "position": { + "x": -742932.1479557978, + "y": -5463886.575900588, + "z": 3196444.7358962456 + }, + "direction": { + "x": 0.11636553215672032, + "y": 0.8558090678914342, + "z": -0.5040336320531377 + }, + "up": { + "x": 0.06790925849514162, + "y": 0.49943791891617306, + "z": 0.8636840265741415 + }, + "right": { + "x": 0.9908821299915159, + "y": -0.1347316015769006, + "z": -1.9428902930940242e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 900.0000000004128, + "frameIsIdentity": true + } + } + ], + "flights": [ + { + "step": 1, + "outcome": "complete", + "frames": 0 + } + ] + }, + "flyTo where the camera already is completes inside the call": { + "records": [ + { + "step": 2, + "kind": "flying", + "value": { + "flying": false, + "pose": { + "position": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": 0.36092901926672916, + "y": 0.8441580339915824, + "z": 0.39639305833808886 + }, + "up": { + "x": 0.1059330423425059, + "y": -0.4594029987784171, + "z": 0.8818883575903804 + }, + "right": { + "x": 0.9265573018390272, + "y": -0.27630797737463836, + "z": -0.2552361025558853 + }, + "heading": 0.2999999999999998, + "pitch": -0.6000000000000001, + "roll": 6.283185307179586, + "height": 600.0000000002616, + "frameIsIdentity": true + } + } + } + ], + "flights": [ + { + "step": 1, + "outcome": "complete", + "frames": 0 + } + ] + }, + "rectangle destinations": { + "records": [ + { + "step": 2, + "kind": "pose", + "value": { + "position": { + "x": -751947.4785351472, + "y": -5525205.585116517, + "z": 3223894.5692276806 + }, + "direction": { + "x": 0.11654615446887955, + "y": 0.8563649483202656, + "z": -0.5030467862604284 + }, + "up": { + "x": 0.06783632709842566, + "y": 0.4984519052955266, + "z": 0.8642591803579843 + }, + "right": { + "x": 0.9908658973868824, + "y": -0.1348509302737231, + "z": -1.5959455978986625e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 68266.53028034384, + "frameIsIdentity": true + } + }, + { + "step": 4, + "kind": "pose", + "value": { + "position": { + "x": 12974702.828049012, + "y": 3476561.144809536, + "z": 7753327.086205596 + }, + "direction": { + "x": -0.8351566399388897, + "y": -0.22377954722511528, + "z": -0.5024301951611848 + }, + "up": { + "x": -0.48531030141364523, + "y": -0.1300385033422907, + "z": 0.8646177762400528 + }, + "right": { + "x": -0.2588190451025206, + "y": 0.9659258262890683, + "z": -1.9428902930940242e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 9136675.502083447, + "frameIsIdentity": true + } + }, + { + "step": 6, + "kind": "pose", + "value": { + "position": { + "x": 14926443.071414784, + "y": -6960314.7132153325, + "z": 6004715.867808007 + }, + "direction": { + "x": -0.850804588245522, + "y": 0.39673669509229725, + "z": -0.34457444389799047 + }, + "up": { + "x": -0.31229050171857203, + "y": 0.14562345252043715, + "z": 0.9387589960220838 + }, + "right": { + "x": 0.42261826174069955, + "y": 0.90630778703665, + "z": -1.942890293094024e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 11154385.120979086, + "frameIsIdentity": true + } + }, + { + "step": 8, + "kind": "pose", + "value": { + "position": { + "x": -7481674.14122793, + "y": 9.162408289318306e-10, + "z": -6252288.005905731 + }, + "direction": { + "x": 0.7652100415869902, + "y": -9.371120281049209e-17, + "z": 0.6437807019897667 + }, + "up": { + "x": -0.6437807019897667, + "y": -1.432042073205708e-16, + "z": 0.7652100415869902 + }, + "right": { + "x": 2.0483351716941613e-17, + "y": -1, + "z": -1.6991076136884995e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 3380878.2052681325, + "frameIsIdentity": true + } + }, + { + "step": 10, + "kind": "pose", + "value": { + "position": { + "x": 19628816.78887241, + "y": 0, + "z": 0 + }, + "direction": { + "x": -1, + "y": 0, + "z": 2.220446049250313e-16 + }, + "up": { + "x": 2.220446049250313e-16, + "y": 2.220446049250313e-16, + "z": 1 + }, + "right": { + "x": -4.930380657631324e-32, + "y": 1, + "z": -2.220446049250313e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 13250679.78887241, + "frameIsIdentity": true + } + }, + { + "step": 12, + "kind": "pose", + "value": { + "position": { + "x": 4343553.675273656, + "y": 189643.64903618084, + "z": 4882136.422023611 + }, + "direction": { + "x": -0.9397106512905629, + "y": 0.2572709558733447, + "z": -0.22528991791707398 + }, + "up": { + "x": -0.11933020821145057, + "y": 0.37069463412744436, + "z": 0.9210568872970508 + }, + "right": { + "x": 0.32047494950351146, + "y": 0.892410860250555, + "z": -0.3176454993346925 + }, + "heading": 0.5, + "pitch": -0.9000000000000004, + "roll": 6.283185307179586, + "height": 171221.26219356808, + "frameIsIdentity": true + } + }, + { + "step": 15, + "kind": "pose", + "value": { + "position": { + "x": 12974702.828049012, + "y": 3476561.144809536, + "z": 7753327.086205596 + }, + "direction": { + "x": -0.8351566399388897, + "y": -0.22377954722511528, + "z": -0.5024301951611848 + }, + "up": { + "x": -0.48531030141364523, + "y": -0.1300385033422907, + "z": 0.8646177762400528 + }, + "right": { + "x": -0.2588190451025206, + "y": 0.9659258262890683, + "z": -1.9428902930940242e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 9136675.502083447, + "frameIsIdentity": true + } + } + ], + "flights": [ + { + "step": 13, + "outcome": "complete", + "frames": 62 + } + ] + }, + "flyToBounds default offset and explicit offset": { + "records": [ + { + "step": 3, + "kind": "pose", + "value": { + "position": { + "x": -743028.0319117856, + "y": -5464591.753434517, + "z": 3195599.567576459 + }, + "direction": { + "x": 0.13030193990081027, + "y": 0.9583042303352324, + "z": 0.2543116328044856 + }, + "up": { + "x": -0.03426381358742819, + "y": -0.25199285239492364, + "z": 0.9671223259858683 + }, + "right": { + "x": 0.99088212999152, + "y": -0.13473160157686984, + "z": 5.315192730392937e-14 + }, + "heading": 6.283185307179525, + "pitch": -0.785569032641992, + "roll": 6.283185307179586, + "height": 1088.7553895953624, + "frameIsIdentity": true + } + }, + { + "step": 6, + "kind": "pose", + "value": { + "position": { + "x": -745425.4168415141, + "y": -5464323.531851885, + "z": 3195100.5370105966 + }, + "direction": { + "x": 0.8659992966445589, + "y": 0.4023947661006701, + "z": 0.29685631276076374 + }, + "up": { + "x": 0.1460791380408637, + "y": -0.7713444408323176, + "z": 0.619426056140939 + }, + "right": { + "x": 0.47823226955148856, + "y": -0.49305801465128474, + "z": -0.7267652238156184 + }, + "heading": 0.9997798452485549, + "pitch": -0.30044934326360306, + "roll": 6.283184591910284, + "height": 887.2048464748486, + "frameIsIdentity": true + } + } + ], + "flights": [ + { + "step": 1, + "outcome": "complete", + "frames": 182 + }, + { + "step": 4, + "outcome": "complete", + "frames": 61 + } + ] + }, + "a cancelled flight stops where it was": { + "records": [ + { + "step": 5, + "kind": "flying", + "value": { + "flying": false, + "pose": { + "position": { + "x": -1773311.06638954, + "y": -13041797.366289483, + "z": 7659494.280044535 + }, + "direction": { + "x": 0.11624972857822315, + "y": 0.8549573917057475, + "z": -0.5055035696938199 + }, + "up": { + "x": 0.06810730554768926, + "y": 0.5008944538565265, + "z": 0.862824513459606 + }, + "right": { + "x": 0.9908821299915159, + "y": -0.13473160157690126, + "z": -2.636779683484747e-16 + }, + "heading": 6.283185307179586, + "pitch": -1.5707963267948966, + "roll": 0, + "height": 8855572.433024691, + "frameIsIdentity": true + } + } + } + ], + "flights": [ + { + "step": 1, + "outcome": "cancelled", + "frames": 31 + } + ] + }, + "changed fires by each listener’s threshold": { + "records": [ + { + "step": 53, + "kind": "changed", + "value": [ + { + "threshold": 0.5, + "count": 6 + }, + { + "threshold": 0.1, + "count": 28 + } + ] + } + ], + "flights": [] + }, + "projection, picks and the horizon": { + "records": [ + { + "step": 2, + "kind": "project", + "value": [ + { + "x": 639.9998400284944, + "y": 1980.307337277763 + }, + { + "x": -1748.6843471897712, + "y": 1395.6087575296795 + }, + null, + { + "x": 321.114707167823, + "y": 154.60524754964482 + }, + null + ] + }, + { + "step": 3, + "kind": "pickEllipsoid", + "value": [ + { + "x": -742513.6695927457, + "y": -5462732.724329275, + "z": 3196714.783169287 + }, + { + "x": -743158.3181329333, + "y": -5461939.22456592, + "z": 3197912.564935943 + }, + { + "x": -741078.6232824821, + "y": -5462559.4110169215, + "z": 3197339.6752386307 + }, + { + "x": -743042.0104366634, + "y": -5462796.394165715, + "z": 3196484.7582323574 + }, + { + "x": -742276.2388011032, + "y": -5463024.754391748, + "z": 3196273.8132951404 + }, + { + "x": -742831.7488226165, + "y": -5462772.267162364, + "z": 3196574.256440562 + } + ] + }, + { + "step": 4, + "kind": "pickRay", + "value": [ + { + "origin": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": 0.36092901932484533, + "y": 0.844158033937051, + "z": 0.3963930584013019 + } + }, + { + "origin": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": -0.11084611858274519, + "y": 0.7177781031673482, + "z": 0.687391978865497 + } + }, + { + "origin": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": 0.7721265691989996, + "y": 0.45446746139954164, + "z": 0.44416200610367235 + } + }, + { + "origin": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": -0.16692946595145694, + "y": 0.9609964366113153, + "z": 0.22050034516416442 + } + }, + { + "origin": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": 0.7160432216003363, + "y": 0.697685795209533, + "z": -0.02272962742882348 + } + }, + { + "origin": { + "x": -742897.238287725, + "y": -5463629.833118253, + "z": 3196293.5259137903 + }, + "direction": { + "x": 0.07238646382351538, + "y": 0.947880196294632, + "z": 0.3102955580210207 + } + } + ] + }, + { + "step": 6, + "kind": "occluded", + "value": [ + false, + true, + true, + true, + false, + false + ] + } + ], + "flights": [] + } + } +} diff --git a/src/render/conformance/goldens/visual/city-city.png b/src/render/conformance/goldens/visual/city-city.png new file mode 100644 index 0000000000000000000000000000000000000000..9505968f1f195e1713b98ee561fdb20e1e6ab60d GIT binary patch literal 25373 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 literal 0 HcmV?d00001 diff --git a/src/render/conformance/goldens/visual/city-street.png b/src/render/conformance/goldens/visual/city-street.png new file mode 100644 index 0000000000000000000000000000000000000000..93697140b979100fce48ff27fa8b0d56a71126d7 GIT binary patch literal 9462 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

YYD9!gP1(qw<+nFFqKEs_yw-OH|EY@g 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

%_i-{m{zy#=VkO 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 ZB>h$uv;4T12)=>bP}5O;cOCiQe*xGs^$q|4 literal 0 HcmV?d00001 diff --git a/src/render/conformance/goldens/visual/lines-top.png b/src/render/conformance/goldens/visual/lines-top.png new file mode 100644 index 0000000000000000000000000000000000000000..d6b72e7db498a12bff4b8d9307abb296e007897b GIT binary patch literal 10564 zcmbWdby$?&6F0oOF0k|xf}p_C9RiY~OG$?yAs{T$k`^7iAfj{#h_rx(G)R}AfG8j( zA)$h_fOH7Y* zK%EgV@QbG51Stf9glMTLU-N}7XS%<2G&AYQ_FJHmGXCVMffkb_f5`0ms>syC6M5}Y z&Q%8Wj^M9r(CENC<~K%Mk8@01o^pPLGc=uvE=xnOUP!Xh4Zg#h*V4bITPw7R^<2hpU%*GZpJ?_8z87AkYvP z5(|NIF^nipU;gi}=L~D2Enja-al%m$C<5JrK(&0tF%dpQA(J7nbEkcblIW-iZ#Oj| z&_8sSj8*ZBur@G6>D1UkPsTDQpxo32?`k2sw15c&c8MI-a%o9_xQRSo366?~K;d#Y zE+mY=WtSTZIVhB8QEichmPequ;K*q;3gt6+(NNP-5ZH3zq&+1RvCD_vCZ|JV8H7l4 z36f(uz=GKpNrHGTQ*iojcBuYa)PqyR1%cd`7IjkyT;k8*I$;^++b8dk^N%60?+m^# z!odo7QsnfbEhizO23j0oh&2cLLynUzCx9)}y<2_V$xirT5qzvb9Z38Z%>zSt@$~8b zN{5W%b>K{o#-^*uJtVM2sG+bB4eVz|egqRAA%$3E~BTfCytEwZ@lquebO zh}&#Lnfstnc+II-ROG~!o~q}#L9>#Tz7f8Lz?6Uz17N+CL+E< zuWuu~97(vXh=gH)@CsWOj3B8bcV9WgDMjgr3Q;5efX@y*%jHBPKmi~2r5N$rL!dF% zz@!cN^Cy~j1}6Q^r8}jX09+Bg7g2#gtsgK2ov23?sK*l4B|&sm2u>YnA&NflGLcsf z3D^uJk_84MzAvQ$hGDt&o#=H)bHKZS3+?frRYi0F@04>pCw4OhYAS)mS>l(%`*R@@ zjWx^dXe5{dg5w2C>4g50A?$Ze4>Pn(H*G?G=VWONIoJk$UkwS-%B4cM1`^+)<=PWZ z?PCC&=3mpL#5s_^Ey+AzzxCVwu1fuK{CBYLO*A--P=6g`NnISAry$X!f}uKt!h8Q+ zL}++Y`q}YTS*Oal%xdTbUtFgqIcyX-a)l@xj#(M~+nozdf~O43^4 z2`*n$S?R7cRuIy=kmTuoIN5ez>bi-mPd7PJ-Du44nDedsudctXm?$)@6E?m`@%epH zo|NQ`55Rf<09TqQbFg29^VCVcem0{w+`=jPM2nF(Msh&^wO!u$NZYJFej_Xn&#o51 z`n0Z}F{~3#B2Dk2P4xow-oO?sf9<6aR6KR%S0|Fkx_AQUFQ-)+nld+cE=yOz3@=K4 z$TR)1kpG!0VPE@U@*|5q??ezNoq>aYXVUt(u8)2$oMh!vAxtTkf2&_cFk?`kX-4#( zX8TnPg+cDK1w+R&t)PNdZM~vE#ZjB?&q#p>z{52;werMa^f%JlwVn%8+78~A6}VpW ztR@eu_i*S_>h%^Q8<)~V6X7ZSe&G4tz*kd$eY}BuWqm;>5(tV_trM>J=h0W>bwN=> zP&4eyOvG+MQ$MDl#57ngZa^N&C-4X?Vt8JQmkC!oOU8xqmF=g_9Fer&KPOl|t<)~sbQULxJt z%-nD^Go0cq161s7PX54YTv@;H*W~*b9L9cWgI!9>GrcCTwR3U?u}~0%H$kr&I4%;> zZ+Z3U8t+yzY4?md^Xn36bH0wAKCm}CauIu)CG)slAZ ztTy1rWUiPG0#Gyr=624R(~vG+^Fj0A+2DrSx|VODaN;`kCK%o}dOO00NX&3c2ApD* zZ$T+&T$P@@+jy~?tnuN^WK)7@U*+&BG3@RD(FOkW0tC0{V=+|lEx4aT%$+WNUps6| zDTZ2LFwypL8C$>CcTx*iX5hkC!u~%|A$HzYBq>_3?s(ty34BNeWf!yE-MUeg1r89ciJt&}e(H z6&8*61tTFXb_T@!)nc-`8yEe*(J9S;d8rqJ)y{)mzb2GM;dDt9zR%US)qwE&_JF0? z0OtQ_>1r`zx#CLI-!gmhSLl9BRit6~z>>548Mrnt7Lsal{V(hMdQn6TdLGBlzNFA@ z#Ahfzyg$Zr`uwc(V`^9-QqvM^))gJV#?Tk0c$k@D$4{#h#Mag+c|TcK`)!|zwo9qS z-WfOr*dnv3VL!{fZIQ-1T>SoGlKO_r1{aFjbdRy(^tGZuDi|*~g#$%=38Knqj{WCN zI$k;(OhFs=tZ&z@nVvSIA;sMU?)l+KqS~9rlAw8ECV#J`by2eJXV}PBMivx8Vh0#i zWlK_^qiBVc)DQ1j7QVjKOPqL7o$BwEZji1BO_p>n)qblomqoX(O3cS(0$|l1Zk9Cu z=HtDqBb@F~I4i3a`bs_o3Pd4ktuH!mr>?aeIpdxga1b;%nH8Y0v}cyTMgQupQ1QR( zMV~S+b9ILgO998byA@HJz1wXt<6fP?+LY38w7umMqOfXpr$jw(`*)ysEPD` zqY|U(Zdo9i&p&a8<}DlA8E4PB#fl}fplu`4Q?ET^1(6$f;MY6~>zS}At#`sUgHsD6 z`tQ7_10+|=bhJ&fVII~;WpitCVuH$d()3Y`H@eYLw*F4VDkwc5&a?ZRZ(QJ;ElRh$ z1($Nk*4M)G*#`DeInOy1{YLH#-Rw)0$d>Vh^*quOo_Hh{2!ktV_l%UGsbryZMtEIGV0fbe^B)i}{_TJ2 zbwtUz&+<^wQv;ZwGts1qHdLbpaS$gn$!}2C1J}K!Je#}DoG5A;L_Y@nC!Pejf+^qk zO7fg0mv1k7=iM0IB1YhpuoOy=Hm7R)#Tk2MIXurv?Ue-H4uiUf>V;L2UkN+Kl+g~P zd(13g?fba5eOq=Nw#A+$J#sUiG+mVvM|}5aO$heNQyo`w9S+!9yM>ZKb+SPu2##sc zcqQ8_d9^RyDJ_F0no|drqgU!uD%0xhMSfaTi40z|NimnrF+O$ZdPe+|OQ{|owM+yP ztP8}MBvERd3EXfhQstYCLyTuSX11Nr99jAV2qS3L5#Z*(A;Lh?=$F#fijqje%Cx>{ z9fRk!O5OgNs{srWx0dEl3Vqis1-b19gSwR7NFYs@Dww4Q2Ogw!>REnHNJId0@WK$4 zkrlf}i%e@BJyRk$by~pHlZ41xtvE3O%z*n1`6x2n9>STyjn$3Pr78W~HrFh^>N zu>Zr*dpR|uarP@`2AOrDl^gj_uKe3@8QP3$lY2i!tVFMTa2n;nP2e zkgAwoOIDm)NGfS(j2%#oU71(8CosX(X-&*g(VX{(>hn_82bHZOb;o1p%^1iNB_w&h zj&Ka^82RUBf*AnX_m5c3$jJRXpG*dGN z*+9jFhBuhqzj_ep_KjovavK{(ZezI)19nr(x&#|q__D0=t((D$l_-oLNHC0F!Ol~8 zOpGv*xQ960kw8@HPwzIit*#8)+<&pv%7TRHfR9qKA4NqO`XEy0kte5S=Y4#Rn2}{ddyVuz_xLTKVk13KZrB!-)S(yq;}gPoM>26BGx3NEDN- zIa?0qghkfs_**RJ|F9UO;dB&1R9E!!>SKmBo_Vr$L2;vw3nLs;-B%o$$`hbCseCd~Y?17wipHrPH9PVe`sp&b+~`?oV_=Gl|Kw##7*i6&GuN)) zu~-XSrCRoKQ}WQs%RaGB$@!3gA6z0M7UT)Bk@dY@e7%jXQ^nSwx%#_X4%qAKQXx+V zrEqaaPFt(o3!?XIkZ%75{6P^IP<$k@qa;3gcFN;0;qLa)UY^bMsLP5{D$}o34_0Wg zDL2KX-Ms(5BeO%c^P$?TIXG4CxbF|M+S5!yR3B>X+xQTTdDPk$vZjF=#yLY{yOZl9 z`RGWNJW^k@IOmyW+51fOrBo@1x_X|Uq?k$o|p zWM+KQ&uYo*u3deq^Mwn)GXOde2)(v2CBIv=>&OA|RJtR8=Y~V)?4Oc2^Puf%te)Od z8u+Sq|KD8GUYGGdGG({}14 zgYb^tj>;J3OCFzBkNcaRZb&l2uaG;_jolh7Ki4?6z>=OiEQRwR8IihvCtD5k&&27T z%hD!i)fs#}TMa^9bpiiv1Ud)kz_s8+9Cv;v5LHekZO zrsp~oN0yQ$w#Tamj@+v=>?-8FHEV;ylCFuN05y;RfWk!vIyN8`zWiNbKx4<9K0W#m zJgEhT?XK-}V@vq1=sEWU`wS-XHJoOY1k0^pu+HJzc>s1i(QRo`w7Q`BC!*y$I0&^@ zztWumBJAKsRZ5A51J-zu$p+Lw4M;JC5^b!FOr?i0jFA)>1KAm=m4 zYpaSyjBFI=wX)#RFEj_7DT@JWeTGGr06szBru?9m?VnYUgg0Xs5U|lYjK1 zVSi9G!&7eS(*5SIqUVMW2LJWt{ul(0E7e}+tHwL35f6>Q~;(balS)_;Qw zvN!y(zB^rM9L{gD>chatT=#ISXpiZ`n@GfGRR}aIfW* zA`}ndU=3h}!5mgAQgql^NfJMH=>HFXzMz|3cCPq zYU+=nViQ0_7-B^Hih2;GfP=Rns%11;5pmj_;Fi1!66%4`7K( z>`-HyFU%Yb~rIGO`H+pi8ci|ktU3{BJ z0ewOEdoHT(@`=x4O}i1j{{|y^kLK%p{xtoDmE?_wryqmV_~?R_{3#`7E=z>`Nz7bh zFpt?NI!E(u&xEYk=D7H!?9udV=7vAD8-Y9*^WPtXfWZV`_1@kqdFCWk#!!4^vZk8qg zq`nA8T@)Ws&QWf;bZXLn#_(65aD>GV#pT7+%RU=qJ(IhW6$iyiy#|ge=wF*4)v?(1 zA7d`L9_41hamY!DA_@$(jA%YyG>+LwqGjG~yBW6X?6caqwr>;gTQuZwuu67Ct{0~R z!eMv0Y~nCw*{-+X!??hIohaf(hYXe+rLpuB7yWysVrji(b+7-%LG#{J%*HtdkjN>D z_)E`vh6@zU=zSzvII@t>fQEVlQ8iQfWarlRO)>T`Ho!3;q3@-|uhXa&|orE9Tt=f)Vk*fuA{%pHFxjB8p5qJIk`o&u{*s0KMv6V zK{vKVa=8KSOR1F3s5Hh6D!B6Pk9Ir=5fJ07v)FvzsemN02xA|w4)Q_2t2Z}2GeI7x zU_@#a4H7)&U$Z4X^(-4lwKuY&-%$YE`LzG{aT@5PN_AVpRY$tMZl}?=)>aY!btR<2 z0ny8rA4bgZzkbKx)wiS50Fhj>wr?zVL-=IF=8~UM9%@G)tW^tO*CqkL5v;lT0NWTo z+;?c9t=v+F!dICwpSR7Be@pxG)UY}wUVM9O%|5%rexqikwWTP4K8^&u-Y4*+URH_r7_V zl9tr($MjD}FR!mox((ZWPNO?D$OwCvGN`|hG+l5#H| zyzIJPuB+WWM=NPlO-CIe>fYOHp7zWap*#B1Q{p8YqWP0y*SR1;A*F*HJ|3SyCo99a z%IX7a@VRGGG%IiZxbp`5OfigzkTcJ91H!$*beG} zakwx-rZp5BLyr#9Nb}-5X+}UbGK}Nlk58paLt}V>&d(-y;6Zvawhm^L{8I3oQxvai8rsX)U

|9|4@J3IIc zNKVh*9px31Q-s${|4d1HCOty08Ws~aXS!JEfP(FRPn}W_q%pS!ZrQM?UU?d~_G+lj zEN*)7+N6zIGZoZ5unMx=^NXJrkHSQQJeK8jlwkyXahy#lbPL69JsskKcs0Kj7hJ{j zJzxL%EQmYZuL}xwH(RP`!;oC+Dw27D3xk8;;M3nTKWi2Qwvw$CG6<+@Q?}KLzr}06D zS)gIuw9k|;-@q<;fIO@u=;ww-y-*`l%N_1~uT-NmRHP>)HY$Jo;i>VY@*3D ziDcxUdEdJtrhe)wS|Ps=l~Yc$bWpPDJ08(AUi0i%?$NyMNrfoCu#p

d-J6~C6 z5P~~`6wrxFs8aQ-%YrSNv~SP~6>G+gDig_|jr>xRPgRN~#=;z7Xj|YQ?=MVkDpEEY_%1=c69xkNQ0TF#Ce5I1!;_-EgUF zCRhQGP^U=5?*?a%MlJii_ge#|tsL1Y*Yul{LXp~b39rqC((tnJ?`SxovaEuZ0>YPxddgwkYnFTE<8EH-UIwD(!du);msx$Z7%j#OpLf|}&FFgYU zUX?Uy^;c_8JZzgsYCgmtK|w@*0IYv{&Bp@YNFhh>3`n;5!a_On&CX64K3i{;r^Nll z@(lV_c&BtdMm6htBFOXkO=v!d1A$Yz~LiLMz;EYMwZEJi#hA$bkXL#xVmJO35-ZpiY*=_~Vp0!a`KEV)XtO@-a zUh<(<3@U-h(EC1tVWFMPZ0Y(1#!4g- z3O7eeAg;k#Ep6RH@m;YYhbAT-)PlpWdX!Y%0ob5Mp)A9!a!%7}({^HGji$^G5v?tL zcFM2jYsXoDa$A1r@{xg#Q!Itq$`4x-I!pCc1b7}j=$jFNJ$D#OEI+Xew)UHOqPMoj=(p=5@SM zET+PjOTIIfE)<7)F8hT2VQ=2?vew#S5Uo(bNP=WX@@z30LkHUsD7&UN_OA2Q`o54v zIR8L^<2_k+AICccq%+WH^=NfSVxiU>^~2hgFsrdKqh=VYIooqv^2^of8r>z5g_ zq+8EkTTV!;$W_whQt#lK;8%=0@0m?7#_8e&FQ&=Vm-~}}5f#jBSj8#`O;XRv;1)cn ziR1>Y6?-GaD*DTrPcAr;e-hGorH#q86Q?V*K#7_pM%zjlra-?2;nYZYNl_d>e}am?2ojk+dO8## z{dxl6E3vXSGc&}&oa^99tq@>PCZZoMfUsXk`w|gail+vI=|*${L_$z%ih*eX7;!#> zn3plP5(x$ID}qKPep(}tp;IgXE?1vOCqX1c1H#-pxXuQ4=xyWIEeB`G$wq}9 zKMV%Jodb08rZG>0@Do$$90VYwbKoH-62<^R%MXv3iF+ra6Wa7R0$~P_UEUzY5DCKpp-#ag20SR^3H#s>2VpidqQ`@hU`0IPwZ~@&lIF<4D9OEUk0v3B z>dxT@d<>@3h~sgAz&+=RFS8Lo1qdeId+oY3PqbGHM8BK&cOro>9f!`j0Z8jFvQVEm z0W&a^nC`v5gi$fz1)w7I$QI;2u?#W8cz%j#HgwKeKrCy;a{7cAsO;itT4v(r*kKc3 z<$AmB&XbiN0~dMhT!Z&aJoveq0&+)Dn=ANJ1AdDJ-SW5(@3l^>nsWfG`iDh|SjM0+ zU?c_Kpg3Xmhdf`TWx}bK>g4Y%pdBTglf?-!ei-4Xo#<@pKpRnF;$6hU`Uc)a3xOOT lzq}Z;0D(YA@yX692v^kR64Uv)4e$#@OI=^BSOpXMe*hy^p%4H7 literal 0 HcmV?d00001 diff --git a/src/render/conformance/goldens/visual/manifest.json b/src/render/conformance/goldens/visual/manifest.json new file mode 100644 index 000000000..0adb87527 --- /dev/null +++ b/src/render/conformance/goldens/visual/manifest.json @@ -0,0 +1,20 @@ +{ + "renderer": "cesium", + "size": { + "width": 640, + "height": 360 + }, + "views": [ + "icons-top.png", + "icons-tilted.png", + "points-top.png", + "lines-top.png", + "lines-tilted.png", + "shapes-top.png", + "shapes-tilted.png", + "models-oblique.png", + "city-orbit.png", + "city-city.png", + "city-street.png" + ] +} diff --git a/src/render/conformance/goldens/visual/models-oblique.png b/src/render/conformance/goldens/visual/models-oblique.png new file mode 100644 index 0000000000000000000000000000000000000000..cc316838c62b274c6c37644132b6ecf7e5dca474 GIT binary patch literal 12776 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 NlvR;IJTQIve*lF_fnWdt literal 0 HcmV?d00001 diff --git a/src/render/conformance/goldens/visual/points-top.png b/src/render/conformance/goldens/visual/points-top.png new file mode 100644 index 0000000000000000000000000000000000000000..65bf97ff2b73f5d152e6fd78172dcdb6b0ec78cb GIT binary patch literal 17195 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 KEfCiG^8Wz2up?do literal 0 HcmV?d00001 diff --git a/src/render/conformance/goldens/visual/shapes-tilted.png b/src/render/conformance/goldens/visual/shapes-tilted.png new file mode 100644 index 0000000000000000000000000000000000000000..819e40fd02d186395bd44e1f4106892261c8e577 GIT binary patch literal 15011 zcmZvDcRbZ!{6E)C=wn1kcD9=lWh5g~AtPLu%*x0f*;}Fyg_4;aGA^!!Ymds_^Wxfj z?>&F#(&zX1eIMW7A3YwqulG5x^?bd~>zwmCL24?GNl9*y;NjtsDm;0pj)w>F#lypY zO#}hIJW(4V#KWV&Q+W75(-nU?fjHk7;Wk$NW~}wgBX?PtASET`$EeTO6keO*eHj=p zS*DORO$y;HwkIWNRCdAbdXslm1fRediEfaLtcc=o+EI zqhBn<=Im{|#jAVn<)=v=Wk);M*`1}T5tkz;ueH{N@_3K-wZhqqwPA0UZ&y%d0ls)p z7Cd|+8KY+C&8bV^KX?!d-`_ZK^N@ zZulowd}utYCqL==r+t8ml-TQI5>I`PBi;tY4XB;l`OKM|_)xtzH?!V8&YF2vJU<2s znHupJXxRmqJ~l8^bnfGI$T|5F6@*BPi|T>sxq!*fp%ivSvvFOrc<1Cd5ApElUJK2W zoXfBI9uJb&t|zRHW7Eg^>1P+)c)T*vh#yWpKMTO7FUIT<@wtTJY=S2Eb5`4qmw~D{ z5w8Lfue$PIxsaa|XeV~2M{+^z1TuNB?^9j;B`6Oeat+Q$dht9WKmXbvO0mvDx_CYh zo*&OunHmj9n@P5^Zn=_X99agC@pzTb$#_ubn`$=rYj@oLBMaU1^%aJ6n4G&fc?g)= zZ7%8a`P4F@#4O_ss$}~9=eFDgV$399Ey6yYtJo+GjJHMn&Es53H5CX^N&HrG=3fh3 zV)6C8BBLKL zzP`n2gHQC25*|LeQt;F{#_%9KKs%maInEj%na&40%i-a#1=2M9HJdsxlLkaVxbGak z_(aQ5c#u)P+o3q5gUQl>ku&fBfB4=OxpJ-e@O1GI7BKL>wQ z#KR9$4Bh(6`xQXxtC}gz3kcWDH?V@wQyNOoEsN)O6}aJQt^JL2PMUEwCBOtzl+&Jn z!9@g+NYp8I=|X2ge!rJjT!*{fNS%K!!@>q)iEUkv=A4|ZjE7I_a=IXXPP`TaEWJM6 z<#b_beqib0QlxVSog^Upy#=Velq6Gjf%iI~!Uu8N?Xn^0bwE~w0DnWjG*XMg(-{An zi63y)ugTs``Rm|rpiobmg6>}lB^i_cQhlIoDuH_J>(+7{S*N2FE^FS|B}Ceuetnw^W!G#0|??$05KWPZ~Zq! zi-Et<_`glhd1Z?DDTFcMt9}(+`Hu`hj`sb<`B#)Q;%VKQFHDmEk^h$w?7Zia5-%b+ z2xvvA$}e{j!N&mO2MKW&dlwPR4I)@?li1YtT*^nBDe~u@7Z>_@^1tqKm_M?8VjETnxYXzaC}l}G8c|bwsP`I z0AK1jyTFSGJ^*aCZj*0cL@@6oBY$6*i2Gk6MI)H0o#=jaz#z47lfSevlZU+)d$bd94On9)>ia4#`FP@uDq50s~km)2tM?_^`sv8#at{9 zB5evHBF#8F+~@)z)6eAgyBDC_1gg$RL-774XJe4N&Eyl8E;LUE23Ay+_6MVIQ=0+D znkgh%o_kKl1Q455Bnfay0we|<;8rt90zKw)p1j~wgu@WsopbSxm05HF(LL`X+WtR^ z54K4o0?*Ar0?a_2=ov_Mz7P!ps15!=?M7ZiZ7_~_h9`KD9U23H{Q@Nrxfkn>Obvw~ zGvoqCGK4@c!p>55D6g__EfAO(xXCCP_y}jQr_p$jM1I>mLi`J($Ry!GR#TH9=PW5= zn1J;p`}K%{Cb%W*&71FtUeU+r^{t>(jY1_)P zmO3gMibP}bDpH;M7_*9$Juy-8+LY1$sIq^M-@qb^~n7R~NNLNpzXC)pwl z#F!gQu{8%`c;)Z@!t|3>m)v6nNOLi|Ao$LcI<}BcI=FY_v#6MUL}EM=$@u zw!Ii~TsvgbgwLxy%cyDFaBqcT zr^P$Z`GCGK2?w2gUC3d%DLTXMK2(S;zv8SxMbG<4So9QWW^;(?7(PCwLR1wwPrxi_ zs|uXER7E=O=U3^rPph~&vc$hBrupnP!l~Q8yi{fuD?H~pvK{V4y=l|%QXW&)Z_d%x zb@kjzBb5I-d;H1^oiidzQ%ZI|ZTb4910h@H^)u-1O#8Iz&-KTsZ7kc82S!}OE7j{a zK5xRpOi0`l4C1Z3zf0U`)r#p#0IEONgz*Uuu>;h8uMv;m2to@u@?AEbjl5r}r_oo4 zSh#Ys{YzZw*CotX;ebVwEX2g#qwn1qIr)ahO@)5gnlo*@$EY>(=8?#hGu!H&+>wC@ zPkI3y$R&Vy_umpc;;3<>Vi|9{-V!2pOdAegGpt(^Hr!{{P*#DnQ*(`Eud)gC%{&j_ zZ67S_KA=)=gDt@e`3?#q-7a&DDlgNccb;3qO}0KNS#+H7Bs|24dp%L+&T$F@ zv7aZ!Cr9easwclIvzro|!o=r(hpRi4_G8$V%CxX>M3x!{v5(BA)y?&2Y zA3oc6A&_pzBs8t94RTH z6h&ydUrsu6@Nx3SrH_9ev)ot}41Ft;M>M}`m2b=arK&V7;vw~DQTtoR6D)U@t>NPJ z1j(44#o4@HW|bi&>DrMml}D}fV#3^q)w%a_YyZ#Xo?=3n;=}~&a^!Pq2K+h zPbwrz_J>#E)0SGXy|HhqCL>NC&5ATUwM|tDjp)LjI-_Xf%SWv(K8xGr)^9F7ckSX_ z%#BOKh8Zwbc#bMZRdC}3TzK8%R4sV|Fc)Jqe_m{pcxmNX1T z`imZB8WfW1X#HSV?9b$x%=RFyZf?E;%q;4PV)KZA%*OF60r&x%lKlsL% zN1X=dHI-*)IvV7uEZBx`@{UEi$ZfA!Htpp`><8vxeA0D99T6{Q3Z#s6=v;VgZZ))- zSl-f+OGhnP=lr96Lo4fbU(SNCQF@C)4u6M!wh9F<&pU%+%;n)9eDRz4=xXRnFKnbl zS+hgJRjV~YEBC&p6Z$PBY+8nB`lC!mG095r-Hcy4s+H~ZF7=+J4biLw z&Yxpf_ZJ_bZXUe+Y53G2v{G+osLmDs!w4BXimm^4P}#yMDHNM(^Sw(^3|D(UdOKuRAax(eGWu|ns zuiT)lX@-YI%?a|+2Lb)2&)8N=(HLJd#9O$gsQtE|^o1Eeajx)Q2tCi-UEnTI)<%;?A|E$OSUv15q$fk5&XRv#W6bOzP$7;^uNx-StLG%^MAW3mfSV{nMhlHyK;vTsUWB@_Xw?x8cY}UxI#m zi^ZTTuBMH-=J#5iw1*%1T~1~&_bgzX{UpRRetGFtzp1G*w$h!}c+hnr%XOp4bt8zK zY$=ydno6mDCsonM+No=-Khn`jQE|kkHSvqO(j5YPqKGdtSx~)po|l?jT%8LsLPm3i z$2UPa@#B++rBJ@r>W1yoZA z>O6C*Id>kaPwqupIXh+?RqAV_J-3FIs-N9GbDf+2z47_7#ABx5FsWhj4bhF`SBh@A zKjxW5Vhz5>ZQ8vi1KWmwK(+EUE3a9mpXu@l^N-cdGCHoEME5AlvHeE51m=^o_9>q( zx5?A?p0N;Grm)E6Vy&b18n}Y%(`8V&lmC=qu4ha3Kzr;Bn$I~U?ms$0ohlurNUt!Q zMy>7~t_g-=1kG?|X$%9X-NcIv?_Y&1`H+j1J}l&ta`0IiC3iZVJ^NFX+QdnM%g}D1 zK-!X$+qS(9*|6}4`7K6>tM$A=#dX|zNz$+8U#J);{NR(Z zN%IK!pPCr(^Gi?x*Ck53GZX#!;MVGUa5qg*A_90R3R+LK9;IAx6+*?S7BNPUC`kAf zpgtJ4CH6R-$L)Cmp9rvv@R}48MnH#SX=k#f9dUZsGyxBP*?M8CLYz;M&=Zsbc8wsf zP^@=mirF#ZkK&dJ-x_W3gZ93dxHOa@%)hR=bCQLCN%$kZb%_ZqB=SCjY~ zJgZWp=Es3<=Y2AFl7UiC(|iIRi@(`QDZ&xQn?=gn(p*FWXMY~BOD1i~D1BV}o55}9 zi;q<63kCu|P2$e$P<3z&rXi=Z9cbLc@;p*K=OI!}e)69bf_jFjeZGI3jehewN-!5fd|qKYRs{IyE~Hl-92aWT z#kSO&dZ4Y)y}7%y1sFM}VEBBBpgjeUQwbm_x<|+&!&oB#Fnxrq&`JpoAEFqNu)y-f zXGo=WHp@`aYo%P?Rm1HLKMDEu#>oMbB^dY@WIaTFddUF_9uhZ|l**o^=1znn8Ua4m zZ$Fg`tX~$F^Pi9b*~tN9yxZUPM#EprJ^A5~D@z@ul~P%EJ#f$}8|`=3LM!ExA6T4% zpCqe6_-~H?IJ)mqSM>lxqlA7H<~>J9h)7`!^B<+V{umc8!1`N4RMZRaPcPpb`D~*d zpn|r4j@x$&iUSBZSZUaZZ_VBy?~AT-&0%KSdn|s^IA`Pb(uewM!vou<_aPvMfo+WS>mn&|0h<-s#4^^lcuSeyHW1#VO9==hwKQ{Fpa{!(EknhgXkNu$dWUyw z>AQD|X8c*rRS5&5Zao@J62S??c2H=uV)7znwcNR-SF5E_spW1d-~(KM3r;;`%rCB2 z@zZyA^nOZ@+E=~58-%UV&(buwVSvk7hNbjwjjGo=7FD%GZg@Il zSbaPyfpQz~bTdE;t(FNcRoy)!*(IGp4L_t@j$J44Yo^SM@;xepTOf~$g1rsOS&Kl- zdSM7hw01QLW@;J^#vpga;&!da^GQ~^Js7q>B<{=$7&><7Ci!%f?~Zw4YQwxw)=ox! zDo%f{dDd3CFqP~vBBbn2PG_ogZPgL`yl=hWZVjaDwM17`^s-Trmeko#3WFD>i%d?nNpBYq7TOXleVxjYH5A^PkL##@ywSh4 zdb%fYJfAdj=yA5X*m1fkzgM+#us3QyNPxMI87p_1wYK=)EpFD1$kBZ^a!XA6?3s9_ zQxkvWtf7#Nnyo~!Ggr{SsI#O({`=qL&};=6AH+59aS-_5;O}&c{j*l254X;~U$vPV z>Rxi*HEJFcrD^s47z^FQDj>hXUyBY;B|EL{Zl|wRzFrd@bLbyoE?G}CELkO}ezRaU z>Ut_^TRNVYR6SjeNSt$$7=W40{>~LRvV{|ON#Ck~o72SYtY8;nZW5LB6&qHBvJbdE zFw%vXj6J)Le~z+bT|PEgVPH@v3e`ZCCnQqlYH9H=VLD z3)Z$Cd%4B$1h?AirDk??Y?q@Gjux$*a|VOybN6ZUWNSdt)W>e;fN$3EXRf^I4PS4h zw*|=VT^*JR)|a@xXqO%Bi(wKHYOKe-#!6tu=s2fx6GUXVyRwC0d|GwN)9j&HIgz_> zOb?$e{LU)l?^!{*R_qQCw zWv3P)hfC7mscsYqTNY9d)+_K>lIbaUPhs&e^it&!m{IdQKkRjd$b_e{s%DGU=bz4o44G-OWFAgr)Op-~r}-6 z-Vwp-&09y=CIOoxQ5E#EmhCIr&E!A23nITm8Biiq2u87rH!k8JF>%xSRa_~VFI1IY zxwF^Qan0+R*GS@49PRSiAN&B`?u?hMDOoeOb+2!2J2R|}OhuOFB~%3^i2uyfNvK%r z8LP5^KYLd>5_~X>W<1KlVA0l6OYlL(hsbVvj0XmZUg$#xPjU%x5{np6WZnR*_Uo9> z(5jLNCj{PCPJYCU%;feD!gkv#53Nkl1vz@4AjpHiMwD@zx(uQnQF;Bl4^zB^F#nj?wyP(>I1<{`L!8Kq-_gF$uI+~U=D`s? z0M628ve61w>o%@SvD|A(s2w!p7`Q~K9H!~7{jK-?e>mGgjz$2{B?Zx5J zRm6{D<`YYaH>RVt?a@Jjxw?dxAJ5);X1gE$Mt9HMt1-u*k`y&MWT9utUEyBoMc?cz zw_m8eyYdKDwK2>ULJLYvI>kO*r z0~TCbDYr}oSKstn#(w&2^$8Fx83%nXo<1~b)j_hI(=+epDT zB$&S3aD919tpZ_=V`<-pVR5*G(+AN^7jt-h2rc4TammXMNC!Jg9GE@!!AMlAfJ3p)aO~H92*}VwLccd79Po7D+7uGT)tADlQs2lM%kcuNt)d%s) zq*A>|<=L;?UL8z9u7PO05@p5UyKCS6I}4zuEq;*f2;CNas?zXBG~eOgZLEuu3Ro5k za@2Kk^@ePDv9v&oRY+@WpsAoYpbz8B(qy%mSOgamCc4l zLluRTE#+K`&fjP{Fq2ewHN*|WXtkcERFaGk=5fF}M#~wi?HGro6%;5vDaDq*vf~dW z^7_5bMqu0OwBL@B=Kd2JsR2$6Z^1CUnmT1iC7{rn?|c&N*rfCPX_Q7}Z}F=$ZWu?& z5Wnb=$y}pCS%EFl$oIaoA3~-aC6p^0CKAOA%(}s09Mq_ZvTj4&92l3@>$br$)_$EP zCueT{`wfiuC>YD`xStdiF_M{x0?>Lw#HsU_>8p2??-RhJnFYn+dGN*pTwxBXNBx6^ zIUA47%BeT^o61#BNSgq5U1 z-SLmM5!gt4ft>v{-K@Ja%@$_muA_siQM8ZHRf6s(%ED*UYin*Dsw-~4+k^#5Z@Dj| ze|}mqqGJU%hn(_8iz~C5H5zj@moQg9{aIyYXfR~_O zi`Z3l+IWnjIV>9CS#{L<($lqyURB`FW13tpqcwd-8WqG9fBRFN)q16G^oA6bvxl(~ zJ}*0@z3UROHt}v7eoySX#KF<0X-e-I>GrBD?wmLI55R;f)8huwe391Xhea9n|9RzH zj1(>okEJ5BQ5R&cw#!>x>of8_-Zzm~iZ*ZUw6ER{*v{#j<(ivn2&)TY`oJtfBW-_E zs)9nPrXjl0#c7gI2xD+f-{sl%x4FmCUMsCw!>Tzrrft|!!EuY@GZa-{{vyaRp)~EK zg6g!blMJrx=N9$TBX%WqGOzM;I$X#w*WB}xZ1D+IW4!*)|MZvtW380a&fC?{+|T`S z6VdjsW>P1ja+f_84K!}$4uq-v3A;aXFshO8J}K14HEk<(>PNxoP__?YR69K0C9r%G z;?j3%wE^{Ubbm5dlzC`s#lzn5q^|3dnfRR5s~GVB;cCe&?M;$hcx*)~$V$ymSx}JK zq!&*XG`KRrhT(h{bQ8l{2uuGEkrHd#A}y;fYJ>fu5c}Lm%DO+nJ6N*FXw17}D}>px zYDaqxMSc|F;?~!Gzm3r~w0yf%ODd}&9xI#sS>o~g>j z)3|l|(5t7~ zl=Ci;j{WX_xmh=Npj&O0WV0tZeDLce_iWGZvHE?Nc74yh^3#`l!N}v6nA`5QsX_4@eJZ_x#YqTAG+*SV<-fIM#WAr02?3a7FAgJ9~uep(l^c5w?o-t{)+^|0D<6W>@zuq^a8A-y3TBkmlc#6gRqMRD4d1T#%t+i_! zHU41z%23GYGb@XlJEZiQolceC4)(Hbf>PBb{llk&>01%)|$^vy2TonLB5yTpLSo?AsE}J|rG<8+J%j?+^=3XsVL>y0ZUKgQID+bco5KYO+;_vi(q&l)5NY zksH2|!loZivt%Tk(HV9lw9?+`88c%K;~2P7ZE`_U!ElA9ugoCvv`UTJJ|RsR-%*42 z$iC^fJhG{QMN(b!uQmiQ*a}kwcMt7p2JBfA+@t6Lb-*di;nYN-j+u@0{dz;p*knA9 zRcDpDt$^{c!JoF`GdMS+f~Ftds~jQxlaSA-i6y4>2#I$#8UNh3jmDxe3g`lq>{7?` zfGFR{NSpbLpMR1Me;+G}Fn-LlN!WqCK#gWoDq@Ys1|s&|*NVUVLG$hKdg_z)*McK7 zH5s*1;$F@%53Zol(51L);U9i z4)-NJw2(IDQ4weD5@mR0UN^bGcxc*EA8d^`?aWl^JrxDFq+d39B^6{~*~|J9Oi9@z%YbU zqs3>T6!+9T;HK1jC+m(QdyzM0#>Ebp`W0Iu0&SMVT+CJLZSo6t=aI<#f;{H@H;)^ zCl@(qq<+tGQw)a0EqfVuc}XO{V8Yv99kop#pIyfLpz!n7g6(ugIJ_)Vcf(1Tk>}`e ze!I%H@3YI7*L4a7OB&WqQbA%oCu&fJF*nYs!t5wqcMdQCUYMCuHgB&vO0xZM`p-_|I@PCqNv zOedeCF4yYPRY0UP%e*5H$wVxmZeR+28XDWU$ipy)C3{bNb@q7>OsY;J(kVmsy0&{wbDQHlOVXv;B`Z=<1xZ+5$tyNA;z<(Fv7;y8O+ z_R78~n$#lvlJ9%JFCBED3)G9H= zm)oqbL@fCuI}64Yd(MzGRpjlov)e*&4n)9Ko>R6J+1%3wq{CPd>K>ZCm%Yb2_91L4 zdxQO{lN*BGNuF8RlKXh$6sg-3zyn;`P1zXjtMox!lmMHIeK2K#C?1J7y|)}1cMk2omlYqalNHlNK126DS|Ke(p>1_XcUXUB-__AFp|n?bKjMrWkUmUZ z7|B`6+iefNTunf8_yDw?M&KIYFrGFyLe#Mo^pwM`*66H^s3d*`Z}nHm%Kpc(aMrrO z7<+|7`h%i*x;Z6l41R&FgpQp}bLxt5LTO*0b)`8hq-0bx23(@{AQ80Tap?wp%6m6$ z3SaHi;;zNgLGP;4-AadnWSg^jdBf7&y^1R<&1Y&qSEkG0^WUqzU@zNS-wPkWqjvVZ zW=0y#D%EWpeNP_E(T129n(OXHs(q24Eg2PLR)$YL&lGKqE9s*C8M-W=<58oa{nLao zZumnKIJVaVeQc#MfnE1l^{qQ|yN9uqQx=%l)}|g>#o)Yr*AG5l7fEZ#t>EhOIhsSjKA=JE!xK{9juEV^_=oYGl#h=oEl|} zGRTz{ZJTT0^C8gwifAmict9hHTe%{^DL8MN^VRz7X(_DDE+WN=O-s_=w4Gx-~T zj4o;aFnBS@t4QxwP~MY5(x84C#I=oXi;BXR!86pTQFemeN6{%JW9)l7hF#sILX{hD zYYK_%67;VA>(EDkkHD|hmSC?sxNn~Cfd@WUSwgJ6`dVUao`2*|#qNTv;~O8#)xvNa zTkSOqZ3VjmXl~t{YkG>>;Sz0pQE#ihfz%2`rkG#myHXB-f%OQhjpJK!nD3}Mz1X*;S*~N z&<$on50Rbi2lO?pCkY8ZDm^NE`?7_ek1R<(`w+&D?MmOD4|l`Icrx9>SE^L}P{cp- zdhiq4)kJT^L+g02LEz}mULGQguZ#j$FU8boUebFR`VXHx1l96@@)m92Rr${v%@XAe zZL7D%2V%{wbrvNHu1Qx?NT+H?_)fiQ`5v<61YFI7s;;ANAN#I(#G0lX@Da8ITVEBa z8TiMK5_FVt#A^md+{Dk7wj4Z&sB~^-@)6cv>V8Z!1-4vVp+}op0Y-*1Ro+FVeWSb0 z573mkj>u1za#kJ?iSxAtbdASNLirOaHhOqEz4u1 zs**paY*lWAxRoG6JcC4j(bk9EmTY@@y<|?GK%I&36w_=rfGVkfGP|wnx&i zz!a!&Kk619q*YTiGJ?yN-w{@wD`byRV~p+k)qF)R995TZJ&Kk7r0%8Wx!Tp@z4=AJ z^$cTh=e3^om^Eno0@AhtX-zq4{?Ik^Ij=crJYQMfEU1&Z&jqPko9NL|=ilWD8kJ*d z07=Eg;Vq}!;K(25SjxsG>+z>_5mS@6zVU&tGJTgAJ|Z0~?=M&<{Nm_dy2S^84(@aR z5Nm4IbiepFh+r0y3I_3RXO7I>Ct`CkT#AGjcTaxk=>VCb%}+y$i3jj3do5L$x{@Gx206&u^$`VE zZ!_R7-Mt{`)cI-j-=sBp1K4B;^-6eBe}j3$YTA9G)M~`H`4`sOlm6s}%GBsSdqEE> zQA$WUr5#zHL}{2pY0|sCxj_FT?o2Qd2b&rtzHQcM{8Q?MH8HAM>v8*edwy_BY6c0Q zwM*zI57r+$gun>M4TDNh=pX;v=gxeX{0Y)t?JjDc*hpwY zpbCYBoL@SU_`M}Aky+$8QC=dr-)uczn5Qg(YvbX3l>7t|<;7LcIurQAwpqp;bT;_P zED&9jt5HXN<6i$^8!Qa}Y_fYTD3&f47&(CDlM8-Jd9ZAM!)1h4nr#NJ8MgsSR$E(EUCK~NcYSnJn|6p#Dv?o`_SC#qh)EIoECi~QXc*n8#({~X? z8@(J|m2NZ5Drm!G>L(?WJP9QcJ=P<_jsBoNyW2()?V$59j@1YEJO;4dh46oKN1Be2 z*7uDJ+fgB~EgMPrxN{2@K`%%aiR<2oz&9@!>E_7<)}LgvCD+le8c)K&nmbI@s9sZ7 zfGRMM&*Rkm`V=W`K{ECI(ii9Rem6N5VSL&~aP#r<@z=V0cvCc%Rr7oR7*dcyq7v<0 zzJ{Dv!w#)%I6jJUECl$pbydyo)O8693pdau@$na0&Kzb18DB`tNlKw6*Bn*Q3!1G0?s zP`(GGhsv&f1#D}a$pxo`BSv@|+DfQ+^iMn6L@Van(J-mAtT5@_1XNbI`fpqdjtol} z^gH|beJo-WK5g&NJeomTAL%l7wCcj$EVWt?61D-Dr(TT-^nGJ#=!Up`L#+Kqi0>xu zR+Iq&5|5e&^XULHH-FioRvo?ff`0bxcr)x5!WKs^L>MX2Ya+0`FV>)~Ntp z3EvfcKZHC^K88d&%6+^X{1ZcDWv-`yP-+e)MK-e`HMy9|X6lQO>bN!mHGaq!YP;g4 z4p-6K4&Z}knfo#Pye-rES4g>|xBg~_ z0y-FM87~{_1pq_7MLvNwz^Ax^>>_E$Jbx>M;-Ui2Z;~=5$Cs;x!(8$S!ele_r8Y$F z213-)M6WJ?<-5C{on5wa_jTNd6%^drz9z#IZumOyyt!-P@%J0r`b+4!E$Vb#zKU8+ z16<Pj*?MM?BjZrq;X(%JQHFVLuC3A!{a`Q^uM z(AbhwpSifMPT4cWNq?{c##Vd)>XDZ>S|tAMK2Cb*mhms{eH0s5{n_5a{+M&P1_wkX z2b4hxy14(q1ylI_B*e2FVhTpu1@X^n135qi(=U%%6W1;18I1t#{sU?Ev(iiD^*$ zC`&eL&p_9DC;A;&Re(8F17W!;zBOJP?uCue^-gvWFM^m>3}x~I-is3ozh{eF)9RiFkpQ1W1~$r*_tnAt<>FY6jkr* zro_706qSH6O+k5KI$yd@1TDFIA96c$a-ra>a~nF_wc~(HCzvAFfjd`6WIbr1p4a?9&syo%rwUeJosn^Vn@3<7d=N# zM9wx*hp&39dLDUqAX9e6WEqX^JONmmR(JHaqTS@`s;qYPhmY(2>+}{;eO?yg0CyMwAc%lS} zOd~hoE{yD;VX!znacqN)ABJD=xfRwDBrQ1!=6N9o`r5<8SZtey|NaQpBILzIt4TDW zDuynyzN9e{$UUfFC`(t6xMwsi+uH= z5f*Fj*kHTop$E1}dgpdBM*SE?8q2*&hJ?i2o_-Bw9lwPZ71Z7wq-NpFK8jZ|CEF!pM$o$?6apEN(fG*ePbS z*AmsgK}y~q<8-jB2UW==<8T3_fLb&HELfM$9A1hjT)B=!llF?rMto)l5bz@bR69S{ zOi)Pf!r?aa7P6F(*KR_pqVsbz*P>pYB#~%3r|+ysv@s{~_>sw=%oRvJlF4cy6l2a( zLxpyOP*zH&3hIX2N)U37%|?X+Kqw$6Lkd*iU4QH7IBs+PRn`Vn2yzvj8l8#8_7nb? zSm&oqh6Ca*N$VC3qeN_fiL7l3Xc(^-b5B>Go<~4JcBI)`pq?LFBEejLBG&j`KjOOc zQY^OgX6)Jd$nJ*7uG-mn7p*wrIw;LJne&huPrdc0@DoD*D{6 z4C8qC^T2#YL_i5aS5pO5G9dD6Idk9;7bzjInS{j}NtC)GFG2-i6De~;iAdpXgrZrh z-_VRaAUPoqxF6e#5I=7f(J=L;^a5+d-arV4(bTCHBqJEQBp4{EyH6NVeJcfC%XRac z-&pe3A)uD3_qL)!P@Sa$SJ=-G9f2hLC%Ngs8dNA6gwhZD?LxL6!TeAeM(L>8Jv1_b zpxa1rG7fo7Ya@Ntf`F4v8sUhk#6^&Ap^J=l$olSj2y}17Zq#$K9VB7BoIfM_1GhEY1^JN5PJ|`6SCiw-6;2T_&?|P^=F{ zVSir9G(hz;2{_y&j$|#36iL6Z?^w}yq_-x#^nZ%DdD9+hy!6dSq=4wUV8eru7g;=F+hmektkRj%lSali z!t|B!`yJ008Ir;tWiz{;x@_NTBSK#SKi+V7p^jT6`Yeg80)f0`u70drluMo6JvW}Q zu3Ft>_RDt3^R3Mjs+6I#!Q6>dN=Nx@)X+eKXGM2WQcLh$y^Cqy=%o0t(QHnVg6XUC z9JXO@NNCS%E~`Ya$LsQhp%fI~9rIHT)pIj%t<_DQR!r0$6;03oD_>?9U78)u>uvSV zso{kfY6mG(d-jdB$M4{tbr~yn>$%P!HsX~hwUmX;eQfNTyzHz=fGWE9Aju=c(2!9s zc%7SWBu_Q%;_nwYEwnWXD^8p?h`rFtg%qyGhi||(^uJujTDJb`z`JMlj zk>gfG4HL8*FFK&!nY_j2z7W1&sGJWc2L70w{NP~g(~(O9K)O<=R0#mBNMN zp$XpFbir(GFq9WPme6HU!Na%xd6I7J-(gWDz$!j2+sTAiM%pQ8SAwby`6ow>1f7t1rIgy-2)DG^=KSlP#}JX6@(II+&GGZel- zshzcgef~>BtKmm#tECKASKCOG9WeJMpCZlF4sp|7M;jdqYB!W)2J#z*g(c#J69Yzz zauUWorTk;dEowSA4Iw@1oh3qzlij22OA_G?G`EXh0yvFl64Z+ydrH1P@-WA2Pn+6V z4$D8egm-(3ZgiDljQ8CVUMKkeg{A+LqKMnfsqQHo-@+YgMcUtqF}+kVTZc`SMh$Gs z0Id-5ZRh(VQ>K)3g*xQR@}ud0CFOTLRB}H)Ahpwy>G)FSS$}*<-7}71L(J!uylKDL z$>~8u>$dJmL(g;mvqM|2)pU6b6!0X8-&CJ%g~oGtt=3d8!=SHz8zaZ;6DPHmrK)`* zp~~~IesU1k+9_-z^IOV;ll?g{tm~0TpC^~sRu_6km0oxj9bGZ1mE56}#(iTHlrU!O zFaA z+lO;)sbbBv)gBf{dv^z?H`1*ueAfhq{s`KY&7bB~Waq_n`B(X0o=`rqH=pXLHZs}L z-Od&dpK6>;9{uuNe75+@vUbNGR{#KPYf8iueb-2E{PZ69Cv%bl$C?$?zw+A+8>Up$ zd=zKXE7J9=Qq7=etBhywr7^$aY?nt#`TyxplCShptQb->^IvAh&ojX53w5QnjNot~ z#BU+%PArB-@=Dcv`U}PQOnY#S`ti__NQOlpWOy*ChsA0MFCWT__Kkg1n;G6t8;8!A zmq(5%j0qS?D*J0hZ7@&@j{fSeR5q2L&vl%}d=D=d%`f(rrw7HmW+RQ>m2jfDdLQNB zxcT`J&$j`7=>}8vU-`YBJ!O-!uPXXVp63iq$;s={rgB?u;E5;K+Pu32pw4euSe%>4!EvJ?H{}_x z`eBM*%Ae}dVWSZ7Q#DRujr=`ZtyiuESmXJrmyt%sMF=0vuVOz_%WnutXpQMBQLtw2 zl^1ft|B;qp{vH1mCA?(i=hB|iK;Ma^Dh~HlOrcQ8PiOxvCF4{*5y5eJhYVbR?|NIC zg}8#kc=9IU7#53hy*&VzoJJWf81)ed#_J=t7O&k)-%{tvlELEAM604h)p>)4hU3i% z$6q!jhKRe2wO_C4@Gcyn2x4cY?}W|ZHqR+#(#VcD(cuDN_R-<$-bjwOrefQDrK+*A z#+AH_F@-nDZ^7B0-QSndPY_P^*)`c6-&orj(4Lgeon5XhkexEqE`LjyM_%_ubs-S8 zyl*9v3nurEdX3H|_00}RSj<+qsgAaP4^4n`V(;GblBB!>vW)4l=U4rOBfs_sw;FU0 z`_3KOTsUOb(RtP$@QD4)!Nv48f|^}Zb=v2fwfSRZo7k;q#NHIOxXwk=!iFJYe4ojy z7`h75Az>$BqWY+Y<`l)yhU#0(U--qrhju%hmnP*El5J})QC|BPc-RY1@j6SEPZV4d z54n{4f@pLw920u2Y5^dG6?r~IKT4(aa<9%L^|foLl#dR!7>a)-Mohq&J$0u5AbJl` zw~xBX!%>vl+GyAzJjG}zYCeFkR+@3<0RqD+@B z(+};rFKdyg#qM_*a|~VAHrqWmq2I`Uk|fzn2HZ_^--W}=F8N-1%%MoTZ2WcKFhef* zN-goGIh^ND?}->#GtaLP`KHkHT0K2Cc$IEgr@_0$KcwkaaB8QQs;QGZ_t5(V`Hhs2 zH_Am@q~A-sK+|^N<_U zYba0k>hSBKgy@RtjFyVAXx?mI!i%66pzC|HMk3i7H!e*n{}ZKr(?I`kLq#OREHb{( z=aZR!yUEL$zlAZ$#H)7IAJ+i*x?AEx!B>;K$3^FYXPjnNpK8xjRr6BZ=D)~vze~ne zOmtdIKZ@z5@cZY?G3o6pQz*jO2d!;if$cs7O5G|ZGq8z4;}daLnK9NTyyBKs#$UZ( zisJC*kaLTs`oSB2f2Es_rYrybt{OJVx1OmqO*85e%g=v(95^Dxi@oXnCsE;PR?6Hv zg^s`Uu^J)Mv7cA^eI(%+lZ66}Feti#0<%}p1MGh5vW&6*QG^K-f5S!}dwQ5O=|5o_ z7A~ExajN%i(|7|ku^3vE!?OqZzSwa%(L1*R;#0XTI*<6KAXp&>bfi|J^k*F)Te%bG zkPiZ41!Ttp?fhV*@ zr5;7}KlGrdWyvvj#{^-Ny&d9 zY>URQD=0vsogavwK}pzZILlW5cy-yk}*AP##D6#$a+AP7i8!A zfIM(IDsVc!*bh@w-5OBmk+2sb%;aI*0Kt2>sTFis*4 z;xY7l5Yvyojys(V=uW}?heNs9VRt64CD`YzJq(>D6QXLHGuD!U!trJ)U~0TT?fF3N zg+O+9+x{aEXQ75)3-3i!gO7z$s!a&oF^dZuFL5%u@XP6_vdoc782TaL^BOnREFC)O zg~n$~)HMwUmPI2NPX;n3l4`ks$q}8sJ(|`pw5ZGWGk@TZLCt0Fd$3xP9_U@&dT2tr z=X&bBj6K^RA~8Tjo?M*@6s>x4Gc{t_mInn9*$-mK;kc(>woo-6&%C7LPmrs>^mREj zbo6x}NSN-^XqAi3YHhG*hngw26r_|D%v4(xbd2>*B;-;mes5}}P=lwI=dwe41`0aH z1xb99{874_f9PbSp=N1d&lSdC=}2>uT)Un^M@h`FrU|o7EEYSBxkk~EzE&h}aVlX$ zXo>0EU|mFwg4b*t{COxgv`yc4^O_xN)DP>=NHF~?NHFF7s#B(Rbw{@q{;iB=2Xn{e zIxY;o<5v9XGSsY?Z0n9_*y|Xa6T~ahQfQRpNO8+~_u|LiQ5T%HcsPC(?>+EDZP1d` z?WJhEUeE4n&+k)bqoz-tRkbf$lH+k@cf#?rQhNP1hgGU14+%0K#MM~pssH?H9#A_z zq#Pnh6Ax{u>zJlD%zkY(Q4W^exd-lknk8C~l1U4XD~j6p-uXtD5PO@=_NHnM4u_Xj zdVA})C0&)M5mCr}Fx}op)UI`T>{NEpp~T)eD6Kg3bGx}C zB`#X$)o|MWocAa53s_mqCnBT!T8{5O9Mi4WE*vu%z}`OP``TW-ocNDAQU-8qwo^o? zrTrQ9kgF}Ryb3J2U>&HG$gX*7}*Hu?0pY(YN@r!K{R zG!~hnp)9W6nJ;Xta}zjvH9J#OMA3qKF|OqKX1ZEPRyV%XCwPJ#zdV@3fzvfie^B`< zYv|Qqj>8){`ejhRtuF7L5dH~w0jWHM+&SslqegS-YL93S2um*6yRZgh5?@V8qA7nK z9qNR0e?;~>vk&)`Z!5>Im$)UT0H;iJ2|Es_%*>M~aCv!pDX_r6szn8V<$4(?y1v{I`O{4OQv zd8dFFwPQnw&NH&@rf_!TmOW}s)18JDkv>#iv8Pi)DoSDET(RPP5z?EX6{pm4K7($_d6_4^4ITQ zTRn1Wx!wtZ(&b67^Pkw=gH(y&UIp{fO%vPjBi{|5o8VNXHjA)X0G9+-4|*f}6jJr0 z*fX7G9g-KQ(`m#`9)I(U$nvm8?miL2w#3Vu_0()jBVAm+27gu%F9(umIHe+1F|hWE z9;>no*CRC2HClLwxtS0=max;Q2Vh_I82j{zfKf-muzsI{_?zp4je5j&0Dt5d=Umls zYm~#%DC+B-LceCZZ}UIW9>{@ul3QnX7yQ?=2Gzi_}k%{)>G!fRL)V2aCiQ{I}VsbW^6(UYfO%@?Jvk zzF#leBC~U1QX{0r)03K;@_Trnpgxt~>^NF+&VfKpq|`ABEC>vvu-S%PHO#W$e6jE^-bVnIs~Qdr&xCX}tsEK`0# zBdTxqc&@@Ld3rnSMannXT~;JO$satk1{c4J4$XDQ%yAIW3+CaJ2rwHST+b z=cNY-Cx)wi`VS~69_sv3+msqt+2VA++CMp*ZFxIOqZqwpXS}l#StLU@%cj2c<6FxV zhNcD8-5dGJNH84+m#fY zGh%RM9i}`^$J7tmm7nbttFLrnlCLq{%6Z>{)fH;y2sbRJe~tHxI;AMJCz*0$nUjR5 zFn~)LBGNLwTjV&?J}Zv}XS50iYpok}0yY|~2;fAjg+!P0G;yxPl`>4%TUmcp=o@ew z>kX8}v%Bdl@!d;}OtBSx!hH$b4|qIwgVR)}@VAxRwc~*T=~~le={H>dwgm3zz-eDH z9dAnKu=o*9DHCM{8e zoj+fz{M^*-t?sW@)AJ$4M>i@dn|C@!)pHYHU;mrW_i8Bq!~qnw^AMaUtq)%6`5k)A zUok;9baUV$nB~_BfJS|HLr`e+3=};3kr8i17<_=CO98UE?r<1|MvGu;2~p^aVa5O# z{<~x(m}!2F>@v}5_U^D5p_I9yDvP$0TrJs^HqU674X=0~hn+er7;~x-Un=%|(8w%1 z36qU;{3a^|!d(J<_@;L~+&BV0+?DHyiJO&v7z(yX$iv7({H@nMWDUa%Jl0-dO%ov3 zo6ZuDUSi1(Wc|>WerG}9SbG)B^yD5|M3OEW!C}YR^?--Q76ywhZ^*ihICMw4 zcM~lptNkhhm}&oeoKeV_wY!Fb{!#$_POrVA(SOF^P^PACpsIjJAT$GTEL6N>e!Sxd z8&mU>?Ef3v9D%WoN0%fLZ_;PDOCL`BK9A^OYy+o6ql3{d6m)Naz$X29koa$G1DEr` z1(9y3QDir;SLc{Cf)$am4PfX7J`cVBmgt3i`oDGGKcCOeF4*ty%yYmfurnLt=*wLmobNf?-d`=S4^a$j)crMcw;yNbMq4oD=ID&(dFj1n$2W zP|uIBDP$l)e-2%zc_<$pmA(hQ(DS3{B_G|l|Dk7~j|8L1M3` z1O6PKwW153LWNNlH-FWUwhgg|qz*y{7=!oFGB#ZcLYaPRucAV5BU0r1(FFR7O#!nv zQ;wN>Hsc~HBm_ccaf9f7G*{1q8#*j0$#FCoZpXZ%%A?@n6-(Q!MY zhE_se9-Q~_nS-LX=$L8ciRfN z`IbxcRfU0$&nA@JO8moomjCGYFzjmof>TKRkUK&c=$tWv9){5Gj%?V~%`JmyLOKt^ z#$6jBgmzI6`Y_OMwt^iU2>{Uu=)Lj^9=ew3btL`943`IK%%|>u`RScG!_Y*N2((z~ x0#ybbPGyKpo5Cc2ZZ5~Z@f<7`BK&;;#`ThGs#xh0J@^G makeRenderer(...), + * frame: (r) => advanceOneFrame(r), // 1/60 s on a virtual clock + * now: (r) => clockMs(r), + * toEcef: ({ lon, lat, height }) => ecef, + * tolerance: DEFAULT_TOLERANCE, + * flightPaths: 'trace', // or 'endpoints' + * }); + * ``` + * + * Optional harness members widen what is checked: `pointer(r, type, x, y)` + * (synthetic input), `scriptHeights(r, fn)` (heights from a function), + * `wallTime(r)` (the wall clock a `live` motion clock reads), + * `assets.model` (a glTF URL). `create` may return null when it cannot + * make a renderer with fewer capabilities; those checks are then skipped. + */ +import { cameraChecks } from './checks/camera.js'; +import { portChecks } from './checks/ports.js'; + +/** + * @typedef {object} ConformanceTolerance + * @property {number} metres Positions and heights. + * @property {number} unit Unit vectors (direction, up, right). + * @property {number} radians Heading, pitch and roll. + * @property {number} pixels Screen positions. + * @property {number} changedCount `changed` events per script. + */ + +/** + * @typedef {object} ConformanceHarness + * @property {string} name + * @property {(options: {viewport: {width: number, height: number}, capabilities?: string[]}) => Promise} create + * @property {(r: import('../contract/renderer').Renderer) => void|Promise} frame + * @property {(r: import('../contract/renderer').Renderer) => number} now + * @property {(p: {lon: number, lat: number, height?: number}) => {x: number, y: number, z: number}} toEcef + * @property {ConformanceTolerance} tolerance + * @property {'trace'|'endpoints'} flightPaths Whether flight paths are compared frame by frame, or only where flights end. + * @property {(r: object, type: string, x: number, y: number, extra?: object) => void|Promise} [pointer] + * @property {(r: object, fn: (point: {lon: number, lat: number}, against?: string) => number|null) => void} [scriptHeights] + * @property {(r: object) => number} [wallTime] Epoch ms the renderer's `live` motion clock reads. + * @property {{model?: string}} [assets] + */ + +/** + * Tolerances a renderer that reproduces the reference camera meets: under a + * millimetre at city scale, well under a pixel on screen. + * @type {Readonly} + */ +export const DEFAULT_TOLERANCE = Object.freeze({ + metres: 1e-3, + unit: 1e-9, + radians: 1e-9, + pixels: 1e-3, + changedCount: 0, +}); + +/** Every check, in run order. */ +export function conformanceChecks() { + return [...portChecks(), ...cameraChecks()]; +} + +/** + * @param {ConformanceHarness} harness + * @param {{only?: (id: string) => boolean}} [options] + * @returns {Promise<{harness: string, passed: number, failed: number, skipped: number, + * results: Array<{id: string, port: string, title: string, status: 'pass'|'fail'|'skip', detail?: string, ms: number}>}>} + */ +export async function runConformance(harness, options = {}) { + const results = []; + for (const c of conformanceChecks()) { + if (options.only && !options.only(c.id)) continue; + const start = Date.now(); + let status = 'pass'; + let detail; + try { + const outcome = await c.run(harness); + if (outcome?.skip) { + status = 'skip'; + detail = outcome.skip; + } + } catch (error) { + status = 'fail'; + detail = error?.message ?? String(error); + } + results.push({ + id: c.id, + port: c.port, + title: c.title, + status, + ...(detail ? { detail } : {}), + ms: Date.now() - start, + }); + } + const count = (s) => results.filter((r) => r.status === s).length; + return { + harness: harness.name, + passed: count('pass'), + failed: count('fail'), + skipped: count('skip'), + results, + }; +} + +export { CAMERA_SCRIPTS, SCRIPT_VIEWPORT } from './cameraScripts.js'; +export { CALIBRATION_PATH, REFERENCE_SCENES, sceneSupport } from './scenes.js'; +export { + VISUAL_TOLERANCE, + compareImages, + drawScene, + referenceName, +} from './visual.js'; +export { contractDriver, runCameraScript, tracePose } from './cameraRunner.js'; +export * as expect from './expect.js'; diff --git a/src/render/conformance/scenes.js b/src/render/conformance/scenes.js new file mode 100644 index 000000000..aab5b0666 --- /dev/null +++ b/src/render/conformance/scenes.js @@ -0,0 +1,558 @@ +/** + * @module render/conformance/scenes + * @description The reference scenes: one per primitive family, a city scene + * that mixes them, and a calibration path. Each is built from contract calls + * only, so any renderer can draw it, and each has fixed views to capture. + * + * The scenes load nothing from the network: the globe is bare (no imagery, + * no photoreal tiles, no atmosphere), so what a capture shows depends on the + * renderer alone. Images are inline SVG. + * + * A scene is `{ id, family, capability, build(renderer, ctx), views }`: + * `build` returns a disposer; `ctx` has `toEcef` and optional `assets` + * (`model`: a glTF URL). A scene whose capability the renderer lacks, or + * whose asset the context lacks, is not drawn. + */ +import { AUSTIN } from './cameraScripts.js'; + +/** Looking down at Austin from `height`, tilted by `pitch`. */ +const over = (height, pitch = -Math.PI / 2, heading = 0, at = AUSTIN) => ({ + destination: { ...at, height }, + orientation: { heading, pitch, roll: 0 }, +}); + +/** A point `east`, `north` metres from `center`, approximately. */ +function offsetFrom(center, east, north, height = 0) { + return { + lon: center.lon + east / (111320 * Math.cos((center.lat * Math.PI) / 180)), + lat: center.lat + north / 110540, + height, + }; +} + +/** A point `east`, `north` metres from Austin, approximately. */ +const offset = (east, north, height = 0) => + offsetFrom(AUSTIN, east, north, height); + +const svg = (body, size = 32) => ({ + svg: `${body}`, +}); +const ICONS = { + dot: svg(''), + arrow: svg(''), + square: svg( + '', + ), +}; + +const BG = { + sky: { + atmosphere: false, + stars: false, + milkyWay: 0, + sun: false, + moon: false, + fog: false, + lighting: false, + }, +}; + +/** Bare globe, empty sky: nothing in a capture but the scene. */ +async function bare(r) { + await r.globe.setBase({ + photoreal: null, + terrain: { kind: 'ellipsoid' }, + imagery: [], + }); + r.globe.setGlobeVisible(true); + r.globe.sky.set(BG.sky); +} + +/** Disposes every set it was given. */ +const disposeAll = + (...handles) => + () => + handles.forEach((h) => h.dispose()); + +const ring = (ctx, center, radius, count, height = 0) => + Array.from({ length: count + 1 }, (_, i) => { + const a = (i / count) * Math.PI * 2; + return ctx.toEcef( + offsetFrom(center, Math.cos(a) * radius, Math.sin(a) * radius, height), + ); + }); + +/** @type {ReadonlyArray} */ +export const REFERENCE_SCENES = Object.freeze([ + { + id: 'icons', + family: 'icons', + capability: null, + async build(r, ctx) { + await bare(r); + const set = r.draw.icons({ id: 'ref:icons' }); + const items = []; + const colors = ['#ff3b30', '#34c759', '#0a84ff', '#ffd60a']; + for (let row = 0; row < 4; row++) + for (let col = 0; col < 6; col++) { + const id = `i${row}${col}`; + const pos = ctx.toEcef(offset((col - 2.5) * 250, (1.5 - row) * 250)); + // Drawn over the globe: at 0 m a tilted view would hide their lower half. + const base = { + id, + pos, + size: [24 + col * 4, 24 + col * 4], + color: colors[row], + disableDepthTest: Infinity, + }; + if (row === 0) items.push({ ...base, image: ICONS.dot }); + if (row === 1) + items.push({ + ...base, + image: ICONS.arrow, + rotationMode: 'screen', + rotation: (col * Math.PI) / 6, + }); + if (row === 2) + items.push({ + ...base, + image: ICONS.arrow, + rotationMode: 'course', + course: col * 60, + }); + if (row === 3) + items.push({ + ...base, + image: ICONS.square, + anchor: col % 2 ? 'bottom' : 'center', + alpha: 0.4 + col * 0.1, + }); + } + set.set(items); + return disposeAll(set); + }, + views: [ + { name: 'top', view: over(3000) }, + { name: 'tilted', view: over(1500, -0.6, 0, offset(0, -2300)) }, + ], + }, + { + id: 'points', + family: 'points', + capability: null, + async build(r, ctx) { + await bare(r); + const set = r.draw.points({ id: 'ref:points' }); + const items = []; + for (let i = 0; i < 8; i++) + items.push({ + id: `p${i}`, + pos: ctx.toEcef(offset((i - 3.5) * 180, 150)), + pixelSize: 4 + i * 3, + color: [1, i / 7, 0, 1], + }); + for (let i = 0; i < 8; i++) + items.push({ + id: `q${i}`, + pos: ctx.toEcef(offset((i - 3.5) * 180, -150)), + pixelSize: 18, + color: '#0a84ff', + outlineColor: '#ffffff', + outlineWidth: i, + }); + set.set(items); + return disposeAll(set); + }, + views: [{ name: 'top', view: over(2500) }], + }, + { + id: 'lines', + family: 'lines', + capability: null, + async build(r, ctx) { + await bare(r); + const set = r.draw.lines({ id: 'ref:lines' }); + const row = (north, height = 0) => [ + ctx.toEcef(offset(-1200, north, height)), + ctx.toEcef(offset(1200, north, height)), + ]; + set.set([ + { + id: 'solid', + positions: row(600), + widthPx: 2, + mode: '3d', + style: { kind: 'solid', color: '#ffffff' }, + }, + { + id: 'wide', + positions: row(400), + widthPx: 8, + mode: '3d', + style: { kind: 'solid', color: '#ff3b30' }, + }, + { + id: 'glow', + positions: row(200), + widthPx: 12, + mode: '3d', + style: { kind: 'glow', color: '#34c759', power: 0.25 }, + }, + { + id: 'dash', + positions: row(0), + widthPx: 4, + mode: '3d', + style: { kind: 'dash', color: '#ffd60a', length: 16 }, + }, + { + id: 'arrow', + positions: row(-200), + widthPx: 14, + mode: '3d', + style: { kind: 'arrow', color: '#0a84ff' }, + }, + { + id: 'raised', + positions: row(-400, 300), + widthPx: 3, + mode: '3d', + style: { kind: 'solid', color: '#bf5af2' }, + }, + { + id: 'geodesic', + positions: row(-600), + widthPx: 3, + mode: 'geodesic', + style: { kind: 'solid', color: '#64d2ff' }, + }, + { + id: 'loop', + positions: ring(ctx, offset(0, 0), 900, 48), + widthPx: 2, + mode: '3d', + style: { kind: 'solid', color: '#ff9f0a' }, + }, + ]); + return disposeAll(set); + }, + views: [ + { name: 'top', view: over(4500) }, + { name: 'tilted', view: over(2500, -0.45, 0, offset(0, -3500)) }, + ], + }, + { + id: 'shapes', + family: 'shapes', + capability: null, + async build(r, ctx) { + await bare(r); + const set = r.draw.shapes({ id: 'ref:shapes' }); + // Flat shapes sit 5 m up: at 0 m they share depth with the globe. + const lift = 5; + const square = (east, north, half) => [ + offset(east - half, north - half, lift), + offset(east + half, north - half, lift), + offset(east + half, north + half, lift), + offset(east - half, north + half, lift), + ]; + set.set([ + { + id: 'polygon', + geometry: { kind: 'polygon', rings: [square(-700, 400, 250)] }, + fill: [1, 0.23, 0.19, 0.8], + outline: '#ffffff', + }, + { + id: 'holed', + geometry: { + kind: 'polygon', + rings: [square(0, 400, 280), square(0, 400, 120)], + }, + fill: '#34c759', + }, + { + id: 'extruded', + geometry: { kind: 'polygon', rings: [square(700, 400, 200)] }, + fill: '#0a84ff', + extrudeHeight: 400, + }, + { + id: 'ellipse', + geometry: { + kind: 'ellipse', + center: offset(-700, -400, 5), + semiMajor: 300, + semiMinor: 160, + rotation: 0.5, + }, + fill: [1, 0.84, 0.04, 0.7], + }, + { + id: 'rectangle', + geometry: { + kind: 'rectangle', + rect: { + west: offset(-250, 0).lon, + south: offset(0, -650).lat, + east: offset(250, 0).lon, + north: offset(0, -150).lat, + }, + height: 5, + }, + fill: '#bf5af2', + }, + { + id: 'box', + geometry: { + kind: 'box', + center: offset(700, -400, 150), + dimensions: { x: 300, y: 300, z: 300 }, + }, + fill: '#ff9f0a', + }, + { + id: 'outline', + geometry: { kind: 'polygon', rings: [square(0, -1000, 200)] }, + outline: '#ffffff', + outlineOnly: true, + }, + ]); + return disposeAll(set); + }, + views: [ + { name: 'top', view: over(5000) }, + { name: 'tilted', view: over(2500, -0.5, 0.3, offset(0, -3600)) }, + ], + }, + { + id: 'models', + family: 'models', + capability: 'models', + needs: ['model'], + async build(r, ctx) { + await bare(r); + r.globe.sky.set({ lighting: true }); + // No wait on `ready` here: a model that never loads would hang the + // run, and `whenSettled` before each view waits for loading models. + const set = r.draw.models({ id: 'ref:models' }); + set.set([ + { + id: 'plane', + pos: offset(0, 0, 300), + url: ctx.assets.model, + orientation: { heading: 0.6, pitch: 0, roll: 0 }, + scale: 4, + minPixelSize: 64, + }, + ]); + return disposeAll(set); + }, + views: [{ name: 'oblique', view: over(330, -0.2, 0, offset(0, -140)) }], + }, + { + id: 'quads', + family: 'quads', + capability: 'quads', + async build(r, ctx) { + await bare(r); + const set = r.draw.quads({ id: 'ref:quads' }); + set.set([ + { + id: 'card', + center: offset(0, 0, 200), + orientation: { x: 0, y: 0, z: 0, w: 1 }, + size: [400, 250], + source: { image: ICONS.square }, + outline: { color: '#ffffff', widthPx: 2 }, + }, + ]); + return disposeAll(set); + }, + views: [{ name: 'oblique', view: over(1500, -0.5, 0, offset(0, -1600)) }], + }, + { + id: 'meshes', + family: 'meshes', + capability: 'meshes', + async build(r, ctx) { + await bare(r); + const set = r.draw.meshes({ id: 'ref:meshes' }); + // A square pyramid, 400 m across and 300 m tall. + const positions = new Float32Array([ + -200, -200, 0, 200, -200, 0, 200, 200, 0, -200, 200, 0, 0, 0, 300, + ]); + const indices = new Uint16Array([0, 1, 4, 1, 2, 4, 2, 3, 4, 3, 0, 4]); + set.set([ + { + id: 'pyramid', + origin: ctx.toEcef(offset(0, 0)), + positions, + indices, + color: '#ff9f0a', + flat: true, + }, + ]); + return disposeAll(set); + }, + views: [{ name: 'oblique', view: over(1200, -0.5, 0, offset(0, -1400)) }], + }, + { + id: 'fields', + family: 'fields', + capability: 'fields', + async build(r) { + await bare(r); + const width = 32; + const height = 16; + const values = new Float32Array(width * height).map( + (_, i) => (i % width) / width, + ); + const field = r.draw.field({ + kind: 'scalarRaster', + id: 'ref:scalar', + values, + width, + height, + rectangle: { west: -100, south: 28, east: -95, north: 32 }, + ramp: [ + { value: 0, color: '#0a84ff' }, + { value: 0.5, color: '#34c759' }, + { value: 1, color: '#ff3b30' }, + ], + alpha: 0.8, + }); + return () => field.dispose(); + }, + views: [ + { + name: 'regional', + view: over(900000, -Math.PI / 2, 0, { lon: -97.5, lat: 30 }), + }, + ], + }, + { + id: 'agents', + family: 'agents', + capability: 'agents', + async build(r, ctx) { + await bare(r); + const set = r.draw.agents({ id: 'ref:agents' }); + set.set([ + { + id: 'road', + positions: [ctx.toEcef(offset(-800, 0)), ctx.toEcef(offset(800, 0))], + count: 20, + speedMps: 0, + color: '#ffd60a', + pixelSize: 6, + }, + ]); + return disposeAll(set); + }, + views: [{ name: 'top', view: over(2500) }], + }, + { + id: 'city', + family: 'city', + capability: null, + async build(r, ctx) { + await bare(r); + const shapes = r.draw.shapes({ id: 'ref:city:blocks' }); + const blocks = []; + for (let i = 0; i < 5; i++) + for (let j = 0; j < 5; j++) { + const e = (i - 2) * 220; + const n = (j - 2) * 220; + blocks.push({ + id: `b${i}${j}`, + geometry: { + kind: 'polygon', + rings: [ + [ + offset(e - 80, n - 80), + offset(e + 80, n - 80), + offset(e + 80, n + 80), + offset(e - 80, n + 80), + ], + ], + }, + fill: [0.55, 0.6, 0.7, 1], + extrudeHeight: 40 + ((i * 7 + j * 13) % 9) * 25, + }); + } + shapes.set(blocks); + const roads = r.draw.lines({ id: 'ref:city:roads' }); + const road = (id, a, b) => ({ + id, + positions: [ctx.toEcef(a), ctx.toEcef(b)], + widthPx: 3, + mode: '3d', + style: { kind: 'solid', color: '#ffd60a' }, + }); + roads.set([ + ...[-330, -110, 110, 330].map((n, k) => + road(`ew${k}`, offset(-600, n, 2), offset(600, n, 2)), + ), + ...[-330, -110, 110, 330].map((e, k) => + road(`ns${k}`, offset(e, -600, 2), offset(e, 600, 2)), + ), + ]); + const vehicles = r.draw.icons({ id: 'ref:city:vehicles' }); + vehicles.set( + Array.from({ length: 12 }, (_, k) => ({ + id: `v${k}`, + pos: ctx.toEcef(offset(-500 + k * 90, -110, 5)), + image: ICONS.arrow, + size: [20, 20], + color: '#ff3b30', + rotationMode: 'course', + course: 90, + })), + ); + const sensors = r.draw.points({ id: 'ref:city:sensors' }); + sensors.set( + Array.from({ length: 10 }, (_, k) => ({ + id: `s${k}`, + pos: ctx.toEcef(offset(110, -550 + k * 110, 5)), + pixelSize: 8, + color: '#34c759', + outlineColor: '#000000', + outlineWidth: 1, + })), + ); + return disposeAll(shapes, roads, vehicles, sensors); + }, + views: [ + { name: 'orbit', view: over(15000000) }, + { name: 'city', view: over(1800, -0.6, 0.4, offset(-1200, -2600)) }, + { name: 'street', view: over(60, -0.08, 0, offset(-150, -700)) }, + ], + }, +]); + +/** + * The calibration path: the city scene seen from orbit down to the street, + * the poses a quality calibration (and the performance budget) samples. + */ +export const CALIBRATION_PATH = Object.freeze({ + scene: 'city', + poses: Object.freeze([ + over(15000000), + over(1500000), + over(60000, -1.2), + over(6000, -0.8, 0.3, offset(-800, -3000)), + over(1200, -0.5, 0.5, offset(-600, -1200)), + over(250, -0.25, 0.2, offset(-250, -800)), + over(60, -0.08, 0, offset(-150, -700)), + ]), +}); + +/** Whether a renderer and context can draw a scene; the reason when not. */ +export function sceneSupport(scene, renderer, ctx = {}) { + if (scene.capability && !renderer.capabilities.has(scene.capability)) + return `no ${scene.capability}`; + for (const need of scene.needs ?? []) + if (!ctx.assets?.[need]) return `no ${need} asset`; + return null; +} diff --git a/src/render/conformance/scenes.test.mjs b/src/render/conformance/scenes.test.mjs new file mode 100644 index 000000000..b3d301e88 --- /dev/null +++ b/src/render/conformance/scenes.test.mjs @@ -0,0 +1,229 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PORTS } from '../contract/index.js'; +import { createStubRenderer } from '../stub/index.js'; +import { toEcef } from '../stub/math.js'; +import { CALIBRATION_PATH, REFERENCE_SCENES, sceneSupport } from './scenes.js'; +import { compareImages } from './visual.js'; + +/** + * The stub behind proxies that throw on any member the contract does not + * list, so a scene that reaches past the contract fails here. + */ +const MEMBERS = { ...PORTS, handle: { dispose: null, update: null } }; + +function contractOnly(renderer) { + const guard = (target, port, prefix = '') => + new Proxy(target, { + get(obj, key, receiver) { + if (typeof key === 'symbol' || key === 'then') + return Reflect.get(obj, key, receiver); + const member = prefix + key; + if (!(member in MEMBERS[port])) + throw new Error(`${port}.${member} is not in the contract`); + const value = Reflect.get(obj, key, receiver); + if (port === 'draw' && typeof value === 'function') + return (...args) => + guard( + value.apply(obj, args), + key === 'field' || key === 'celestial' ? 'handle' : 'drawSet', + ); + const nested = Object.keys(MEMBERS[port]).some((m) => + m.startsWith(`${member}.`), + ); + if (value && typeof value === 'object' && nested) + return guard(value, port, `${member}.`); + return typeof value === 'function' ? value.bind(obj) : value; + }, + }); + const wrapped = {}; + for (const port of Object.keys(PORTS.renderer)) { + const value = renderer[port]; + wrapped[port] = + MEMBERS[port] && typeof value === 'object' ? guard(value, port) : value; + } + return wrapped; +} + +const ctx = { toEcef, assets: { model: '/models/c172.glb' } }; + +test('every reference scene builds and disposes on the stub through the contract alone', async () => { + const ids = new Set(); + for (const scene of REFERENCE_SCENES) { + assert.ok(!ids.has(scene.id), `${scene.id} is unique`); + ids.add(scene.id); + const stub = createStubRenderer(); + // Models report ready once their asset loads; the stub has none to load. + if (scene.capability === 'models') continue; + assert.equal(sceneSupport(scene, stub, ctx), null, scene.id); + const renderer = contractOnly(stub); + const dispose = await scene.build(renderer, ctx); + assert.ok( + stub.testing.sets().length > 0 || scene.family === 'fields', + `${scene.id} drew something`, + ); + for (const view of scene.views) renderer.camera.setView(view.view); + dispose(); + assert.deepEqual( + stub.testing.sets(), + [], + `${scene.id} left nothing behind`, + ); + assert.ok(scene.views.length > 0, `${scene.id} has views`); + } + for (const family of [ + 'icons', + 'points', + 'lines', + 'shapes', + 'models', + 'quads', + 'meshes', + 'fields', + 'agents', + 'city', + ]) + assert.ok(ids.has(family), `a ${family} scene`); +}); + +test('a scene a renderer cannot draw says why', () => { + const stub = createStubRenderer({ capabilities: [] }); + const models = REFERENCE_SCENES.find((s) => s.id === 'models'); + assert.equal(sceneSupport(models, stub, ctx), 'no models'); + assert.equal( + sceneSupport(models, createStubRenderer(), {}), + 'no model asset', + ); +}); + +test('the calibration path runs from orbit to street over the city scene', () => { + assert.ok(REFERENCE_SCENES.some((s) => s.id === CALIBRATION_PATH.scene)); + const heights = CALIBRATION_PATH.poses.map((p) => p.destination.height); + assert.deepEqual( + heights, + [...heights].sort((a, b) => b - a), + ); + assert.ok(heights[0] >= 1e7 && heights.at(-1) <= 100); +}); + +const image = (w, h, fill) => { + const data = new Uint8ClampedArray(w * h * 4); + for (let p = 0; p < data.length; p += 4) data.set(fill(p / 4), p); + return { width: w, height: h, data }; +}; + +test('the image comparison passes rounding and fails a moved item', () => { + const base = image(100, 100, (i) => + i % 100 > 40 && i % 100 < 60 && i < 5000 + ? [255, 0, 0, 255] + : [20, 30, 40, 255], + ); + const rounded = image(100, 100, (i) => + [...base.data.slice(i * 4, i * 4 + 3)] + .map((c) => Math.min(255, c + 2)) + .concat(255), + ); + assert.equal(compareImages(rounded, base).match, true); + const moved = image(100, 100, (i) => + i % 100 > 10 && i % 100 < 30 && i < 5000 + ? [255, 0, 0, 255] + : [20, 30, 40, 255], + ); + const result = compareImages(moved, base); + assert.equal(result.match, false); + assert.ok(result.diffFraction > 0.1); + assert.ok(result.solidPixels > 100); + assert.equal( + compareImages( + image(10, 10, () => [0, 0, 0, 255]), + base, + ).match, + false, + ); +}); + +test('the image comparison drops one-pixel fringes and catches a small item', () => { + const blank = image(100, 100, () => [20, 30, 40, 255]); + // Antialiasing: a one-pixel ring and a one-pixel line differ, nothing solid. + const fringe = image(100, 100, (i) => { + const x = i % 100; + const y = (i - x) / 100; + const ring = Math.abs(Math.hypot(x - 50, y - 50) - 20) < 0.5; + return ring || x === 80 ? [200, 200, 200, 255] : [20, 30, 40, 255]; + }); + const fringed = compareImages(fringe, blank); + assert.ok(fringed.diffPixels > 100, 'many pixels differ'); + assert.equal(fringed.solidPixels, 0); + assert.equal(fringed.match, true); + // The smallest reference item: a 4 px point. + const point = image(100, 100, (i) => { + const x = i % 100; + const y = (i - x) / 100; + return Math.hypot(x - 30.5, y - 30.5) <= 2.2 + ? [255, 0, 0, 255] + : [20, 30, 40, 255]; + }); + const dropped = compareImages(blank, point); + assert.ok(dropped.solidPixels > 2, `solid ${dropped.solidPixels}`); + assert.equal(dropped.match, false); +}); + +test('the image comparison catches a dropped thin line and a one-pixel shift', () => { + const at = (fill) => + image(200, 200, (i) => { + const x = i % 200; + return fill(x, (i - x) / 200) ? [255, 255, 255, 255] : [20, 30, 40, 255]; + }); + const blank = at(() => false); + // These 160 px lines, 2 and 3 px wide, cover 320 and 480 px: over the + // 250 px limit. A thin change covering less passes (see visual.js). + for (const width of [2, 3]) { + const line = at( + (x, y) => x >= 20 && x < 180 && y >= 100 && y < 100 + width, + ); + assert.equal( + compareImages(blank, line).match, + false, + `a dropped ${width} px line`, + ); + } + const box = (dx, dy) => + at((x, y) => x >= 40 + dx && x < 160 + dx && y >= 50 + dy && y < 150 + dy); + // A 120×100 box moved leaves strips on the sides it moves across: caught + // when they cover more than 250 px. Moved 1 px along one axis, it covers 200 + // and passes; these cover more. + for (const [dx, dy] of [ + [1, 1], + [2, 0], + [2, 2], + ]) + assert.equal( + compareImages(box(dx, dy), box(0, 0)).match, + false, + `a box moved by (${dx}, ${dy})`, + ); +}); + +test('the image comparison takes pixel data that does not start on a word', () => { + const base = image(4, 4, () => [20, 30, 40, 255]); + const bytes = new Uint8ClampedArray(base.data.length + 1); + bytes.set(base.data, 1); + const sliced = { width: 4, height: 4, data: bytes.subarray(1) }; + assert.equal(compareImages(sliced, base).match, true); +}); + +test('a thin change under 250 px passes, as documented', () => { + const at = (fill) => + image(200, 200, (i) => { + const x = i % 200; + return fill(x, (i - x) / 200) ? [255, 255, 255, 255] : [20, 30, 40, 255]; + }); + const line = at((x, y) => x >= 20 && x < 100 && y >= 100 && y < 102); + const result = compareImages( + at(() => false), + line, + ); + assert.equal(result.solidPixels, 0); + assert.equal(result.mismatchedPixels, 160); + assert.equal(result.match, true); +}); diff --git a/src/render/conformance/visual.js b/src/render/conformance/visual.js new file mode 100644 index 000000000..8c9556d4c --- /dev/null +++ b/src/render/conformance/visual.js @@ -0,0 +1,227 @@ +/** + * @module render/conformance/visual + * @description The visual checks: draw a reference scene from one of its + * views, capture it, and compare the capture with the reference image under + * a perceptual tolerance. + * + * Pixels are compared as pixelmatch compares them (its algorithm, ported; + * pixelmatch is ISC-licensed, copyright Mapbox): by YIQ difference, which + * weighs brightness over hue as the eye does, and excusing a differing pixel + * that is antialiasing in either image, one on an edge between two flat + * colours. A dropped or moved item differs inside its body and along hard + * edges, which are not excused. A differing pixel whose four neighbours + * differ too is solid: antialiasing differs along one-pixel fringes and has + * none. Two images match when at most `maxSolidPixels` pixels are solid and + * at most `maxMismatchedPixels` are unexcused. + * + * The two measures cover each other. Measured on the default renderer at + * 640×360, against references drawn with 4× MSAA: + * + * | Change | Solid | Unexcused | + * | ------------------------------ | ----: | --------: | + * | Drawn with 1×, 2× or 8× MSAA | 0 | ≤ 192 | + * | The smallest point (4 px) gone | 7 | 8 | + * | An arrow line reversed | 24 | 88 | + * | An arrow icon turned | 64 | 91 | + * | A 2 px line gone | ≥ 267 | ≥ 536 | + * | The whole view moved 1 px | ≥ 36 | ≥ 480 | + * | An icon gone | ~ 300 | ~ 300 | + * + * The limit: a solid pixel needs a change at least 3 px thick, so a change + * that is nowhere that thick (a thin line gone, an item moved a pixel or two) + * passes when it covers at most 250 px: a missing 2 px line up to 125 px + * long, say, or a 20 px box moved 1 or 2 px. A blob up to about 3×4 px is + * under both limits too (2 solid pixels). Larger thin changes, and any + * change whose 3 px core is more than two pixels, are caught; the smallest + * item in the scenes, the 4 px point, leaves 7. + */ + +/** Tolerances the default renderer meets against its own references with any antialiasing. */ +export const VISUAL_TOLERANCE = Object.freeze({ + threshold: 0.1, + maxSolidPixels: 2, + maxMismatchedPixels: 250, +}); + +/** The largest YIQ difference two RGB colours can have. */ +const MAX_YIQ_DELTA = 35215; + +/** Composites a channel over black, so transparent and black read the same. */ +const over = (c, a) => (c * a) / 255; + +const rgb = (d, p) => [ + over(d[p], d[p + 3]), + over(d[p + 1], d[p + 3]), + over(d[p + 2], d[p + 3]), +]; +const brightness = ([r, g, b]) => + r * 0.29889531 + g * 0.58662247 + b * 0.11448223; + +function yiqDelta([r1, g1, b1], [r2, g2, b2]) { + const i = (r, g, b) => r * 0.59597799 - g * 0.2741761 - b * 0.32180189; + const q = (r, g, b) => r * 0.21147017 - g * 0.52261711 + b * 0.31114694; + const dy = brightness([r1, g1, b1]) - brightness([r2, g2, b2]); + const di = i(r1, g1, b1) - i(r2, g2, b2); + const dq = q(r1, g1, b1) - q(r2, g2, b2); + return 0.5053 * dy * dy + 0.299 * di * di + 0.1957 * dq * dq; +} + +/** Whether (x, y) has more than two neighbours of exactly its own colour. */ +function hasManySiblings(px, x1, y1, w, h) { + const val = px[y1 * w + x1]; + let zeroes = x1 === 0 || y1 === 0 || x1 === w - 1 || y1 === h - 1 ? 1 : 0; + for (let x = Math.max(x1 - 1, 0); x <= Math.min(x1 + 1, w - 1); x++) + for (let y = Math.max(y1 - 1, 0); y <= Math.min(y1 + 1, h - 1); y++) { + if (x === x1 && y === y1) continue; + if (px[y * w + x] === val && ++zeroes > 2) return true; + } + return false; +} + +/** + * Whether (x1, y1) of `img` is antialiasing: a pixel between its darkest and + * brightest neighbours, one of which is flat (has siblings) in both images. + */ +function antialiased(img, px, otherPx, x1, y1, w, h) { + const here = brightness(rgb(img, (y1 * w + x1) * 4)); + let zeroes = x1 === 0 || y1 === 0 || x1 === w - 1 || y1 === h - 1 ? 1 : 0; + let min = 0; + let max = 0; + let minX; + let minY; + let maxX; + let maxY; + for (let x = Math.max(x1 - 1, 0); x <= Math.min(x1 + 1, w - 1); x++) + for (let y = Math.max(y1 - 1, 0); y <= Math.min(y1 + 1, h - 1); y++) { + if (x === x1 && y === y1) continue; + const delta = here - brightness(rgb(img, (y * w + x) * 4)); + if (delta === 0) { + if (++zeroes > 2) return false; + } else if (delta < min) { + min = delta; + minX = x; + minY = y; + } else if (delta > max) { + max = delta; + maxX = x; + maxY = y; + } + } + if (min === 0 || max === 0) return false; + return ( + (hasManySiblings(px, minX, minY, w, h) && + hasManySiblings(otherPx, minX, minY, w, h)) || + (hasManySiblings(px, maxX, maxY, w, h) && + hasManySiblings(otherPx, maxX, maxY, w, h)) + ); +} + +/** + * @param {{width: number, height: number, data: Uint8ClampedArray}} actual + * @param {{width: number, height: number, data: Uint8ClampedArray}} expected + * @param {{threshold?: number, maxSolidPixels?: number, maxMismatchedPixels?: number, diff?: Uint8ClampedArray}} [options] + * `diff`, when given, is filled with an RGBA image of the differences: + * mismatches red, excused antialiasing yellow. + * @returns {{match: boolean, diffPixels: number, diffFraction: number, solidPixels: number, mismatchedPixels: number, reason?: string}} + */ +export function compareImages(actual, expected, options = {}) { + const { threshold, maxSolidPixels, maxMismatchedPixels } = { + ...VISUAL_TOLERANCE, + ...options, + }; + if (actual.width !== expected.width || actual.height !== expected.height) + return { + match: false, + diffPixels: NaN, + diffFraction: 1, + solidPixels: NaN, + mismatchedPixels: NaN, + reason: `size ${actual.width}×${actual.height}, reference ${expected.width}×${expected.height}`, + }; + const { width: w, height: h } = actual; + // Whole pixels compare as 32-bit words; data that does not start on a + // word (a sliced Node buffer) is copied first. + const aligned = (d) => + d.byteOffset % 4 === 0 ? d : new Uint8ClampedArray(d); + const a = aligned(actual.data); + const b = aligned(expected.data); + const aPx = new Uint32Array(a.buffer, a.byteOffset, w * h); + const bPx = new Uint32Array(b.buffer, b.byteOffset, w * h); + const limit = MAX_YIQ_DELTA * threshold * threshold; + const diff = options.diff; + const differing = new Uint8Array(w * h); + let diffPixels = 0; + let mismatchedPixels = 0; + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const k = y * w + x; + const p = k * 4; + const differs = + aPx[k] !== bPx[k] && yiqDelta(rgb(a, p), rgb(b, p)) > limit; + let mismatch = false; + if (differs) { + differing[k] = 1; + diffPixels += 1; + mismatch = + !antialiased(a, aPx, bPx, x, y, w, h) && + !antialiased(b, bPx, aPx, x, y, w, h); + if (mismatch) mismatchedPixels += 1; + } + if (diff) { + // Differences over a faded copy of the reference. + const faded = 40 + over(b[p + 1], b[p + 3]) * 0.25; + diff[p] = differs ? 255 : faded; + diff[p + 1] = differs ? (mismatch ? 0 : 220) : faded; + diff[p + 2] = differs ? 0 : faded; + diff[p + 3] = 255; + } + } + // Solid: a differing pixel whose four neighbours differ too. + let solidPixels = 0; + for (let y = 1; y < h - 1; y++) + for (let x = 1; x < w - 1; x++) { + const k = y * w + x; + if ( + differing[k] && + differing[k - 1] && + differing[k + 1] && + differing[k - w] && + differing[k + w] + ) + solidPixels += 1; + } + return { + match: + solidPixels <= maxSolidPixels && mismatchedPixels <= maxMismatchedPixels, + diffPixels, + diffFraction: diffPixels / (w * h), + solidPixels, + mismatchedPixels, + }; +} + +/** The file a view's reference image is stored under. */ +export const referenceName = (sceneId, viewName) => + `${sceneId}-${viewName}.png`; + +/** + * Draws a scene, then each of its views once the renderer has settled. + * @param {object} renderer + * @param {object} scene One of `REFERENCE_SCENES`. + * @param {{toEcef: Function, assets?: object}} ctx + * @param {(view: {name: string}, capture: Blob, settle: {state: string, blockers: string[]}) => void|Promise} onView + * `settle` is what `whenSettled` answered; a capture that did not settle + * is no reference and no pass. + */ +export async function drawScene(renderer, scene, ctx, onView) { + const dispose = await scene.build(renderer, ctx); + try { + for (const view of scene.views) { + renderer.camera.setView(view.view); + const settle = await renderer.whenSettled({ timeoutMs: 20000 }); + await onView(view, await renderer.capture({ type: 'png' }), settle); + } + } finally { + dispose(); + } +} diff --git a/src/render/contract/camera.d.ts b/src/render/contract/camera.d.ts new file mode 100644 index 000000000..9656320f1 --- /dev/null +++ b/src/render/contract/camera.d.ts @@ -0,0 +1,247 @@ +/** + * The camera. It belongs to the renderer: Core asks for poses, flights and + * follows, and every renderer reproduces the behaviour below exactly. + * A renderer whose engine has no such camera of its own builds this port + * on the shared GEV camera controller (the stub's camera is its seed), so + * flights, `lookAt` and follows are written once, not per engine. + * + * Behaviour every renderer keeps (Core depends on each one): + * - Camera input is suspended during a flight, by a hold: a flight ending + * gives back only its own hold, never another owner's. + * - `lookAt` orbits the target (the user can still rotate and zoom about it); + * `release` returns to the world frame. + * - `changed` fires when the view has moved by more than the listener's own + * `threshold` (default 0.5) since it last fired, measured per listener: + * heading and roll each by their change over π since they last fired; the + * view by the larger of the direction's turn over half the vertical field + * of view and the eye's travel over its height, since the view last + * fired. A frame can fire it twice, once for each measure. + * - `moveEnd` fires 500 ms after the last change, and never while following. + * - A flight's `onCancel` and `onComplete` run synchronously, inside the call + * or frame that ends it. + * - Axes follow the usual convention: heading clockwise from north, pitch + * negative looking down, roll about the view direction; local frames are + * east-north-up. + */ +import type { + Ecef, + GeoRect, + HeadingPitchRange, + HeadingPitchRoll, + Off, + Position, + Release, + Sphere, + Vec3, +} from './common'; + +export interface CameraPose { + lon: number; + lat: number; + /** Metres above the ellipsoid. */ + height: number; + heading: number; + pitch: number; + roll: number; + position: Ecef; + direction: Vec3; + up: Vec3; + right: Vec3; + /** Vertical field of view, radians. */ + fovy: number; + aspectRatio: number; + near: number; + far: number; + /** False while `lookAt` has the camera in a target's frame. */ + frameIsIdentity: boolean; +} + +export type Orientation = + Partial | { direction: Vec3; up: Vec3 }; + +/** + * A position, or a rectangle. For a rectangle the eye stands on the surface + * normal at the geodesic midpoint of its central meridian (latitude 0 when it + * spans nearly pole to pole), at the least distance that keeps on screen its + * corners, its edge centres and, when it crosses the equator, its equator + * points. The orientation is then resolved as for any position. + */ +export type Destination = Position | { rect: GeoRect }; + +/** + * Omitted orientation angles are heading 0, pitch −π/2 (straight down) and + * roll 0, not the current ones; an omitted destination keeps the position. + */ +export interface ViewSpec { + destination?: Destination; + orientation?: Orientation; +} + +export type Easing = + 'linear' | 'quadraticInOut' | 'cubicInOut' | 'cubicOut' | 'quinticInOut'; + +/** + * A flight's omitted orientation is the same as `setView`'s. Its omitted + * duration is `min(3, ceil(distance / 1000 km) + 2)` seconds, the distance + * measured from the eye's position in its current frame (the `lookAt` frame + * when one is active). A flight with a duration of 0 or less, or one whose + * eye (its position in its current frame, as above) already stands at the + * destination with the destination's angles (to 1e-10, relative), + * completes inside the call: it places the eye, runs `onComplete`, raises + * `flightEnd` and resolves `done`, and no frame is needed. Starting a + * flight cancels the one in progress. + * + * The path, at eased time `s` (0..1): longitude and latitude linear in `s` + * the short way round; heading, pitch and roll linear in `s` the short way + * round; height on an arc to `maxHeight` when both ends are below it, + * otherwise linear. The reference traces (`conformance/goldens/camera.json`) + * pin the arc and the omitted `maxHeight`. + */ +export interface FlightOptions { + orientation?: Orientation; + durationS?: number; + /** + * Omitted: `cubicOut` when descending from above 11.5 km, otherwise + * `quinticInOut`. + */ + easing?: Easing; + /** Top of the height arc, metres. Omitted: a fifth of what fits both ends on screen. */ + maxHeight?: number; + /** When the arc tops this height, pitch turns straight down mid-flight. */ + pitchAdjustHeight?: number; + /** End in the world frame even when a target frame is active. */ + endFrame?: 'world'; + onComplete?: () => void; + onCancel?: () => void; +} + +export interface Flight { + done: Promise<'complete' | 'cancelled'>; + cancel(): void; +} + +/** Something the camera follows; Core owns it and the renderer reads it each frame. */ +export interface FollowTarget { + /** Stable key, e.g. `'flights:a1b2c3'`. */ + key: string; + /** Where the target is at scene time `t` (epoch ms), or null when unknown. */ + position(t: number): Ecef | null; + /** Where it is drawn, when that differs from `position` (a lifted icon). */ + visualPosition?(t: number): Ecef | null; + /** + * The drawn item this target is, when it is one. A renderer that moves + * items itself (`motionSegments`) follows where it draws the item, so the + * eye and the icon never part; `position` stays the answer elsewhere. + */ + item?: { set: string; id: string }; + /** + * `enu`: offset in the target's east-north-up frame; + * `autodetect`: an orbit frame from its motion, north-up when still + * (`followAutodetect`). + */ + frame: 'enu' | 'autodetect'; + /** Offset of the eye from the target, metres in `frame`. */ + viewFrom: Vec3 | ((t: number) => Vec3); + minRange?: number; + boundingRadius?: number; +} + +/** + * A camera command, as an ownership hook sees it before it runs. An owner + * that issues commands itself (a ground guard) recognises its own by + * issuing them inside its own call; any other command is someone else's. + */ +export interface CameraCommand { + kind: + | 'setView' + | 'flyTo' + | 'flyToBounds' + | 'lookAt' + | 'release' + | 'follow' + | 'cancelFlight'; +} + +export interface CameraControls { + /** False while any hold is out. */ + readonly enabled: boolean; + /** Suspend user camera input until released. Holds are counted. */ + suspend(reason: string): Release; + enableTilt: boolean; + enableRotate: boolean; + enableZoom: boolean; + minDistance: number; + maxDistance: number; + inertiaSpin: number; + inertiaTranslate: number; + inertiaZoom: number; + /** What the eye cannot pass below. */ + collision: 'none' | 'terrain' | 'tiles' | 'both'; + zoomFactor: number; + /** Pitch the user may tilt between, radians (−π/2 straight down). */ + pitchRange: [min: number, max: number]; + /** Input bindings that zoom. */ + zoomInputs: Array<'wheel' | 'pinch' | 'rightDrag' | 'ctrlWheel'>; +} + +export type CameraEvent = + 'changed' | 'moveStart' | 'moveEnd' | 'followChanged' | 'flightEnd'; + +export interface CameraPort { + pose(): CameraPose; + setView(view: ViewSpec): void; + setFov(fov: { vertical: number }): void; + /** + * The geodetic rectangle in view: the bounds of the canvas corners on the + * ellipsoid, a corner that misses it taken where the horizon is, and the + * full longitude span to the pole when the view goes over one. The whole + * globe when fewer than two corners land; null only when the globe is + * wholly outside the view. + */ + viewRectangle(): GeoRect | null; + flyTo(destination: Destination, options?: FlightOptions): Flight; + /** + * Fly to look at a sphere from `offset` in its east-north-up frame + * (default heading 0, pitch −π/4; a range of 0 fits the sphere in view). + */ + flyToBounds( + sphere: Sphere, + options?: { offset?: HeadingPitchRange; durationS?: number } & Pick< + FlightOptions, + 'onComplete' | 'onCancel' + >, + ): Flight; + cancelFlight(): void; + readonly flying: boolean; + /** Orbit `target` at `offset`; user input orbits it too. */ + lookAt(target: Ecef, offset: HeadingPitchRange): void; + /** Leave `lookAt` for the world frame. */ + release(): void; + move( + direction: 'forward' | 'back' | 'left' | 'right' | 'up' | 'down', + metres: number, + ): void; + look(direction: 'left' | 'right' | 'up' | 'down', radians: number): void; + rotateAround(axis: Vec3, radians: number): void; + zoom(metres: number): void; + follow(target: FollowTarget | null): void; + readonly followed: FollowTarget | null; + /** Whether the follow has placed its first frame. */ + readonly followFramed: boolean; + /** The eye's offset from the followed target, in its frame. */ + readonly followOffset: { + get(): Vec3 | null; + set(offset: Vec3): void; + }; + readonly controls: CameraControls; + on(event: CameraEvent, cb: () => void, options?: { threshold?: number }): Off; + /** Runs before each camera command, so an owner can yield to another. */ + onCommand(cb: (command: CameraCommand) => void): Off; + /** + * Column-major, right-handed: `view` takes ECEF to eye space (absolute, + * not eye-relative); `projection` takes eye space to clip space with depth + * in [-1, 1]. A renderer with an infinite far plane returns the + * infinite-far form, and `pose().far` is `Infinity`. + */ + matrices(): { view: Float64Array; projection: Float64Array }; +} diff --git a/src/render/contract/common.d.ts b/src/render/contract/common.d.ts new file mode 100644 index 000000000..93bf8ed34 --- /dev/null +++ b/src/render/contract/common.d.ts @@ -0,0 +1,200 @@ +/** + * Shared value types of the renderer contract. + * + * Only plain numbers, strings and typed arrays cross the contract: no engine + * object ever does. Positions are f64 Earth-centred, Earth-fixed metres + * (`Ecef`) or geodetic degrees and metres (`LonLatH`). Angles are radians + * unless a name says degrees. Times are epoch milliseconds. Screen positions + * are CSS pixels from the top left of the renderer's canvas. + */ + +/** Earth-centred, Earth-fixed position in metres (WGS84). */ +export interface Ecef { + x: number; + y: number; + z: number; +} + +/** A direction or offset in metres; the frame is stated where it is used. */ +export interface Vec3 { + x: number; + y: number; + z: number; +} + +/** Geodetic position: degrees, and metres above the WGS84 ellipsoid. */ +export interface LonLatH { + lon: number; + lat: number; + height?: number; +} + +/** Geodetic position without a height. */ +export interface LonLat { + lon: number; + lat: number; +} + +/** Either position form. A value with `lon` and `lat` is geodetic. */ +export type Position = Ecef | LonLatH; + +/** CSS pixels from the canvas's top left. */ +export interface ScreenXY { + x: number; + y: number; +} + +/** A screen rectangle in CSS pixels. */ +export interface ScreenRect { + x: number; + y: number; + width: number; + height: number; +} + +/** A geodetic rectangle in degrees. */ +export interface GeoRect { + west: number; + south: number; + east: number; + north: number; +} + +/** A ray in ECEF metres; `direction` is a unit vector. */ +export interface Ray { + origin: Ecef; + direction: Vec3; +} + +/** A bounding sphere. */ +export interface Sphere { + center: Ecef; + radius: number; +} + +export interface HeadingPitchRoll { + heading: number; + pitch: number; + roll: number; +} + +export interface HeadingPitchRange { + heading: number; + pitch: number; + range: number; +} + +/** A quaternion `(x, y, z, w)`, unit length. */ +export interface Quaternion { + x: number; + y: number; + z: number; + w: number; +} + +/** A CSS colour string, or linear `[r, g, b, a]` in 0..1 (`a` defaults to 1). */ +export type Color = string | readonly [number, number, number, number?]; + +/** + * A distance curve: `nearValue` at `near` metres from the eye, `farValue` + * at `far`, linear in between and clamped outside. + */ +export interface NearFar { + near: number; + nearValue: number; + far: number; + farValue: number; +} + +/** + * Unsubscribes. Calling it twice is harmless. + * + * Every `on(...)` in the contract keeps these rules: listeners run in the + * order they subscribed; one removed while an event is being delivered does + * not run for it if it has not yet; one added during delivery first runs for + * the next event; a listener that throws is reported (`Renderer` `error`, + * or the platform's `reportError` when nothing listens for errors) and the + * others, and the frame, still run. + */ +export type Off = () => void; + +/** Gives back a hold. Calling it twice is harmless. */ +export type Release = () => void; + +/** A handle on something the renderer draws. */ +export interface Handle { + show: boolean; + dispose(): void; +} + +/** Opaque data Core attaches to a drawn item and gets back on a pick. */ +export type Meta = unknown; + +/** + * Optional features. A renderer lists the ones it has in + * `Renderer.capabilities`. A member's documentation names the capability + * it depends on, in backticks, first: + * - on an optional member (`?`), the member exists only with the capability; + * - on a required member, the member always exists, and without the + * capability it answers null, an empty list or a status saying so (a + * query asked for depth on a renderer without `pickPosition` returns null). + * Options guarded by a capability are ignored without it. Quality settings + * are not capabilities: `QualityPort.knobs()` lists the ones a renderer has. + */ +export type Capability = + /** Google photorealistic 3D tiles as the base. */ + | 'photoreal' + /** A vector basemap (`BaseSpec.vector`), with its own labels. */ + | 'vectorBase' + /** Heights from the finest data available, not only what is drawn. */ + | 'finestHeights' + /** Height terrain under the globe. */ + | 'terrain' + /** Atmosphere scattering applied to drawn models. */ + | 'modelAtmosphere' + /** Imagery draped onto 3D tiles as well as the globe. */ + | 'imageryOnTiles' + /** A screen-split comparison between imagery layers. */ + | 'splitSwipe' + /** Timed imagery frames with hidden prefetch. */ + | 'imageryFrames' + /** Wind-relief and similar shading of the globe surface. */ + | 'surfaceShading' + /** A screen-space sector modulation pass (a sweeping sonar band). */ + | 'screenModulation' + /** GPU frame timing through timer queries. */ + | 'gpuTiming' + /** Depth picks against tiles and terrain. */ + | 'pickPosition' + /** Height sampling against tiles and terrain. */ + | 'sampleHeight' + /** Ray casts against tiles and terrain. */ + | 'rayCast' + /** Columnar draw-set updates with dirty ranges. */ + | 'columns' + /** Icon motion segments evaluated on the renderer's clock. */ + | 'motionSegments' + /** glTF models. */ + | 'models' + /** Image, video and canvas planes. */ + | 'quads' + /** Custom meshes in a local east-north-up frame. */ + | 'meshes' + /** Wind streamlines, scalar rasters and raised raster shells. */ + | 'fields' + /** Stars and orbit rings in an inertial frame. */ + | 'celestial' + /** Agents moving along polylines (traffic flow). */ + | 'agents' + /** Screen-space clustering of icons. */ + | 'cluster' + /** Post-processing styles. */ + | 'styles' + /** A bloom pass. */ + | 'bloom' + /** A sharpen pass. */ + | 'sharpen' + /** Following with an automatically chosen frame (orbit or north-up). */ + | 'followAutodetect' + /** A built-in quality benchmark. */ + | 'benchmark'; diff --git a/src/render/contract/contract.test.mjs b/src/render/contract/contract.test.mjs new file mode 100644 index 000000000..167646543 --- /dev/null +++ b/src/render/contract/contract.test.mjs @@ -0,0 +1,168 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { + CAPABILITIES, + CONTRACT_VERSION, + FRAME_STEPS, + PORTS, + QUALITY_KEYS, + checkPortShape, + checkRendererShape, +} from './index.js'; + +const DIR = path.dirname(fileURLToPath(import.meta.url)); +const INDEX = path.join(DIR, 'index.d.ts'); + +const program = ts.createProgram([INDEX], { + strict: true, + noEmit: true, + target: ts.ScriptTarget.ES2022, + lib: ['lib.es2022.d.ts', 'lib.dom.d.ts'], + skipLibCheck: false, + types: [], +}); +const checker = program.getTypeChecker(); +const contractFiles = program + .getSourceFiles() + .filter((file) => path.dirname(file.fileName) === DIR); + +/** The named interface or type alias declared in the contract files. */ +function declared(name) { + for (const file of contractFiles) + for (const statement of file.statements) + if ( + (ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement)) && + statement.name.text === name + ) + return statement; + throw new Error(`${name} is not declared in the contract`); +} + +const PORT_INTERFACES = { + renderer: 'Renderer', + frame: 'FramePort', + globe: 'GlobePort', + draw: 'DrawPort', + camera: 'CameraPort', + query: 'QueryPort', + input: 'InputPort', + effects: 'EffectsPort', + credits: 'CreditsPort', + quality: 'QualityPort', + drawSet: 'DrawSet', + imageryLayer: 'ImageryLayer', + flight: 'Flight', + lease: 'Lease', +}; +const PORT_NAMES = new Set(Object.values(PORT_INTERFACES)); + +/** Whether a property's type is one of the contract's own object shapes to list member by member. */ +function nestedShape(type) { + if (type.isUnion() || type.getCallSignatures().length) return null; + const symbol = type.aliasSymbol ?? type.getSymbol(); + const declaration = symbol?.declarations?.[0]; + if (!declaration) return null; + const inContract = path.dirname(declaration.getSourceFile().fileName) === DIR; + if (!inContract) return null; + if (ts.isTypeLiteralNode(declaration)) return type; + if (ts.isInterfaceDeclaration(declaration)) + return PORT_NAMES.has(declaration.name.text) ? null : type; + return null; +} + +/** `[member, optional]` for every member, nested shapes dotted. */ +function members(type, prefix = '') { + const out = []; + for (const property of checker.getPropertiesOfType(type)) { + const name = `${prefix}${property.name}`; + const optional = Boolean(property.flags & ts.SymbolFlags.Optional); + out.push([name, optional]); + const declaration = property.valueDeclaration ?? property.declarations?.[0]; + const propertyType = checker.getTypeOfSymbolAtLocation( + property, + declaration, + ); + const nested = nestedShape(propertyType); + if (nested) out.push(...members(nested, `${name}.`)); + } + return out; +} + +function unionLiterals(name) { + const type = checker.getTypeAtLocation(declared(name).name); + return (type.isUnion() ? type.types : [type]).map((t) => t.value).sort(); +} + +test('the contract type-checks under strict TypeScript', () => { + const diagnostics = ts.getPreEmitDiagnostics(program).map((d) => { + const where = d.file + ? `${path.relative(DIR, d.file.fileName)}:${d.file.getLineAndCharacterOfPosition(d.start).line + 1}` + : ''; + return `${where} ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`; + }); + assert.deepEqual(diagnostics, []); +}); + +test('PORTS lists every member of every port, required exactly when the type requires it', () => { + for (const [port, interfaceName] of Object.entries(PORT_INTERFACES)) { + const type = checker.getTypeAtLocation(declared(interfaceName).name); + const typed = Object.fromEntries(members(type)); + const described = PORTS[port]; + assert.deepEqual( + Object.keys(described).sort(), + Object.keys(typed).sort(), + `${port} members`, + ); + for (const [member, capability] of Object.entries(described)) + assert.equal( + capability !== null, + typed[member], + `${port}.${member}: optional in the type exactly when a capability adds it`, + ); + } +}); + +test('the capability and frame-step lists are the types’ own', () => { + assert.deepEqual([...CAPABILITIES].sort(), unionLiterals('Capability')); + assert.deepEqual([...FRAME_STEPS].sort(), unionLiterals('FrameStep')); + for (const port of Object.values(PORTS)) + for (const capability of Object.values(port)) + if (capability !== null) assert.ok(CAPABILITIES.includes(capability)); + assert.equal(CONTRACT_VERSION, '0'); + const settings = checker.getTypeAtLocation(declared('QualitySettings').name); + assert.deepEqual( + [...QUALITY_KEYS].sort(), + checker + .getPropertiesOfType(settings) + .map((p) => p.name) + .sort(), + ); +}); + +test('the shape check names what is missing and what a capability does not allow', () => { + assert.deepEqual(checkRendererShape(null), ['renderer is not an object']); + assert.deepEqual(checkRendererShape({}), [ + 'renderer.capabilities is not a Set', + ]); + const problems = checkRendererShape({ + capabilities: new Set(['warp']), + contractVersion: '0', + }); + assert.ok(problems.includes('unknown capability warp')); + assert.ok(problems.includes('renderer.frame is missing')); + const set = Object.fromEntries( + Object.keys(PORTS.drawSet).map((member) => [member, () => {}]), + ); + assert.deepEqual(checkPortShape(set, 'drawSet', new Set()), [ + 'drawSet.setColumns is present without columns', + ]); + delete set.setColumns; + assert.deepEqual(checkPortShape(set, 'drawSet', new Set(['columns'])), [ + 'drawSet.setColumns is missing with columns', + ]); + assert.deepEqual(checkPortShape(set, 'drawSet', new Set()), []); +}); diff --git a/src/render/contract/credits.d.ts b/src/render/contract/credits.d.ts new file mode 100644 index 000000000..7cdb6c0ec --- /dev/null +++ b/src/render/contract/credits.d.ts @@ -0,0 +1,21 @@ +/** + * Attribution. The renderer reports every credit it owes (its own, each + * base layer's, and per-tile credits as tiles load); Core renders the + * credit DOM, so every renderer's credits look the same. + */ +import type { Off } from './common'; + +export interface Credit { + /** Sanitised HTML: text, links and images only. */ + html: string; + /** Shown on the map itself, rather than in the expanded list. */ + inline?: boolean; +} + +export interface CreditsPort { + /** Add a credit Core owes (a layer's source). */ + addStatic(credit: Credit): Off; + /** Every credit owed now, in display order. */ + list(): Credit[]; + on(event: 'change', cb: (credits: Credit[]) => void): Off; +} diff --git a/src/render/contract/draw.d.ts b/src/render/contract/draw.d.ts new file mode 100644 index 000000000..4a1827983 --- /dev/null +++ b/src/render/contract/draw.d.ts @@ -0,0 +1,477 @@ +/** + * Things to draw: batched sets of items keyed by string id. + * + * Core owns every record; a set is only what Core pushed. Each item may carry + * an opaque `meta` that comes back on a pick (`Hit.meta`). There is no world + * text here: every label goes through Core's screen-space label overlay, + * which only needs `query.project`/`projectMany` and `query.isOccluded`. + * + * Moving icons follow a motion segment the renderer evaluates on its own + * clock (`motionSegments`), so Core writes a row only when its motion or + * style changes, not every frame. + */ +import type { + Color, + Ecef, + GeoRect, + Handle, + Meta, + NearFar, + Off, + Position, + Quaternion, + ScreenXY, + Vec3, +} from './common'; + +/** How an item's height is read. */ +export type HeightMode = 'absolute' | 'clampGround' | 'relativeGround'; + +/** What `clampGround` / `relativeGround` clamp to. */ +export type ClampTarget = 'terrain' | 'tiles' | 'both'; + +export interface ClusterSpec { + /** Screen distance under which icons merge. */ + pixelRange: number; + /** Fewest icons that form a cluster. */ + minimumSize: number; + /** Draws a cluster's icon from its member count. */ + icon?: (count: number) => ImageRef; +} + +/** Options of a whole set; the mutable ones can change later through `setOptions`. */ +export interface SetSpec { + /** + * Unique among the renderer's live sets (making a second set with a live + * id throws; a disposed set's id can be used again); returned as `Hit.set`. + */ + id: string; + /** Hide items beyond the ellipsoid's horizon, for sets drawn without depth test. */ + horizonCull?: boolean; + /** `cluster`: screen-space clustering (icons only). */ + cluster?: ClusterSpec | null; + blend?: 'opaque' | 'translucent' | 'additive'; + /** Draw order between sets that do not depth-test against each other. */ + drawOrder?: number; + /** `false`: the set never takes part in picks. */ + pickable?: boolean; + /** Height queries see through this set. */ + excludeFromHeightQueries?: boolean; + /** What clamped items clamp to. */ + clampTo?: ClampTarget; + /** + * `motionSegments`: the clock motion segments are evaluated on. + * `live` (the default) is wall time minus `delayMs`, as a live feed is + * drawn a little behind real time; `scene` is the frame port's scene time. + */ + motionClock?: { kind: 'live'; delayMs?: number } | { kind: 'scene' }; + show?: boolean; +} + +/** Common to items placed at one position. */ +export interface Placed { + id: string; + pos: Position; + height?: HeightMode; + meta?: Meta; + show?: boolean; + /** `false`: this item never takes part in picks. */ + pickable?: boolean; + scaleByDistance?: NearFar; + fadeByDistance?: NearFar; + /** Shown only between these eye distances, metres. */ + displayRange?: [near: number, far: number]; + /** Draw over depth within this many metres of the eye (`Infinity`: always). */ + disableDepthTest?: number; +} + +/** An image an icon draws: a URL, a slot in a shared atlas, or generated. */ +export type ImageRef = + | string + | { atlas: string; slot: string } + | { svg: string } + | { blob: Blob } + | { canvas: HTMLCanvasElement | OffscreenCanvas; key: string }; + +/** + * How an icon moves between updates, on its set's `motionClock`. Times are + * epoch ms. Sending an item a new segment replaces its old one, whatever the + * times (an earlier or equal `t0` included). + * - hold: stays at `pos`. + * - lerp: straight in space from `from` (at `t0`) to `to` (at `t1`), holding + * at both ends; the course runs from `course0Deg` by `courseSlopeDeg` + * across the segment. + * - arc: a constant-rate turn in the plane tangent to the ellipsoid at `fix` + * (its east and north axes), at the fix's height, from `fix` at `t0`, at + * `speedMps` along `trackDeg` turning at `turnRateDps` (under 1e-4 rad/s is + * straight). The elapsed time is clamped to `[minDtS, maxDtS]`; a negative + * `minDtS` runs the fix backward before `t0` (a warm-up), `maxDtS` is how + * long it coasts. + * `courseGlide` eases the displayed course onto the segment's at `rateDps`. + */ +export type MotionSegment = + | { kind: 'hold'; courseDeg?: number } + | { + kind: 'lerp'; + from: Ecef; + to: Ecef; + t0: number; + t1: number; + course0Deg?: number; + courseSlopeDeg?: number; + } + | { + kind: 'arc'; + fix: Ecef; + t0: number; + minDtS: number; + maxDtS: number; + speedMps: number; + trackDeg: number; + turnRateDps: number; + }; + +export interface CourseGlide { + fromCourseDeg: number; + t0: number; + /** Degrees per second; 0 or less holds `fromCourseDeg`. */ + rateDps: number; +} + +export interface IconItem extends Placed { + image: ImageRef; + /** Drawn size in CSS pixels. */ + size: [width: number, height: number]; + color?: Color; + alpha?: number; + scale?: number; + /** `motionSegments`: motion on the renderer's clock. */ + motion?: MotionSegment; + courseGlide?: CourseGlide; + /** Degrees clockwise from north. */ + course?: number; + /** + * `screen`: upright on screen, rotated by `rotation`; + * `course`: rotated on screen along `course` (or the motion's course); + * `axis`: rotated about `alignedAxis`. + */ + rotationMode?: 'screen' | 'course' | 'axis'; + /** Radians counter-clockwise on screen, for `rotationMode: 'screen'`. */ + rotation?: number; + /** ECEF axis for `rotationMode: 'axis'`. */ + alignedAxis?: Vec3; + pixelOffset?: ScreenXY; + /** Offset toward the eye, metres in eye space. */ + eyeOffset?: Vec3; + anchor?: 'center' | 'bottom' | 'top'; + /** Swap to `near` when drawn larger than `switchPx`. */ + imageLod?: { near: ImageRef; switchPx: number }; + /** A larger invisible pick target, CSS pixels. */ + pickProxy?: { size: [width: number, height: number] }; + /** Fade and shrink toward the globe's limb. */ + recede?: boolean; + /** Emphasis factor for a focused item, 1 = none. */ + focus?: number; +} + +export interface PointItem extends Placed { + pixelSize: number; + color: Color; + outlineColor?: Color; + outlineWidth?: number; +} + +export type LineStyle = + | { kind: 'solid'; color: Color } + | { kind: 'glow'; color: Color; power: number; taper?: number } + | { + kind: 'dash'; + color: Color; + gapColor?: Color; + length: number; + pattern16?: number; + } + | { kind: 'arrow'; color: Color } + | { kind: 'pattern'; image: ImageRef; repeat: number } + | { kind: 'flow'; color: Color; repeat: number; duty: number; speed: number }; + +export interface LineItem { + id: string; + /** ECEF positions, or packed `x, y, z` triples. */ + positions: Ecef[] | Float64Array; + widthPx: number; + /** `3d`: straight in space; `geodesic`: along the ellipsoid; `draped`: on the surface. */ + mode: '3d' | 'geodesic' | 'draped'; + clampTo?: ClampTarget; + style: LineStyle; + /** Colour where the line is behind the surface; `null` hides those parts. */ + depthFailColor?: Color | null; + /** Per-segment colours; changing them does not rebuild the line. */ + segmentColors?: Color[]; + segmentShow?: boolean[]; + /** Draw only the first fraction of the line, 0..1. */ + revealFraction?: number; + /** Positions are in a frame rotating with the sky (orbits). */ + frame?: 'fixed' | 'inertial'; + meta?: Meta; + show?: boolean; + pickable?: boolean; +} + +/** A shape's fill. */ +export type Fill = + | Color + | { image: ImageRef; repeat?: [number, number] } + | { preset: 'radialRim'; color: Color; rimColor: Color; rimWidth: number }; + +/** Declarative animation the renderer runs (no per-frame callbacks). */ +export interface Pulse { + property: 'radius' | 'alpha'; + from: number; + to: number; + periodMs: number; + /** Repeat count; omitted repeats forever. */ + repeat?: number; +} + +/** A radius that follows the camera's height above the ground. */ +export interface RadiusByCameraHeight { + byCameraHeight: { factor: number; min: number; max: number }; +} + +export type ShapeGeometry = + | { kind: 'polygon'; rings: Position[][]; perPositionHeight?: boolean } + | { + kind: 'ellipse'; + center: Position; + semiMajor: number | RadiusByCameraHeight; + semiMinor: number | RadiusByCameraHeight; + /** Radians counter-clockwise from north to the semi-major axis. */ + rotation?: number; + /** Only part of the ellipse, radians from north. */ + arc?: { start: number; end: number }; + } + /** `height`: metres above the ellipsoid, for a rectangle not clamped. */ + | { kind: 'rectangle'; rect: GeoRect; height?: number } + | { + kind: 'box'; + center: Position; + dimensions: Vec3; + orientation?: Quaternion; + } + | { + kind: 'wall'; + positions: Position[]; + minimumHeights?: number[]; + maximumHeights?: number[]; + }; + +export interface ShapeItem { + id: string; + geometry: ShapeGeometry; + fill?: Fill | null; + outline?: Color | null; + outlineWidth?: number; + /** Draw the outline only. Clamped outlines are drawn as draped lines. */ + outlineOnly?: boolean; + /** Height of the extruded face, metres above the ellipsoid (not above the shape). */ + extrudeHeight?: number; + height?: 'absolute' | 'clampGround'; + clampTo?: ClampTarget; + /** Classify what is inside the volume (tint the tiles it covers). */ + classifyVolume?: boolean; + /** Pull toward the eye, metres, to win depth against coplanar surfaces. */ + depthBias?: number; + animate?: Pulse; + meta?: Meta; + show?: boolean; + pickable?: boolean; +} + +/** + * A glTF model. Every renderer draws one the same way: glTF is Y-up and is + * turned to Z-up before `orientation` applies; `minPixelSize` is measured at + * the model's bounding-sphere distance from the eye; `colorBlend` mixes the + * tint into the shaded colour and never changes the model's alpha. + */ +export interface ModelItem extends Placed { + url: string; + orientation: { heading: number; pitch: number; roll: number }; + scale?: number; + minPixelSize?: number; + maxPixelSize?: number; + maxScale?: number; + /** Can change live. */ + shading?: 'pbr' | 'unlit' | 'thermal'; + tint?: Color; + colorBlend?: { mode: 'highlight' | 'replace' | 'mix'; amount?: number }; + environmentMap?: boolean; +} + +/** A plane showing an image, a video or a canvas. */ +export interface QuadItem { + id: string; + center: Position; + orientation: Quaternion; + /** Metres. */ + size: [width: number, height: number]; + source: + | { image: ImageRef } + | { video: HTMLVideoElement } + | { canvas: HTMLCanvasElement | OffscreenCanvas }; + alpha?: number; + outline?: { color: Color; widthPx: number } | null; + meta?: Meta; + show?: boolean; + pickable?: boolean; +} + +/** Custom geometry in a local east-north-up frame at `origin`. */ +export interface MeshItem { + id: string; + origin: Ecef; + /** Packed `east, north, up` metres. */ + positions: Float32Array; + indices: Uint16Array | Uint32Array; + color: Color; + /** Unlit, with back faces drawn. */ + flat?: boolean; + meta?: Meta; + show?: boolean; + pickable?: boolean; +} + +export type FieldSpec = + | { + kind: 'windStreamlines'; + id: string; + /** Row-major `u` and `v` in m/s on a `width × height` grid over `rectangle`. */ + u: Float32Array; + v: Float32Array; + width: number; + height: number; + rectangle: GeoRect; + pathBudget: number; + steps: number; + style: { color: Color; widthPx: number; speedFactor?: number }; + /** Draw height, metres; omitted drapes on the surface. */ + heightM?: number; + } + | { + kind: 'scalarRaster'; + id: string; + values: Float32Array; + width: number; + height: number; + rectangle: GeoRect; + /** Colour ramp: value stops in ascending order. */ + ramp: Array<{ value: number; color: Color }>; + alpha?: number; + } + | { + kind: 'rasterShell'; + id: string; + /** A raster on a sphere raised above the ground. */ + image: ImageRef; + rectangle: GeoRect; + heightM: number; + liftM?: number; + /** Full detail inside this rectangle only. */ + detailWindow?: GeoRect; + alpha?: number; + }; + +export interface FieldHandle extends Handle { + update(patch: Partial): void; +} + +export type CelestialSpec = + | { + kind: 'stars'; + id: string; + catalogue: Float32Array; + magnitudeLimit?: number; + } + | { + kind: 'orbitRings'; + id: string; + /** Each ring: packed `x, y, z` in the inertial frame, metres. */ + rings: Float64Array[]; + color: Color; + widthPx: number; + }; + +/** `agents`: dots moving along polylines (traffic flow). */ +export interface AgentPathItem { + id: string; + positions: Ecef[] | Float64Array; + /** Agents on this path. */ + count: number; + speedMps: number; + /** 0 flows freely; 1 stopped; between creeps. */ + congestion?: number; + color: Color; + pixelSize: number; + fadeByDistance?: NearFar; + meta?: Meta; +} + +/** + * Columnar update for large sets (`columns`). Column `i` of each array is + * row `ids[i]`. `dirty` limits the upload to these index runs. + */ +export interface Columns { + ids: readonly string[]; + /** Packed ECEF `x, y, z`. */ + positions?: Float64Array; + /** Degrees clockwise from north. */ + courses?: Float32Array; + shown?: Uint8Array; + scales?: Float32Array; + alphas?: Float32Array; + /** Packed `r, g, b, a`, 0..255. */ + colors?: Uint8Array; + meta?: readonly Meta[]; + dirty?: ReadonlyArray<{ start: number; end: number }>; +} + +export interface DrawSet { + readonly id: string; + /** Replace every item. */ + set(items: T[]): void; + /** Add or replace items by id. */ + upsert(items: T[]): void; + remove(ids: string[]): void; + clear(): void; + /** `columns`. */ + setColumns?(columns: Columns): void; + /** Change the mutable set options. */ + setOptions(options: Partial>): void; + /** Items in the set. */ + size(): number; + show: boolean; + /** `models`: a model has loaded and is posed. */ + on(event: 'ready', cb: (id: string) => void): Off; + dispose(): void; +} + +export interface DrawPort { + icons(spec: SetSpec): DrawSet; + points(spec: SetSpec): DrawSet; + lines(spec: SetSpec): DrawSet; + shapes(spec: SetSpec): DrawSet; + /** `models`. */ + models?(spec: SetSpec): DrawSet; + /** `quads`. */ + quads?(spec: SetSpec): DrawSet; + /** `meshes`. */ + meshes?(spec: SetSpec): DrawSet; + /** `fields`. */ + field?(spec: FieldSpec): FieldHandle; + /** `celestial`: drawn in an inertial frame rotated by sidereal time. */ + celestial?(spec: CelestialSpec): Handle; + /** `agents`. */ + agents?(spec: SetSpec): DrawSet; + /** Register an atlas of images icons refer to by slot. */ + atlas(id: string, images: Record): void; +} diff --git a/src/render/contract/effects.d.ts b/src/render/contract/effects.d.ts new file mode 100644 index 000000000..ccfbd3c34 --- /dev/null +++ b/src/render/contract/effects.d.ts @@ -0,0 +1,48 @@ +/** + * Full-screen effects: visual styles, bloom, sharpen and screen modulation. + * Each style is a plain full-screen pass over the frame's colour, so every + * renderer can provide the same set. + */ +import type { Off } from './common'; + +export interface StyleDescriptor { + id: string; + /** Parameters and their ranges. */ + params: Array<{ key: string; min: number; max: number; default: number }>; + /** Animates, so it needs frames while on. */ + animated: boolean; +} + +/** A sweeping sector drawn over the frame (a sonar band), or null. */ +export interface ModulationSector { + /** Centre of the sweep, CSS pixels. */ + center: { x: number; y: number }; + /** Current angle, radians clockwise from up. */ + angle: number; + /** Width of the band, radians. */ + width: number; + /** 0..1. */ + strength: number; + color: string; +} + +export interface EffectsDiagnostics { + style: string | null; + passes: string[]; +} + +export interface EffectsPort { + /** `styles`. */ + styles(): StyleDescriptor[]; + setStyle(id: string | null, params?: Record): void; + setStyleIntensity(id: string, intensity: number): void; + setStyleParams(id: string, params: Record): void; + /** `bloom`. */ + bloom(settings: { enabled: boolean; strength?: number }): void; + /** `sharpen`. */ + sharpen(settings: { enabled: boolean; amount?: number }): void; + /** `screenModulation`. */ + screenModulation?(sector: ModulationSector | null): void; + diagnostics(): EffectsDiagnostics; + on(event: 'change', cb: (diagnostics: EffectsDiagnostics) => void): Off; +} diff --git a/src/render/contract/frame.d.ts b/src/render/contract/frame.d.ts new file mode 100644 index 000000000..a407347ba --- /dev/null +++ b/src/render/contract/frame.d.ts @@ -0,0 +1,76 @@ +/** + * The frame loop, render on demand and scene time. + * + * A renderer draws on demand: a frame is drawn when something asks for one + * (`requestRender`), while any continuous hold is out, or every frame on an + * engine that cannot idle (it then reports `mode()` as `'continuous'`). + * + * One frame runs in this order, and every renderer keeps it: + * camera flight → input → camera `changed` → follow → `preUpdate` → + * move detection (`moveStart` / `moveEnd`) → `preRender` → render → + * `postRender`. `preUpdate` fires on every frame the loop ticks, including + * frames that end up drawing nothing, so owners that write the camera there + * (a cockpit, a follow clamp) see each one. + */ +import type { Off, Release } from './common'; + +export type FramePhase = 'preUpdate' | 'preRender' | 'postRender'; + +/** The order the phases (and the steps between them) run in one frame. */ +export type FrameStep = + | 'flight' + | 'input' + | 'changed' + | 'follow' + | 'preUpdate' + | 'moveDetection' + | 'preRender' + | 'render' + | 'postRender'; + +export interface FrameTime { + /** Frames ticked since creation. */ + frame: number; + /** Scene time, epoch ms: what sun, stars and lighting show. */ + time: number; + /** The loop's clock, ms (virtual under a test clock). */ + now: number; + /** Seconds since the previous tick. */ + dt: number; + /** Whether this tick draws. */ + rendering: boolean; +} + +export interface FrameDiagnostics { + mode: 'demand' | 'continuous'; + frames: number; + rendered: number; + /** Reasons given to `requestRender` since the last frame drawn. */ + pendingReasons: string[]; + /** Reasons of the holds now out. */ + holds: string[]; + suspended: boolean; + targetFps: number; +} + +export interface FramePort { + on(phase: FramePhase, cb: (t: FrameTime) => void): Off; + /** Ask for one frame. The reason shows in diagnostics and signals. */ + requestRender(reason: string): void; + /** Keep drawing every frame until released. Holds are counted. */ + holdContinuous(reason: string): Release; + mode(): 'demand' | 'continuous'; + diagnostics(): FrameDiagnostics; + /** Stop ticking while the page is hidden; resume on `false`. */ + suspend(hidden: boolean): void; + /** Draw a frame now, synchronously. */ + renderNow(): void; + /** Resolves after the next frame has been drawn and presented. */ + renderOnce(options?: { timeoutMs?: number }): Promise; + /** Cap the loop's rate; 0 is the display's rate. */ + setTargetFps(fps: number): void; + /** Set scene time, epoch ms. */ + setTime(ms: number): void; + /** Scene time, epoch ms. */ + time(): number; +} diff --git a/src/render/contract/globe.d.ts b/src/render/contract/globe.d.ts new file mode 100644 index 000000000..0b35ebfb1 --- /dev/null +++ b/src/render/contract/globe.d.ts @@ -0,0 +1,210 @@ +/** + * The base world: photorealistic tiles or a globe with terrain and imagery, + * the sky, and what drapes onto them. + */ +import type { Credit } from './credits'; +import type { GeoRect, Off } from './common'; + +export interface PhotorealSpec { + provider: 'google'; + key?: string; + /** Renews a session token; called again when the token expires. */ + token?: () => Promise; + /** Fall back to an ion-hosted copy when Google refuses. */ + ionFallback?: boolean; +} + +export interface PhotorealStatus { + /** Which route is drawing, or `'none'` when photoreal is off or failed. */ + route: 'google' | 'ion' | 'none'; + error?: { code: string; message: string }; +} + +/** + * Heights everywhere in the contract are physical (metres above the + * ellipsoid, unexaggerated): absolute positions, `HeightSample.height`, + * `clampToSurface`. Only drawing follows the exaggerated surface: + * `clampGround` and `relativeGround` items sit on the ground as drawn. + */ +export type TerrainSpec = ( + | { kind: 'ellipsoid' } + | { kind: 'quantizedMesh'; url: string; requestVertexNormals?: boolean } + | { kind: 'ion'; assetId: number } + /** A DEM image pyramid (`{z}/{x}/{y}`), heights encoded in RGB. */ + | { + kind: 'heightmap'; + url: string; + encoding: 'terrarium' | 'mapbox'; + maxLevel?: number; + /** The geoid the encoded heights are relative to; omitted: the ellipsoid. */ + geoid?: 'egm96' | 'egm2008'; + } +) & { + /** Vertical exaggeration of the drawn terrain; default 1. */ + exaggeration?: number; +}; + +export type TilingScheme = 'webMercator' | 'geographic'; + +/** One tile request, as an admission hook sees it. */ +export interface TileRequest { + x: number; + y: number; + level: number; +} + +export interface ImagerySpec { + kind: 'xyz' | 'wmts' | 'arcgis' | 'bing' | 'single-image' | 'canvas'; + /** URL or URL template (`{x}`, `{y}`, `{z}`, `{s}`, `{time}`). */ + url?: string; + /** `'canvas'`: draws one tile. */ + drawTile?: (tile: TileRequest, canvas: HTMLCanvasElement) => void; + /** WMTS: layer, style, tile matrix set and format. */ + wmts?: { + layer: string; + style: string; + tileMatrixSetId: string; + format?: string; + }; + subdomains?: string[]; + tiling?: TilingScheme; + rectangle?: GeoRect; + minLevel?: number; + maxLevel?: number; + tileSize?: number; + credit?: Credit; + /** Requests in flight at once for this layer. */ + maxConcurrentRequests?: number; + /** HTTP statuses retried rather than treated as missing. */ + retryStatuses?: number[]; + /** Changes a tile's image before it is drawn. */ + tileTransform?: ( + tile: TileRequest, + image: ImageBitmap, + ) => ImageBitmap | Promise; + /** + * Decides each tile request: call `issue()` to fetch it, or return + * `undefined` to defer it and be asked again on a later frame. + */ + admit?: ( + tile: TileRequest, + issue: () => Promise | undefined, + ) => Promise | undefined; + /** Draw order among imagery layers; higher draws on top. */ + zOrder?: number; +} + +export interface TimedUrl { + /** Epoch ms the frame shows. */ + time: number; + url: string; +} + +export interface ImageryLayer { + alpha: number; + show: boolean; + /** Which side of the split this layer shows on. */ + split: 'none' | 'left' | 'right'; + /** What it drapes onto. `'active'` follows whichever base is showing. */ + drapeOn: 'globe' | 'tiles' | 'both' | 'active'; + /** `imageryFrames`: a timed sequence, prefetched hidden. */ + setFrames?( + frames: TimedUrl[], + options?: { mode?: 'nearest' | 'blend'; prefetch?: number }, + ): void; + /** `imageryFrames`: which time to show. */ + setFrameTime?(ms: number): void; + on(event: 'frameReady', cb: (time: number) => void): Off; + on( + event: 'tileError', + cb: (error: { tile: TileRequest; status?: number }) => void, + ): Off; + raise(): void; + lower(): void; + remove(): void; +} + +export interface BaseSpec { + photoreal?: PhotorealSpec | null; + /** `vectorBase`: a vector basemap from a style document. */ + vector?: { styleUrl: string } | null; + terrain?: TerrainSpec | null; + imagery?: ImagerySpec[]; + credit?: Credit; +} + +export interface BaseStatus { + photoreal: PhotorealStatus; + imagery: ImageryLayer[]; + settled: boolean; +} + +/** + * The sky. A renderer whose sun, moon or fog are not separate from its + * atmosphere follows `atmosphere` for them and reports them as it draws them. + */ +export interface SkySettings { + atmosphere: boolean; + atmosphereTuning?: { + brightness?: number; + hueShift?: number; + saturation?: number; + }; + stars: boolean; + /** Milky Way brightness, 0..1. */ + milkyWay: number; + sun: boolean; + moon: boolean; + /** Distance fog, separate from atmosphere on models. */ + fog: boolean; + /** Sun lighting of the globe and models. */ + lighting: boolean; +} + +export interface SurfaceShading { + kind: 'relief'; + /** Elevation-like field to shade, row-major, `width × height`. */ + values: Float32Array; + width: number; + height: number; + rectangle: GeoRect; + strength: number; +} + +export interface LoadProgress { + tilesPending: number; + imageryPending: number; + terrainPending: number; +} + +export type SettleKind = 'base' | 'tiles' | 'terrain' | 'imagery'; + +export interface GlobePort { + /** Switch the whole base at once; resolves when the new base has settled. */ + setBase(base: BaseSpec): Promise; + /** `photoreal`: on, off or a new token route. */ + setPhotoreal(spec: PhotorealSpec | null): Promise; + setTerrain(spec: TerrainSpec | null): Promise; + /** Show or hide the globe surface (hidden under photoreal). */ + setGlobeVisible(visible: boolean): void; + /** `surfaceShading`. */ + setSurfaceShading?(shading: SurfaceShading | null): void; + readonly imagery: { + add(spec: ImagerySpec, index?: number): ImageryLayer; + list(): ImageryLayer[]; + /** What imagery drapes onto now. */ + host(): 'globe' | 'tiles' | 'both'; + onHostChange(cb: (host: 'globe' | 'tiles' | 'both') => void): Off; + /** `splitSwipe`: split position, a fraction of the canvas's CSS width. */ + getSplit?(): number; + /** `splitSwipe`: move the split to `x01` of the canvas's CSS width. */ + setSplit?(x01: number): void; + }; + readonly sky: { + get(): SkySettings; + set(settings: Partial): void; + }; + /** Whether that part of the base has finished loading what is in view. */ + isSettled(kind: SettleKind): boolean; + on(event: 'progress', cb: (progress: LoadProgress) => void): Off; +} diff --git a/src/render/contract/index.d.ts b/src/render/contract/index.d.ts new file mode 100644 index 000000000..be8f6bc04 --- /dev/null +++ b/src/render/contract/index.d.ts @@ -0,0 +1,48 @@ +/** + * The renderer contract: what Core asks of a renderer, so a renderer other + * than the default one can draw the same application. + */ +export * from './common'; +export * from './renderer'; +export * from './frame'; +export * from './globe'; +export * from './draw'; +export * from './camera'; +export * from './query'; +export * from './input'; +export * from './effects'; +export * from './credits'; +export * from './quality'; + +/** Every capability a renderer may list. */ +export declare const CAPABILITIES: readonly import('./common').Capability[]; + +/** The contract version this module describes. */ +export declare const CONTRACT_VERSION: import('./renderer').ContractVersion; + +/** One port's members: null when required, else the capability that adds it. */ +export type PortDescriptor = Readonly< + Record +>; + +/** Every port's members, by port name. */ +export declare const PORTS: Readonly>; + +/** Every quality setting a renderer may support. */ +export declare const QUALITY_KEYS: readonly import('./quality').QualityKey[]; + +/** The phases and steps of one frame, in order. */ +export declare const FRAME_STEPS: readonly import('./frame').FrameStep[]; + +/** + * What a renderer is missing against the contract: each required member it + * lacks, and each capability-gated member present without its capability. + */ +export declare function checkRendererShape(renderer: unknown): string[]; + +/** What one port (or handed-out shape) is missing against its descriptor. */ +export declare function checkPortShape( + target: object, + port: string, + capabilities: ReadonlySet, +): string[]; diff --git a/src/render/contract/index.js b/src/render/contract/index.js new file mode 100644 index 000000000..2019f7995 --- /dev/null +++ b/src/render/contract/index.js @@ -0,0 +1,370 @@ +/** + * @module render/contract + * @description The renderer contract at run time: the capability flags, every + * port's members, and a shape check. The types live beside this file + * (`index.d.ts` and one `.d.ts` per port); `contract.test.mjs` keeps + * {@link PORTS} and those types in step, member for member. + * + * A member is required (`null`) or added by a capability: a renderer without + * that capability leaves the member out. + */ + +/** @type {import('./index').ContractVersion} */ +export const CONTRACT_VERSION = '0'; + +/** @type {readonly import('./common').Capability[]} */ +export const CAPABILITIES = Object.freeze([ + 'photoreal', + 'vectorBase', + 'finestHeights', + 'terrain', + 'modelAtmosphere', + 'imageryOnTiles', + 'splitSwipe', + 'imageryFrames', + 'surfaceShading', + 'screenModulation', + 'gpuTiming', + 'pickPosition', + 'sampleHeight', + 'rayCast', + 'columns', + 'motionSegments', + 'models', + 'quads', + 'meshes', + 'fields', + 'celestial', + 'agents', + 'cluster', + 'styles', + 'bloom', + 'sharpen', + 'followAutodetect', + 'benchmark', +]); + +/** @type {readonly import('./frame').FrameStep[]} */ +export const FRAME_STEPS = Object.freeze([ + 'flight', + 'input', + 'changed', + 'follow', + 'preUpdate', + 'moveDetection', + 'preRender', + 'render', + 'postRender', +]); + +/** Every quality setting a renderer may support. */ +export const QUALITY_KEYS = Object.freeze([ + 'pixelRatio', + 'overlayPixelRatio', + 'msaa', + 'tiles3dDetail', + 'terrainDetail', + 'imageryDetail', + 'tilesCacheBytes', + 'clouds', + 'shadows', + 'water', + 'atmosphere', + 'milkyWay', + 'particles', + 'models', + 'postFx', + 'animatedStyleFps', + 'sonarCadenceHz', + 'targetFps', +]); + +const required = (...names) => names.map((name) => [name, null]); + +/** + * Every port's members. Nested members are dotted (`imagery.add`); the + * instance shapes a port hands out (`drawSet`, `imageryLayer`, `flight`, + * `lease`) are listed as their own entries. + * @type {Readonly>>>} + */ +export const PORTS = deepFreeze({ + renderer: Object.fromEntries( + required( + 'id', + 'contractVersion', + 'capabilities', + 'canvas', + 'frame', + 'globe', + 'draw', + 'camera', + 'query', + 'input', + 'effects', + 'credits', + 'quality', + 'capture', + 'readPixels', + 'whenSettled', + 'resize', + 'releaseUnused', + 'on', + 'destroy', + ), + ), + frame: Object.fromEntries( + required( + 'on', + 'requestRender', + 'holdContinuous', + 'mode', + 'diagnostics', + 'suspend', + 'renderNow', + 'renderOnce', + 'setTargetFps', + 'setTime', + 'time', + ), + ), + globe: Object.fromEntries([ + ...required( + 'setBase', + 'setPhotoreal', + 'setTerrain', + 'setGlobeVisible', + 'imagery', + 'imagery.add', + 'imagery.list', + 'imagery.host', + 'imagery.onHostChange', + 'sky', + 'sky.get', + 'sky.set', + 'isSettled', + 'on', + ), + ['setSurfaceShading', 'surfaceShading'], + ['imagery.getSplit', 'splitSwipe'], + ['imagery.setSplit', 'splitSwipe'], + ]), + draw: Object.fromEntries([ + ...required('icons', 'points', 'lines', 'shapes', 'atlas'), + ['models', 'models'], + ['quads', 'quads'], + ['meshes', 'meshes'], + ['field', 'fields'], + ['celestial', 'celestial'], + ['agents', 'agents'], + ]), + camera: Object.fromEntries( + required( + 'pose', + 'setView', + 'setFov', + 'viewRectangle', + 'flyTo', + 'flyToBounds', + 'cancelFlight', + 'flying', + 'lookAt', + 'release', + 'move', + 'look', + 'rotateAround', + 'zoom', + 'follow', + 'followed', + 'followFramed', + 'followOffset', + 'followOffset.get', + 'followOffset.set', + 'controls', + 'controls.enabled', + 'controls.suspend', + 'controls.enableTilt', + 'controls.enableRotate', + 'controls.enableZoom', + 'controls.minDistance', + 'controls.maxDistance', + 'controls.inertiaSpin', + 'controls.inertiaTranslate', + 'controls.inertiaZoom', + 'controls.collision', + 'controls.zoomFactor', + 'controls.pitchRange', + 'controls.zoomInputs', + 'on', + 'onCommand', + 'matrices', + ), + ), + query: Object.fromEntries( + required( + 'pick', + 'drillPick', + 'pickPosition', + 'pickEllipsoid', + 'pickGlobe', + 'pickRay', + 'project', + 'projectMany', + 'isOccluded', + 'sampleHeightNow', + 'sampleHeightNowMany', + 'sampleHeight', + 'clampToSurface', + 'rayCast', + ), + ), + input: Object.fromEntries( + required( + 'on', + 'claimPointer', + 'isPointerFree', + 'suspendCameraControls', + 'behaviours', + 'behaviours.select', + 'behaviours.select.enabled', + 'behaviours.select.suppress', + 'behaviours.dblclickFollow', + 'behaviours.dblclickFollow.enabled', + 'behaviours.dblclickFollow.suppress', + 'ctrlWheelZoom', + ), + ), + effects: Object.fromEntries([ + ...required( + 'styles', + 'setStyle', + 'setStyleIntensity', + 'setStyleParams', + 'bloom', + 'sharpen', + 'diagnostics', + 'on', + ), + ['screenModulation', 'screenModulation'], + ]), + credits: Object.fromEntries(required('addStatic', 'list', 'on')), + quality: Object.fromEntries([ + ...required( + 'knobs', + 'get', + 'set', + 'device', + 'probe', + 'signals', + 'signals.gpuTiming', + 'signals.on', + ), + ['benchmark', 'benchmark'], + ]), + drawSet: Object.fromEntries([ + ...required( + 'id', + 'set', + 'upsert', + 'remove', + 'clear', + 'setOptions', + 'size', + 'show', + 'on', + 'dispose', + ), + ['setColumns', 'columns'], + ]), + imageryLayer: Object.fromEntries([ + ...required( + 'alpha', + 'show', + 'split', + 'drapeOn', + 'on', + 'raise', + 'lower', + 'remove', + ), + ['setFrames', 'imageryFrames'], + ['setFrameTime', 'imageryFrames'], + ]), + flight: Object.fromEntries(required('done', 'cancel')), + lease: Object.fromEntries(required('owner', 'current', 'release')), +}); + +/** The ports a renderer object holds, by the key it holds them under. */ +const RENDERER_PORTS = [ + 'frame', + 'globe', + 'draw', + 'camera', + 'query', + 'input', + 'effects', + 'credits', + 'quality', +]; + +function deepFreeze(value) { + for (const inner of Object.values(value)) + if (inner && typeof inner === 'object') deepFreeze(inner); + return Object.freeze(value); +} + +function has(target, dotted) { + let at = target; + for (const key of dotted.split('.')) { + if (at == null || (typeof at !== 'object' && typeof at !== 'function')) + return false; + if (!(key in at)) return false; + at = at[key]; + } + return true; +} + +/** + * What `target` is missing against one descriptor, and what it has that its + * capabilities do not allow. + * @param {object} target + * @param {string} port + * @param {ReadonlySet} capabilities + * @returns {string[]} + */ +export function checkPortShape(target, port, capabilities) { + const problems = []; + for (const [member, capability] of Object.entries(PORTS[port])) { + const present = has(target, member); + if (capability === null) { + if (!present) problems.push(`${port}.${member} is missing`); + } else if (capabilities.has(capability) && !present) { + problems.push(`${port}.${member} is missing with ${capability}`); + } else if (!capabilities.has(capability) && present) { + problems.push(`${port}.${member} is present without ${capability}`); + } + } + return problems; +} + +/** + * What a renderer is missing against the contract. + * @param {unknown} renderer + * @returns {string[]} + */ +export function checkRendererShape(renderer) { + if (!renderer || typeof renderer !== 'object') + return ['renderer is not an object']; + const r = /** @type {any} */ (renderer); + const capabilities = r.capabilities instanceof Set ? r.capabilities : null; + if (!capabilities) return ['renderer.capabilities is not a Set']; + const problems = []; + for (const capability of capabilities) + if (!CAPABILITIES.includes(capability)) + problems.push(`unknown capability ${capability}`); + if (r.contractVersion !== CONTRACT_VERSION) + problems.push(`contractVersion is ${r.contractVersion}`); + problems.push(...checkPortShape(r, 'renderer', capabilities)); + for (const port of RENDERER_PORTS) + if (r[port]) problems.push(...checkPortShape(r[port], port, capabilities)); + return problems; +} diff --git a/src/render/contract/input.d.ts b/src/render/contract/input.d.ts new file mode 100644 index 000000000..d1cc98013 --- /dev/null +++ b/src/render/contract/input.d.ts @@ -0,0 +1,93 @@ +/** + * Pointer input over the scene, through one dispatcher. + * + * A tool takes the pointer with a lease; while a lease is out, ambient + * subscribers (those without a lease) get only `move` and `up`: no presses, + * clicks, double-clicks, wheel or pinch. Suspending camera + * input is separate and counted, and takes effect on the same pointerdown + * that asked for it. + * + * A click is delivered with its pick already made, once, and shared by + * every subscriber (`PointerEv.pick`). A renderer whose picks answer a frame + * or two late delivers the click that much later rather than answering + * without them; `query.pick` stays synchronous for everything else. + * + * The engine's stock behaviours (select on click, follow on double-click) + * are explicit and removable here, never implicit. + */ +import type { Hit, PickWindow } from './query'; +import type { Ecef, Off, Release, ScreenXY } from './common'; + +export type PointerEventType = + 'down' | 'move' | 'up' | 'click' | 'dblclick' | 'wheel' | 'pinch'; + +export interface Modifiers { + shift: boolean; + ctrl: boolean; + alt: boolean; + meta: boolean; +} + +export interface PointerEv { + type: PointerEventType; + position: ScreenXY; + /** For `move`: where the pointer is now (`position` is where it was). */ + endPosition?: ScreenXY; + button: 0 | 1 | 2; + modifiers: Modifiers; + /** Event time, ms (`performance.now()` timeline). */ + timestamp: number; + pointerType: 'mouse' | 'touch' | 'pen'; + /** `click`: how far the pointer travelled and how long the press lasted. */ + gesture?: { travelPx: number; durationMs: number }; + /** `wheel`: lines scrolled; `pinch`: scale since the last event. */ + delta?: number; + /** `click` and `dblclick`: the hit under the pointer, picked once for every subscriber. */ + pick?(): Hit | null; + /** `click` and `dblclick`: every hit under the pointer, from the same pick. */ + drill?(options?: PickWindow & { limit?: number }): Hit[]; + /** `click` and `dblclick`: the surface position under the pointer. */ + ground?(): Ecef | null; +} + +/** A tool's hold on the pointer. */ +export interface Lease { + readonly owner: string; + readonly current: boolean; + release(): void; +} + +/** A stock behaviour, on until suppressed. */ +export interface StockBehaviour { + readonly enabled: boolean; + /** Turn it off until released. Holds are counted. */ + suppress(): Release; +} + +export interface InputPort { + on( + type: PointerEventType, + cb: (ev: PointerEv) => void, + options?: { lease?: Lease | (() => Lease | null) }, + ): Off; + /** Take the pointer for a tool. A new claim ends the previous lease. */ + claimPointer(owner: string): Lease; + isPointerFree(): boolean; + /** Suspend user camera input until released. Holds are counted. */ + suspendCameraControls(reason: string): Release; + /** + * Every behaviour the renderer does on its own in answer to input, each + * suppressible. `select` and `dblclickFollow` are always listed (a + * renderer without one reports it disabled); any other built-in one (a + * double-click zoom) is listed under its own name. + */ + readonly behaviours: { + /** Select the item under a click. */ + readonly select: StockBehaviour; + /** Follow the item under a double-click. */ + readonly dblclickFollow: StockBehaviour; + readonly [name: string]: StockBehaviour; + }; + /** Ctrl+wheel zooms (rather than the page). */ + ctrlWheelZoom: boolean; +} diff --git a/src/render/contract/quality.d.ts b/src/render/contract/quality.d.ts new file mode 100644 index 000000000..31f05e549 --- /dev/null +++ b/src/render/contract/quality.d.ts @@ -0,0 +1,116 @@ +/** + * Adaptive quality. A governor above the contract decides a level; each + * renderer reports its frame signals, lists the settings it supports and + * applies them. Nothing here is engine-specific. + */ +import type { Off } from './common'; + +export interface QualitySettings { + /** Absolute device pixel ratio for the scene; 0 = browser recommended. */ + pixelRatio: number; + /** Pixel ratio of screen-space overlays. */ + overlayPixelRatio: number; + msaa: 0 | 2 | 4 | 8; + /** 0..1: 3D tile detail (screen-space error, both directions). */ + tiles3dDetail: number; + terrainDetail: number; + imageryDetail: number; + tilesCacheBytes: number; + clouds: 'off' | 'low' | 'medium' | 'high' | 'ultra'; + shadows: boolean; + water: 'off' | 'low' | 'high'; + atmosphere: 'off' | 'low' | 'high'; + milkyWay: 'low' | 'high'; + /** 0..1 density of particle fields (wind, weather). */ + particles: number; + models: { maxInstances: number; maxDistance: number }; + postFx: { bloom: boolean; sharpen: boolean; style: 'low' | 'high' }; + /** Frame rate of animated styles. */ + animatedStyleFps: number; + /** Updates per second of a screen modulation sweep. */ + sonarCadenceHz: number; + targetFps: number; +} + +export type QualityKey = keyof QualitySettings; + +export interface KnobDescriptor { + key: QualityKey; + kind: 'number' | 'boolean' | 'enum' | 'object'; + min?: number; + max?: number; + options?: readonly string[] | readonly number[]; + default: unknown; + /** Relative cost, 0 (free) to 1 (the heaviest knob). */ + cost: number; + /** Changing it recreates the drawing context. */ + recreates?: boolean; + /** What else changing it affects (lower tile detail lowers height accuracy). */ + couples?: string[]; +} + +export interface FrameSignals { + cpuMs: number; + /** `gpuTiming`. */ + gpuMs?: number; + passes?: Record; + drawCalls?: number; + commands?: number; + tilesInFlight?: number; + memory?: { gpuBytes?: number; tilesBytes?: number }; + pixelRatio: number; + width: number; + height: number; + mode: 'demand' | 'continuous'; + /** This frame was asked for (not continuous). */ + requested: boolean; + holds: string[]; + /** Spread of present intervals over the last second, ms. */ + presentJitterMs?: number; +} + +export interface DeviceFacts { + backend: 'webgl1' | 'webgl2' | 'webgpu'; + gpuVendor?: string; + gpuRenderer?: string; + maxTextureSize: number; + msaaSamples: number[]; + gpuTimer: boolean; +} + +export interface ProbeResult { + device: DeviceFacts; + browser: { + devicePixelRatio: number; + screen: { width: number; height: number }; + hardwareConcurrency?: number; + deviceMemory?: number; + touchOnly: boolean; + }; + knobs: KnobDescriptor[]; +} + +export interface BenchResult { + frames: number; + cpuMs: { median: number; p95: number }; + gpuMs?: { median: number; p95: number }; +} + +export interface QualityPort { + /** The settings this renderer supports, with ranges, defaults and costs. */ + knobs(): KnobDescriptor[]; + get(): Partial; + /** Applied at the next frame; returns the keys it ignored as unsupported. */ + set(settings: Partial): QualityKey[]; + device(): DeviceFacts; + probe(): ProbeResult; + readonly signals: { + readonly gpuTiming: 'none' | 'frame' | 'perPass'; + on(event: 'frame', cb: (signals: FrameSignals) => void): Off; + }; + /** `benchmark`: the renderer's own; the governor has a default. */ + benchmark?(options: { + durationMs: number; + scene?: 'standard'; + }): Promise; +} diff --git a/src/render/contract/query.d.ts b/src/render/contract/query.d.ts new file mode 100644 index 000000000..31a6e9e48 --- /dev/null +++ b/src/render/contract/query.d.ts @@ -0,0 +1,106 @@ +/** + * Questions about what is on screen. + * + * Picks, projection and rays are synchronous and answer against the last + * presented frame. Heights have a synchronous cache-only form that may + * return null, and asynchronous forms that load what they need. + */ +import type { Ecef, LonLat, Meta, Ray, ScreenXY } from './common'; + +/** One thing under the pointer. */ +export interface Hit { + /** The draw set's id. */ + set: string; + /** The item's id. */ + id: string; + /** The item's `meta`, as it was given; absent when the item had none. */ + meta?: Meta; + /** The map itself (tiles or globe), rather than a drawn item. */ + surface: boolean; + /** Where the hit is, when the renderer knows it. */ + position?: Ecef; +} + +export interface PickWindow { + /** Pick window, CSS pixels; default 3 × 3. */ + width?: number; + height?: number; + /** + * What the pick is for. A hover pick may skip costly exact tests (a + * point-in-polygon on a large fill) that a select pick must make. + */ + purpose?: 'hover' | 'select'; +} + +export interface HeightOptions { + /** `surface`: tiles where they are drawn, otherwise terrain. */ + against?: 'surface' | 'terrain' | 'tiles'; + /** Set ids to see through. */ + exclude?: string[]; +} + +export interface HeightSample { + /** Metres above the ellipsoid. */ + height: number; + /** 1 for the most detailed data, lower for a coarser level. */ + confidence: number; + source: 'tiles' | 'terrain' | 'ellipsoid'; + /** The detail answered from: what is drawn now, or (`finestHeights`) the finest available. */ + lod: 'visible' | 'finest'; + /** Edge length of the tile answered from, metres, when known. */ + tileSizeM?: number; +} + +export interface QueryPort { + pick(x: number, y: number, window?: PickWindow): Hit | null; + drillPick( + x: number, + y: number, + options?: PickWindow & { limit?: number }, + ): Hit[]; + /** `pickPosition`: depth under the pointer, against tiles and terrain. */ + pickPosition(x: number, y: number): Ecef | null; + pickEllipsoid(x: number, y: number): Ecef | null; + /** Where a ray meets the globe surface (not tiles). */ + pickGlobe(ray: Ray): Ecef | null; + pickRay(x: number, y: number): Ray; + /** Screen position, or null behind the eye (off-screen points still answer). */ + project(position: Ecef): ScreenXY | null; + /** + * Packed `x, y, z` in → packed `x, y` out, CSS pixels; NaN for points + * behind the eye. For the label overlay's per-frame pass. + */ + projectMany(source: Float64Array, out: Float32Array): void; + /** Hidden by the ellipsoid's horizon from the current eye. */ + isOccluded(position: Ecef): boolean; + /** `sampleHeight`: cached height now, or null when not loaded. */ + sampleHeightNow(point: LonLat, options?: HeightOptions): number | null; + /** + * `sampleHeight`: cached heights for many points at once: packed `lon, lat` + * degrees in, one height per point out (NaN when not loaded). Core makes + * its per-frame height reads through this, once a frame. + */ + sampleHeightNowMany( + points: Float64Array, + out: Float64Array, + options?: HeightOptions, + ): void; + /** + * `sampleHeight`: heights, loading as needed; null per miss. + * `mostDetailed` (`finestHeights`) waits for the finest data. + */ + sampleHeight( + points: LonLat[], + options?: HeightOptions & { mostDetailed?: boolean }, + ): Promise>; + /** `sampleHeight`: positions moved onto the surface; null per miss. */ + clampToSurface( + positions: Ecef[], + options?: HeightOptions, + ): Promise>; + /** `rayCast`: first surface hit along a ray. */ + rayCast( + ray: Ray, + options?: HeightOptions & { maxDistance?: number }, + ): Promise; +} diff --git a/src/render/contract/renderer.d.ts b/src/render/contract/renderer.d.ts new file mode 100644 index 000000000..8b8ad2619 --- /dev/null +++ b/src/render/contract/renderer.d.ts @@ -0,0 +1,114 @@ +/** + * The renderer itself: what a plugin creates and what Core holds. + * + * A renderer draws the globe and what Core asks it to draw, owns the camera, + * and answers queries about what is on screen. Core keeps every record of + * its own; it never reads a renderer to learn its own state, only what the + * renderer itself measures (picks, depth, heights, readiness). + */ +import type { Capability, Off, ScreenRect } from './common'; +import type { CameraPort } from './camera'; +import type { CreditsPort } from './credits'; +import type { DrawPort } from './draw'; +import type { EffectsPort } from './effects'; +import type { FramePort } from './frame'; +import type { GlobePort, SkySettings } from './globe'; +import type { InputPort } from './input'; +import type { QualityPort } from './quality'; +import type { QueryPort } from './query'; + +/** The version of the contract this file describes. */ +export type ContractVersion = '0'; + +/** What Core is given to make a renderer. */ +export interface RendererPlugin { + /** Stable id, recorded with each session (`'cesium'`, ...). */ + id: string; + create(options: CreateOptions): Promise; +} + +export interface CreateOptions { + /** The element the renderer fills. It creates its own canvas inside. */ + container: HTMLElement; + /** Where Core renders credits, when it has a place for them. */ + creditContainer?: HTMLElement; + /** Google Maps Platform key for photorealistic tiles. */ + googleApiKey?: string; + /** Short-lived Google session tokens, when the key is held server-side. */ + googleTokens?: () => Promise; + /** Cesium ion token, for ion-hosted terrain and fallback tiles. */ + ionToken?: string; + /** Initial multisampling; quality can change it later. */ + msaa?: 0 | 2 | 4 | 8; + /** Keep the drawing buffer after presenting, for capture. */ + preserveDrawingBuffer?: boolean; + /** Sky applied before the first frame, so nothing draws in the wrong sky. */ + sky?: Partial; + /** Scene time in epoch ms. Core passes its boot time. */ + time?: number; + /** + * The clocks the renderer reads, for flights, inertia, tweens and + * `FrameTime.now`: `now` on the `performance.now()` timeline, `date` in + * epoch ms. Omitted: the browser's. Tests pass virtual ones. + */ + clock?: { now(): number; date(): number }; + /** Aborts creation; the renderer cleans up whatever it had built. */ + signal: AbortSignal; +} + +export interface CaptureOptions { + type?: 'png' | 'jpeg'; + /** JPEG quality, 0..1. */ + quality?: number; + /** Output scale relative to the canvas's CSS size. */ + scale?: number; + /** Part of the canvas, in CSS pixels. */ + crop?: ScreenRect; +} + +export interface SettleResult { + state: 'settled' | 'timeout' | 'failed'; + blockers: string[]; +} + +export interface Renderer { + readonly id: string; + readonly contractVersion: ContractVersion; + readonly capabilities: ReadonlySet; + readonly canvas: HTMLCanvasElement; + readonly frame: FramePort; + readonly globe: GlobePort; + readonly draw: DrawPort; + readonly camera: CameraPort; + readonly query: QueryPort; + readonly input: InputPort; + readonly effects: EffectsPort; + readonly credits: CreditsPort; + readonly quality: QualityPort; + /** The last presented frame as an encoded image. */ + capture(options?: CaptureOptions): Promise; + /** + * The last presented frame's pixels, synchronously, for loops that render + * a frame and read it at once (a share clip, a black-frame check). Call it + * right after `frame.renderNow()`. + */ + readPixels(options?: { crop?: ScreenRect; scale?: number }): ImageData; + /** + * Resolves once what is in view has loaded, or the timeout passes, or a + * load failed for good. `blockers` names what was still loading or + * failed (`'tiles'`, `'imagery:'`, `'models'`, ...); empty when settled. + */ + whenSettled(options?: { timeoutMs?: number }): Promise; + /** Follows the container's size. */ + resize(): void; + /** Frees caches and GPU memory nothing in view uses. */ + releaseUnused(): void; + on(event: 'error', cb: (error: Error) => void): Off; + /** + * The drawing context was lost, or came back. A renderer whose device + * cannot come back (WebGPU) never sends `contextRestored`; Core then makes + * a new renderer. + */ + on(event: 'contextLost' | 'contextRestored', cb: () => void): Off; + destroy(): void; +} diff --git a/src/render/stub/camera.js b/src/render/stub/camera.js new file mode 100644 index 000000000..1449b052d --- /dev/null +++ b/src/render/stub/camera.js @@ -0,0 +1,950 @@ +/** + * @module render/stub/camera + * @description The stub renderer's camera: exact poses for every camera + * command of the contract, flights on the stub's clock, follows, holds and + * events. It keeps the eye in a frame (the world, or a `lookAt` target's + * east-north-up frame) the way the default renderer does, so commands made + * in a target's frame behave the same. + */ +import { + EPSILON10, + IDENTITY, + PI_OVER_TWO, + TWO_PI, + acosClamped, + add, + clone, + cross, + distance, + dot, + eastNorthUp, + fromRadians, + framePoint, + frameVector, + geodeticSurfaceNormal, + headingOf, + headingPitchRollColumns, + inverseFramePoint, + inverseFrameVector, + isIdentity, + negativePiToPi, + magnitude, + negate, + normalize, + offsetFromHeadingPitchRange, + pitchOf, + rollOf, + rotate, + scale, + sub, + toCartographicRadians, + toDegrees, + toEcef, + toRadians, + vec, + zeroToTwoPi, +} from './math.js'; +import { geodesicAt } from './geodesic.js'; + +const DEFAULT_FOV = Math.PI / 3; +const MOVE_END_WAIT_MS = 500; +const MINIMUM_ZOOM = 100; +const DEFAULT_SPHERE_OFFSET = Object.freeze({ + heading: 0, + pitch: -Math.PI / 4, + range: 0, +}); + +const EASINGS = { + linear: (t) => t, + quadraticInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2), + cubicInOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2), + quinticInOut: (t) => (t < 0.5 ? 16 * t ** 5 : 1 - (-2 * t + 2) ** 5 / 2), + cubicOut: (t) => (t - 1) ** 3 + 1, +}; + +/** Above this height, a descending flight eases out rather than in and out. */ +const DESCENT_EASE_HEIGHT = 11500; +const ONE_DEGREE = Math.PI / 180; + +/** + * @param {object} options + * @param {() => {width: number, height: number}} options.viewport CSS size. + * @param {() => number} options.now The stub's clock, ms. + * @param {() => number} options.sceneTime Scene time, epoch ms. + * @param {(reason: string) => void} options.requestRender + * @param {(error: Error) => void} options.report + */ +export function createStubCamera({ + viewport, + now, + sceneTime, + requestRender, + report, + drawnPosition = () => null, +}) { + // Local frame values; world values are `frame` applied to them. + let frame = IDENTITY; + let position = vec(0, 0, 0); + let direction = vec(0, 0, -1); + let up = vec(0, 1, 0); + let right = vec(1, 0, 0); + let fovy = null; + let fov = DEFAULT_FOV; + const near = 1; + const far = 5e8; + + const listeners = { + changed: [], + moveStart: [], + moveEnd: [], + followChanged: [], + flightEnd: [], + }; + const commandHooks = []; + const holds = new Map(); + let holdSeq = 0; + let flight = null; + let followed = null; + let followFramed = false; + let lastPose = null; + let moving = false; + let lastMovedAt = 0; + + const controls = { + get enabled() { + return holds.size === 0; + }, + suspend(reason) { + const key = ++holdSeq; + holds.set(key, reason); + return () => holds.delete(key); + }, + enableTilt: true, + enableRotate: true, + enableZoom: true, + minDistance: 1, + maxDistance: Infinity, + inertiaSpin: 0.9, + inertiaTranslate: 0.9, + inertiaZoom: 0.8, + collision: 'none', + zoomFactor: 5, + pitchRange: [-Math.PI / 2, 0], + zoomInputs: ['wheel', 'pinch', 'rightDrag'], + }; + + function aspectRatio() { + const { width, height } = viewport(); + return width / height; + } + + function verticalFov() { + if (fovy !== null) return fovy; + const aspect = aspectRatio(); + return aspect <= 1 ? fov : 2 * Math.atan(Math.tan(fov * 0.5) / aspect); + } + + const positionWC = () => framePoint(frame, position); + const directionWC = () => normalize(frameVector(frame, direction)); + const upWC = () => normalize(frameVector(frame, up)); + const rightWC = () => normalize(frameVector(frame, right)); + + /** Move into a new frame, keeping the world pose. */ + function setFrame(next) { + const p = positionWC(); + const d = directionWC(); + const u = upWC(); + frame = next; + position = inverseFramePoint(frame, p); + direction = inverseFrameVector(frame, d); + up = inverseFrameVector(frame, u); + right = cross(direction, up); + } + + /** Heading, pitch and roll in the east-north-up frame at the eye. */ + function eyeAngles() { + return directionUpToAngles(positionWC(), { + direction: directionWC(), + up: upWC(), + }); + } + + function pose() { + const p = positionWC(); + const c = toCartographicRadians(p) ?? { lon: 0, lat: 0, height: 0 }; + const a = eyeAngles(); + return { + lon: toDegrees(c.lon), + lat: toDegrees(c.lat), + height: c.height, + heading: a.heading, + pitch: a.pitch, + roll: a.roll, + position: p, + direction: directionWC(), + up: upWC(), + right: rightWC(), + fovy: verticalFov(), + aspectRatio: aspectRatio(), + near, + far, + frameIsIdentity: isIdentity(frame), + }; + } + + function command(kind) { + for (const hook of commandHooks.slice()) { + if (!commandHooks.includes(hook)) continue; + try { + hook({ kind }); + } catch (error) { + report(error); + } + } + } + + /** Place the eye at `destination` (world) with `orientation` in its east-north-up frame. */ + function placeAt(destination, hpr) { + const kept = frame; + const enu = eastNorthUp(destination); + frame = enu; + position = vec(0, 0, 0); + const columns = headingPitchRollColumns(hpr.heading, hpr.pitch, hpr.roll); + direction = columns.direction; + up = columns.up; + right = cross(direction, up); + setFrame(kept); + } + + /** Orientation given as direction and up, as angles at `destination`. */ + function directionUpToAngles(destination, orientation) { + const enu = eastNorthUp(destination); + const d = inverseFrameVector(enu, orientation.direction); + const u = inverseFrameVector(enu, orientation.up); + const r = cross(d, u); + return { + heading: headingOf(d, u), + pitch: pitchOf(d), + roll: rollOf(d, u, r), + }; + } + + function resolveOrientation(destination, orientation = {}) { + if (orientation && 'direction' in orientation) + return directionUpToAngles(destination, orientation); + return { + heading: orientation?.heading ?? 0, + pitch: orientation?.pitch ?? -PI_OVER_TWO, + roll: orientation?.roll ?? 0, + }; + } + + /** + * The eye over a rectangle that keeps all of it in view, as the default + * renderer frames one: over the geodesic midpoint of its central meridian, + * far enough for its corners, edge centres and (across the equator) its + * equator points. + */ + function rectangleDestination(rect) { + const { north, south, west } = { + north: toRadians(rect.north), + south: toRadians(rect.south), + west: toRadians(rect.west), + }; + let east = toRadians(rect.east); + if (west > east) east += TWO_PI; + const lon = (west + east) * 0.5; + const lat = + south < -PI_OVER_TWO + ONE_DEGREE && north > PI_OVER_TWO - ONE_DEGREE + ? 0 + : geodesicAt(lon, north, lon, south, 0.5).lat; + const center = fromRadians(lon, lat, 0); + const dir = negate(geodeticSurfaceNormal(center)); + const r = normalize(cross(dir, vec(0, 0, 1))); + const u = cross(r, dir); + const tanPhi = Math.tan(verticalFov() * 0.5); + const tanTheta = aspectRatio() * tanPhi; + const points = [ + [west, north], + [east, south], + [east, north], + [west, south], + [lon, north], + [lon, south], + ]; + if (south < 0 && north > 0) points.push([west, 0], [east, 0]); + let d = -Infinity; + for (const [pointLon, pointLat] of points) { + const corner = sub(fromRadians(pointLon, pointLat, 0), center); + d = Math.max( + d, + Math.abs(dot(u, corner)) / tanPhi - dot(dir, corner), + Math.abs(dot(r, corner)) / tanTheta - dot(dir, corner), + ); + } + return add(center, scale(dir, -d)); + } + + function resolveDestination(destination, orientation) { + const target = + destination && 'rect' in destination + ? rectangleDestination(destination.rect) + : destination + ? toEcef(destination) + : positionWC(); + return { + destination: target, + angles: resolveOrientation(target, orientation), + }; + } + + function setView({ destination, orientation } = {}) { + command('setView'); + const resolved = resolveDestination(destination, orientation); + placeAt(resolved.destination, resolved.angles); + requestRender('camera'); + } + + function lookAtFrame(targetFrame, offset) { + setFrame(targetFrame); + position = offset; + direction = normalize(negate(position)); + right = cross(direction, vec(0, 0, 1)); + if (dot(right, right) < EPSILON10) right = vec(1, 0, 0); + right = normalize(right); + up = normalize(cross(right, direction)); + } + + function lookAt(target, offset) { + command('lookAt'); + lookAtFrame( + eastNorthUp(target), + offsetFromHeadingPitchRange(offset.heading, offset.pitch, offset.range), + ); + requestRender('camera'); + } + + function release() { + command('release'); + setFrame(IDENTITY); + requestRender('camera'); + } + + function finishFlight(outcome) { + const f = flight; + if (!f) return; + flight = null; + f.releaseHold(); + const cb = outcome === 'complete' ? f.onComplete : f.onCancel; + try { + cb?.(); + } catch (error) { + report(error); + } + f.resolve(outcome); + emit('flightEnd'); + } + + /** + * Whether the eye already stands at `destination` with `target` angles; + * like the reference camera, by its position in its current frame. + */ + function alreadyThere(destination, target) { + const p = position; + const a = eyeAngles(); + const close = (x, y) => + Math.abs(x - y) <= EPSILON10 * Math.max(1, Math.abs(x), Math.abs(y)); + const sameAngle = (x, y) => close(negativePiToPi(x), negativePiToPi(y)); + return ( + close(p.x, destination.x) && + close(p.y, destination.y) && + close(p.z, destination.z) && + sameAngle(a.heading, target.heading) && + sameAngle(a.pitch, target.pitch) && + sameAngle(a.roll, target.roll) + ); + } + + /** + * A flight that ends inside the call, as the reference camera's does: a + * duration of 0 or less lands at once, and one already at its destination + * completes without moving. + */ + function instantFlight(destination, angles, durationS, options) { + if (durationS !== undefined && durationS <= 0) { + placeAt(destination, angles); + requestRender('camera'); + } else if (!alreadyThere(destination, angles)) return null; + let resolve; + const done = new Promise((r) => (resolve = r)); + const handle = { done, cancel: () => {} }; + flight = { + handle, + releaseHold: () => {}, + onComplete: options?.onComplete, + resolve, + }; + finishFlight('complete'); + return handle; + } + + function startFlight(destination, angles, durationS, options) { + if (flight) finishFlight('cancelled'); + if (options?.endFrame === 'world') setFrame(IDENTITY); + const instant = instantFlight(destination, angles, durationS, options); + if (instant) return instant; + const startPos = positionWC(); + const duration = + durationS ?? + Math.min(3, Math.ceil(distance(position, destination) / 1e6) + 2); + const start = { pos: startPos, angles: eyeAngles() }; + const path = flightPath(destination, angles, options ?? {}); + let resolve; + const done = new Promise((r) => (resolve = r)); + const handle = { + done, + cancel: () => { + if (flight?.handle === handle) finishFlight('cancelled'); + }, + }; + flight = { + handle, + start, + destination, + angles, + durationMs: duration * 1000, + startedAt: null, + path, + easing: + EASINGS[options?.easing] ?? + (path.startHeight > path.endHeight && + path.startHeight > DESCENT_EASE_HEIGHT + ? EASINGS.cubicOut + : EASINGS.quinticInOut), + onComplete: options?.onComplete, + onCancel: options?.onCancel, + releaseHold: controls.suspend('flight'), + resolve, + }; + requestRender('flight'); + return handle; + } + + function flyTo(destination, options = {}) { + command('flyTo'); + const resolved = resolveDestination(destination, options.orientation); + return startFlight( + resolved.destination, + resolved.angles, + options.durationS, + options, + ); + } + + function flyToBounds(sphere, options = {}) { + command('flyToBounds'); + setFrame(IDENTITY); + const offset = { ...DEFAULT_SPHERE_OFFSET, ...(options.offset ?? {}) }; + if (!offset.range) { + const tanPhi = Math.tan(verticalFov() * 0.5); + const tanTheta = aspectRatio() * tanPhi; + offset.range = + sphere.radius === 0 + ? MINIMUM_ZOOM + : Math.max(sphere.radius / tanTheta, sphere.radius / tanPhi); + offset.range = Math.min( + controls.maxDistance, + Math.max(controls.minDistance, offset.range), + ); + } + const enu = eastNorthUp(sphere.center); + const eye = framePoint( + enu, + offsetFromHeadingPitchRange(offset.heading, offset.pitch, offset.range), + ); + const dir = normalize(sub(sphere.center, eye)); + let u = enu.z; + if (1 - Math.abs(dot(dir, u)) < 1e-6) + u = rotate(enu.y, dir, offset.heading); + const r = cross(dir, u); + u = normalize(cross(r, dir)); + return startFlight( + eye, + directionUpToAngles(eye, { direction: dir, up: u }), + options.durationS, + options, + ); + } + + /** One frame of the flight in progress. */ + function stepFlight() { + if (!flight) return; + const t = now(); + if (flight.startedAt === null) { + flight.startedAt = t; + if (flight.durationMs > 0) return; + } + const elapsed = t - flight.startedAt; + if (elapsed >= flight.durationMs) { + placeAt(flight.destination, flight.angles); + finishFlight('complete'); + return; + } + const s = flight.easing(elapsed / flight.durationMs); + const { path } = flight; + placeAt( + fromRadians( + path.startLon + (path.endLon - path.startLon) * s, + path.startLat + (path.endLat - path.startLat) * s, + path.height(s), + ), + { + heading: + path.startHeading + (flight.angles.heading - path.startHeading) * s, + pitch: path.pitch(s), + roll: path.startRoll + (flight.angles.roll - path.startRoll) * s, + }, + ); + requestRender('flight'); + } + + /** + * The reference flight path, fixed when the flight starts: longitude and + * latitude linear in eased time the short way round; height on an arc + * over the higher end when the two ends are far apart on screen (or + * below `maxHeight`); pitch linear, or straight down mid-flight when the + * arc tops `pitchAdjustHeight`. + */ + function flightPath(destination, angles, options) { + const from = toCartographicRadians(positionWC()); + const to = toCartographicRadians(destination); + let startLon = zeroToTwoPi(from.lon); + let endLon = zeroToTwoPi(to.lon); + if (startLon - endLon < -Math.PI) startLon += TWO_PI; + else if (startLon - endLon > Math.PI) endLon += TWO_PI; + const now = eyeAngles(); + const height = heightCurve( + destination, + from.height, + to.height, + options.maxHeight, + ); + return { + startLon, + endLon, + startLat: from.lat, + endLat: to.lat, + startHeight: from.height, + endHeight: to.height, + height, + pitch: pitchCurve( + now.pitch, + angles.pitch, + height, + options.pitchAdjustHeight, + ), + startHeading: forLerp(now.heading, angles.heading), + startRoll: forLerp(now.roll, angles.roll), + }; + } + + function heightCurve(destination, startHeight, endHeight, maxHeight) { + let altitude = maxHeight; + if (altitude === undefined) { + const diff = sub(position, destination); + const vertical = Math.abs(dot(diff, up)); + const horizontal = Math.abs(dot(diff, right)); + const tanHalf = Math.tan(0.5 * verticalFov()); + altitude = Math.min( + Math.max(vertical / (aspectRatio() * tanHalf), horizontal / tanHalf) * + 0.2, + 1e9, + ); + } + if (Math.max(startHeight, endHeight) < altitude) { + const power = 8; + const factor = 1e6; + const s = -(((altitude - startHeight) * factor) ** (1 / power)); + const e = ((altitude - endHeight) * factor) ** (1 / power); + return (t) => -((t * (e - s) + s) ** power) / factor + altitude; + } + return (t) => startHeight + (endHeight - startHeight) * t; + } + + function pitchCurve(startPitch, endPitch, height, adjustHeight) { + if (adjustHeight !== undefined && height(0.5) > adjustHeight) { + const h0 = height(0); + const h1 = height(1); + const mid = height(0.5); + return (t) => { + const h = height(t); + if (t <= 0.5) + return ( + startPitch + (-Math.PI / 2 - startPitch) * ((h - h0) / (mid - h0)) + ); + return ( + -Math.PI / 2 + (endPitch + Math.PI / 2) * (1 - (h - h1) / (mid - h1)) + ); + }; + } + return (t) => startPitch + (endPitch - startPitch) * t; + } + + /** The start angle moved by a turn so a straight blend takes the short way. */ + function forLerp(start, end) { + let a = Math.abs(start - TWO_PI) <= 1e-11 ? 0 : start; + if (end > a + Math.PI) a += TWO_PI; + else if (end < a - Math.PI) a -= TWO_PI; + return a; + } + + function cancelFlight() { + command('cancelFlight'); + finishFlight('cancelled'); + } + + function move(dir, metres) { + const axis = { + forward: direction, + back: negate(direction), + right, + left: negate(right), + up, + down: negate(up), + }[dir]; + position = add(position, scale(axis, metres)); + requestRender('camera'); + } + + function look(dir, radians) { + const [axis, sign] = { + left: [up, -1], + right: [up, 1], + up: [right, -1], + down: [right, 1], + }[dir]; + const a = clone(axis); + const angle = -(sign * radians); + direction = rotate(direction, a, angle); + up = rotate(up, a, angle); + right = rotate(right, a, angle); + requestRender('camera'); + } + + function rotateAround(axis, radians) { + const a = normalize(axis); + position = rotate(position, a, -radians); + direction = rotate(direction, a, -radians); + up = rotate(up, a, -radians); + right = cross(direction, up); + up = cross(right, direction); + requestRender('camera'); + } + + function zoom(metres) { + move('forward', metres); + } + + // --- Follow ---------------------------------------------------------------- + + /** Where the target is drawn, for a drawn item; otherwise its own answer. */ + const targetAt = (target, t, aheadMs = 0) => + (target.item && drawnPosition(target.item, aheadMs)) || + target.position(t + aheadMs); + + function followFrame(target, t) { + const at = targetAt(target, t); + if (!at) return null; + if (target.frame === 'autodetect') { + const ahead = targetAt(target, t, 1000); + const enu = eastNorthUp(at); + if (ahead && distance(ahead, at) > 0.01) { + const along = normalize(sub(ahead, at)); + const upAxis = enu.z; + const forward = normalize( + sub(along, scale(upAxis, dot(along, upAxis))), + ); + const side = cross(forward, upAxis); + return { x: side, y: forward, z: upAxis, origin: at }; + } + return enu; + } + return eastNorthUp(at); + } + + function follow(target) { + command('follow'); + followed = target; + followFramed = false; + if (!target) setFrame(IDENTITY); + emit('followChanged'); + requestRender('follow'); + } + + function stepFollow() { + if (!followed) return; + const t = sceneTime(); + const targetFrame = followFrame(followed, t); + if (!targetFrame) return; + if (!followFramed) { + const viewFrom = + typeof followed.viewFrom === 'function' + ? followed.viewFrom(t) + : followed.viewFrom; + let offset = clone(viewFrom); + const range = magnitude(offset); + const minRange = Math.max( + followed.minRange ?? 0, + followed.boundingRadius ?? 0, + ); + if (range < minRange && range > 0) + offset = scale(offset, minRange / range); + lookAtFrame(targetFrame, offset); + followFramed = true; + return; + } + // Keep the eye's local offset in the moving frame. + const keptPosition = position; + const keptDirection = direction; + const keptUp = up; + frame = targetFrame; + position = keptPosition; + direction = keptDirection; + up = keptUp; + right = cross(direction, up); + } + + // --- Events ---------------------------------------------------------------- + + function emit(event) { + for (const entry of listeners[event].slice()) { + if (!entry.live) continue; + try { + entry.cb(); + } catch (error) { + report(error); + } + } + } + + function on(event, cb, options = {}) { + const entry = { + cb, + live: true, + threshold: options.threshold ?? 0.5, + ref: null, + }; + listeners[event].push(entry); + return () => { + entry.live = false; + const list = listeners[event]; + const i = list.indexOf(entry); + if (i >= 0) list.splice(i, 1); + }; + } + + /** How far the view moved from a listener's reference, as a fraction. */ + const angleDelta = (a, b) => { + const d = Math.abs(a - b) % TWO_PI; + return (d > Math.PI ? TWO_PI - d : d) / Math.PI; + }; + + /** + * Per listener, the reference camera's formula: heading and roll each + * against their own last reported value, then direction and position + * against the last reported view. Each can report in the same frame. + */ + function stepChanged() { + const list = listeners.changed; + if (!list.length) return; + const current = pose(); + for (const entry of list.slice()) { + if (!entry.live) continue; + const t = entry.threshold; + entry.heading ??= current.heading; + entry.roll ??= current.roll; + const headingF = angleDelta(entry.heading, current.heading); + const rollF = angleDelta(entry.roll, current.roll); + if (headingF > t) entry.heading = current.heading; + if (rollF > t) entry.roll = current.roll; + if (headingF > t || rollF > t) deliver(entry); + if (!entry.view) { + entry.view = { + position: current.position, + direction: current.direction, + }; + continue; + } + const dirF = + acosClamped(dot(current.direction, entry.view.direction)) / + (current.fovy * 0.5); + const heightF = + distance(current.position, entry.view.position) / current.height; + if (dirF > t || heightF > t) { + entry.view = { + position: current.position, + direction: current.direction, + }; + deliver(entry); + } + } + } + + function deliver(entry) { + if (!entry.live) return; + try { + entry.cb(); + } catch (error) { + report(error); + } + } + + function sameView(a, b) { + return ( + a && + b && + distance(a.position, b.position) === 0 && + dot(a.direction, b.direction) === dot(a.direction, a.direction) && + dot(a.up, b.up) === dot(a.up, a.up) + ); + } + + function stepMoveDetection() { + const current = { + position: positionWC(), + direction: directionWC(), + up: upWC(), + }; + const changed = lastPose && !sameView(lastPose, current); + lastPose = current; + const t = now(); + if (changed) { + lastMovedAt = t; + if (!moving) { + moving = true; + emit('moveStart'); + } + } else if (moving && !followed && t - lastMovedAt >= MOVE_END_WAIT_MS) { + moving = false; + emit('moveEnd'); + } + } + + function matrices() { + const p = positionWC(); + const d = directionWC(); + const u = upWC(); + const r = rightWC(); + // Column-major view matrix: rows are right, up and -direction. + const view = new Float64Array([ + r.x, + u.x, + -d.x, + 0, + r.y, + u.y, + -d.y, + 0, + r.z, + u.z, + -d.z, + 0, + -dot(r, p), + -dot(u, p), + dot(d, p), + 1, + ]); + const top = near * Math.tan(verticalFov() * 0.5); + const rightEdge = aspectRatio() * top; + const projection = new Float64Array([ + near / rightEdge, + 0, + 0, + 0, + 0, + near / top, + 0, + 0, + 0, + 0, + -(far + near) / (far - near), + -1, + 0, + 0, + (-2 * far * near) / (far - near), + 0, + ]); + return { view, projection }; + } + + const followOffset = { + get: () => (followed ? clone(position) : null), + set: (offset) => { + if (!followed) return; + lookAtFrame(frame, clone(offset)); + requestRender('follow'); + }, + }; + + const port = { + pose, + setView, + setFov({ vertical }) { + fovy = vertical; + requestRender('camera'); + }, + viewRectangle: () => null, + flyTo, + flyToBounds, + cancelFlight, + get flying() { + return flight !== null; + }, + lookAt, + release, + move, + look, + rotateAround, + zoom, + follow, + get followed() { + return followed; + }, + get followFramed() { + return followFramed; + }, + followOffset, + controls, + on, + onCommand(cb) { + commandHooks.push(cb); + return () => { + const i = commandHooks.indexOf(cb); + if (i >= 0) commandHooks.splice(i, 1); + }; + }, + matrices, + }; + + return { + port, + /** Ends a flight in progress as cancelled, without announcing a command. */ + endFlight: () => finishFlight('cancelled'), + /** Advance camera steps of one frame, in the contract's order. */ + steps: { stepFlight, stepChanged, stepFollow, stepMoveDetection }, + eye: () => ({ + position: positionWC(), + direction: directionWC(), + up: upWC(), + right: rightWC(), + fovy: verticalFov(), + aspectRatio: aspectRatio(), + near, + }), + holds: () => [...holds.values()], + setViewRectangle(fn) { + port.viewRectangle = fn; + }, + }; +} diff --git a/src/render/stub/geodesic.js b/src/render/stub/geodesic.js new file mode 100644 index 000000000..12fac64d3 --- /dev/null +++ b/src/render/stub/geodesic.js @@ -0,0 +1,164 @@ +/** + * @module render/stub/geodesic + * @description The point a fraction of the way along the WGS84 geodesic + * between two places: Vincenty's inverse formula, then the series the + * default renderer interpolates with, term for term, so a rectangle's + * centre matches its to floating-point noise. + */ +import { TWO_PI, WGS84 } from './math.js'; + +const EPSILON12 = 1e-12; +const A = WGS84.radii.x; +const B = WGS84.radii.z; +const F = (A - B) / A; + +function deltaLambda(sineAlpha, cos2Alpha, sigma, sinSigma, cosSigma, cos2Sm) { + const C = (F * cos2Alpha * (4 + F * (4 - 3 * cos2Alpha))) / 16; + return ( + (1 - C) * + F * + sineAlpha * + (sigma + C * sinSigma * (cos2Sm + C * cosSigma * (2 * cos2Sm * cos2Sm - 1))) + ); +} + +/** Distance, start heading and u² of the geodesic from (lon1, lat1) to (lon2, lat2), radians. */ +function inverse(lon1, lat1, lon2, lat2) { + const l = lon2 - lon1; + const u1 = Math.atan((1 - F) * Math.tan(lat1)); + const u2 = Math.atan((1 - F) * Math.tan(lat2)); + const cosU1 = Math.cos(u1); + const sinU1 = Math.sin(u1); + const cosU2 = Math.cos(u2); + const sinU2 = Math.sin(u2); + const cc = cosU1 * cosU2; + const cs = cosU1 * sinU2; + const ss = sinU1 * sinU2; + const sc = sinU1 * cosU2; + let lambda = l; + let lambdaDot = TWO_PI; + let cosLambda; + let sinLambda; + let sigma; + let cosSigma; + let sinSigma; + let cos2Alpha; + let cos2Sm; + do { + cosLambda = Math.cos(lambda); + sinLambda = Math.sin(lambda); + const temp = cs - sc * cosLambda; + sinSigma = Math.sqrt(cosU2 * cosU2 * sinLambda * sinLambda + temp * temp); + cosSigma = ss + cc * cosLambda; + sigma = Math.atan2(sinSigma, cosSigma); + let sineAlpha; + if (sinSigma === 0) { + sineAlpha = 0; + cos2Alpha = 1; + } else { + sineAlpha = (cc * sinLambda) / sinSigma; + cos2Alpha = 1 - sineAlpha * sineAlpha; + } + lambdaDot = lambda; + cos2Sm = cosSigma - (2 * ss) / cos2Alpha; + if (!isFinite(cos2Sm)) cos2Sm = 0; + lambda = + l + deltaLambda(sineAlpha, cos2Alpha, sigma, sinSigma, cosSigma, cos2Sm); + } while (Math.abs(lambda - lambdaDot) > EPSILON12); + const uSquared = (cos2Alpha * (A * A - B * B)) / (B * B); + const bigA = + 1 + + (uSquared * (4096 + uSquared * (uSquared * (320 - 175 * uSquared) - 768))) / + 16384; + const bigB = + (uSquared * (256 + uSquared * (uSquared * (74 - 47 * uSquared) - 128))) / + 1024; + const cos2Sm2 = cos2Sm * cos2Sm; + const deltaSigma = + bigB * + sinSigma * + (cos2Sm + + (bigB * + (cosSigma * (2 * cos2Sm2 - 1) - + (bigB * cos2Sm * (4 * sinSigma * sinSigma - 3) * (4 * cos2Sm2 - 3)) / + 6)) / + 4); + return { + distance: B * bigA * (sigma - deltaSigma), + startHeading: Math.atan2(cosU2 * sinLambda, cs - sc * cosLambda), + uSquared, + }; +} + +/** + * The longitude and latitude (radians) `fraction` of the way along the + * geodesic from (lon1, lat1) to (lon2, lat2). + */ +export function geodesicAt(lon1, lat1, lon2, lat2, fraction) { + const { distance, startHeading, uSquared } = inverse(lon1, lat1, lon2, lat2); + const cosH = Math.cos(startHeading); + const sinH = Math.sin(startHeading); + const tanU = (1 - F) * Math.tan(lat1); + const cosU = 1 / Math.sqrt(1 + tanU * tanU); + const sinU = cosU * tanU; + const sigma1 = Math.atan2(tanU, cosH); + const sineAlpha = cosU * sinH; + const cos2Alpha = 1 - sineAlpha * sineAlpha; + const cosAlpha = Math.sqrt(cos2Alpha); + const u2 = uSquared / 4; + const u4 = u2 * u2; + const u6 = u4 * u2; + const u8 = u4 * u4; + const a0 = 1 + u2 - (3 * u4) / 4 + (5 * u6) / 4 - (175 * u8) / 64; + const a1 = 1 - u2 + (15 * u4) / 8 - (35 * u6) / 8; + const a2 = 1 - 3 * u2 + (35 * u4) / 4; + const a3 = 1 - 5 * u2; + const distanceRatio = + a0 * sigma1 - + (a1 * Math.sin(2 * sigma1) * u2) / 2 - + (a2 * Math.sin(4 * sigma1) * u4) / 16 - + (a3 * Math.sin(6 * sigma1) * u6) / 48 - + (Math.sin(8 * sigma1) * 5 * u8) / 512; + + const s = distanceRatio + (distance * fraction) / B; + const cos2S = Math.cos(2 * s); + const cos4S = Math.cos(4 * s); + const cos6S = Math.cos(6 * s); + const sin2S = Math.sin(2 * s); + const sin4S = Math.sin(4 * s); + const sin6S = Math.sin(6 * s); + const sin8S = Math.sin(8 * s); + const s2 = s * s; + const s3 = s * s2; + let sigma = + (2 * s3 * u8 * cos2S) / 3 + + s * + (1 - + u2 + + (7 * u4) / 4 - + (15 * u6) / 4 + + (579 * u8) / 64 - + (u4 - (15 * u6) / 4 + (187 * u8) / 16) * cos2S - + ((5 * u6) / 4 - (115 * u8) / 16) * cos4S - + (29 * u8 * cos6S) / 16) + + (u2 / 2 - u4 + (71 * u6) / 32 - (85 * u8) / 16) * sin2S + + ((5 * u4) / 16 - (5 * u6) / 4 + (383 * u8) / 96) * sin4S - + s2 * ((u6 - (11 * u8) / 2) * sin2S + (5 * u8 * sin4S) / 2) + + ((29 * u6) / 96 - (29 * u8) / 16) * sin6S + + (539 * u8 * sin8S) / 1536; + + const theta = Math.asin(Math.sin(sigma) * cosAlpha); + const lat = Math.atan((A / B) * Math.tan(theta)); + sigma -= sigma1; + const cos2Sm = Math.cos(2 * sigma1 + sigma); + const sinSigma = Math.sin(sigma); + const cosSigma = Math.cos(sigma); + const lambda = Math.atan2( + sinSigma * sinH, + cosU * cosSigma - sinU * sinSigma * cosH, + ); + const l = + lambda - + deltaLambda(sineAlpha, cos2Alpha, sigma, sinSigma, cosSigma, cos2Sm); + return { lon: lon1 + l, lat }; +} diff --git a/src/render/stub/harness.js b/src/render/stub/harness.js new file mode 100644 index 000000000..969890f09 --- /dev/null +++ b/src/render/stub/harness.js @@ -0,0 +1,27 @@ +/** + * @module render/stub/harness + * @description The stub as the conformance suite's renderer under test, for + * the Node test and the browser page alike. + */ +import { DEFAULT_TOLERANCE } from '../conformance/index.js'; +import { createStubRenderer } from './index.js'; +import { toEcef } from './math.js'; + +/** @returns {import('../conformance/index.js').ConformanceHarness} */ +export function createStubHarness(name = 'stub') { + return { + name, + create: async ({ viewport, capabilities }) => + createStubRenderer({ viewport, capabilities }), + frame: (r) => { + r.testing.tick(); + }, + now: (r) => r.testing.now(), + toEcef, + tolerance: DEFAULT_TOLERANCE, + flightPaths: 'trace', + pointer: (r, type, x, y, extra) => r.testing.pointer(type, x, y, extra), + scriptHeights: (r, fn) => r.testing.setHeights(fn), + wallTime: (r) => r.testing.wallTime(), + }; +} diff --git a/src/render/stub/index.js b/src/render/stub/index.js new file mode 100644 index 000000000..26507eee6 --- /dev/null +++ b/src/render/stub/index.js @@ -0,0 +1,1245 @@ +/** + * @module render/stub + * @description A renderer that draws nothing: every port of the contract in + * memory, with exact camera poses, a scripted clock, picks against what was + * drawn (or a scripted table), scripted heights, and a log of calls. + * + * Tests across Core use it in place of a real renderer, and the conformance + * suite runs against it, so the stub is itself checked against the contract. + * Nothing runs on its own: `testing.tick()` and `testing.advance()` drive the + * loop on a virtual clock, one frame at a time, in the contract's order. + */ +import { CAPABILITIES, CONTRACT_VERSION } from '../contract/index.js'; +import { createStubCamera } from './camera.js'; +import { motionPosition } from './motion.js'; +import { computeViewRectangle } from './viewRectangle.js'; +import { + add, + dot, + magnitude, + normalize, + scale, + sub, + toCartographicRadians, + toDegrees, + toEcef, + vec, + WGS84, +} from './math.js'; + +const FRAME_MS = 1000 / 60; + +/** Every setting the stub accepts, with its descriptor. */ +const KNOBS = Object.freeze([ + { key: 'pixelRatio', kind: 'number', min: 0, max: 4, default: 0, cost: 1 }, + { key: 'msaa', kind: 'enum', options: [0, 2, 4, 8], default: 4, cost: 0.4 }, + { + key: 'tiles3dDetail', + kind: 'number', + min: 0, + max: 1, + default: 1, + cost: 0.8, + couples: ['lower detail lowers height accuracy'], + }, + { + key: 'terrainDetail', + kind: 'number', + min: 0, + max: 1, + default: 1, + cost: 0.4, + }, + { + key: 'imageryDetail', + kind: 'number', + min: 0, + max: 1, + default: 1, + cost: 0.3, + }, + { key: 'particles', kind: 'number', min: 0, max: 1, default: 1, cost: 0.3 }, + { key: 'targetFps', kind: 'number', min: 0, max: 240, default: 0, cost: 0.2 }, +]); + +function uncaught(error) { + if (typeof globalThis.reportError === 'function') + globalThis.reportError(error); + else + queueMicrotask(() => { + throw error; + }); +} + +/** + * @param {object} [options] + * @param {Iterable} [options.capabilities] Default: all. + * @param {{width: number, height: number}} [options.viewport] CSS size; default 1280 × 720. + * @param {number} [options.time] Scene time, epoch ms. + * @returns {import('../contract/renderer').Renderer & {testing: StubTesting}} + */ +export function createStubRenderer(options = {}) { + const capabilities = new Set(options.capabilities ?? CAPABILITIES); + const viewport = { width: 1280, height: 720, ...(options.viewport ?? {}) }; + const calls = []; + const log = (port, member, args) => calls.push({ port, member, args }); + const rendererListeners = { error: [], contextLost: [], contextRestored: [] }; + + let clock = 0; + let sceneTime = options.time ?? Date.UTC(2026, 0, 1); + // Wall time is the virtual clock from a fixed epoch, so live motion is scripted too. + const wallEpoch = options.wallTime ?? Date.UTC(2026, 0, 1); + const wallTime = () => wallEpoch + clock; + let frameCount = 0; + let rendered = 0; + let suspended = false; + let targetFps = 0; + let destroyed = false; + const pendingReasons = []; + const continuous = new Map(); + let holdSeq = 0; + const phaseListeners = { preUpdate: [], preRender: [], postRender: [] }; + const onceWaiters = []; + const onceTimers = new Set(); + const stepLog = []; + + /** + * Hands a listener's throw to the `error` listeners. With none, it goes to + * the platform's `reportError` (or is rethrown on a microtask), as a + * browser reports an event listener's exception: loud, but the frame and + * the other listeners carry on. + */ + function report(error) { + const errorListeners = rendererListeners.error; + if (!errorListeners.length) return uncaught(error); + for (const entry of errorListeners.slice()) { + // One removed during delivery does not run, as everywhere. + if (!entry.live) continue; + try { + entry.cb(error); + } catch (thrown) { + uncaught(thrown); + } + } + } + + /** + * Calls each listener in `list` (callbacks, or `{cb, live}` entries) with + * the contract's rules: one removed during delivery does not run, one + * added during delivery waits for the next event, and one that throws is + * reported while the rest still run. + */ + function deliver(list, ...args) { + for (const entry of list.slice()) { + const fn = typeof entry === 'function' ? entry : entry.cb; + if (typeof entry === 'function' ? !list.includes(entry) : !entry.live) + continue; + try { + fn(...args); + } catch (error) { + report(error); + } + } + } + + function requestRender(reason) { + if (!pendingReasons.includes(reason)) pendingReasons.push(reason); + } + + const camera = createStubCamera({ + viewport: () => viewport, + now: () => clock, + sceneTime: () => sceneTime, + requestRender, + report, + drawnPosition: (ref, aheadMs) => drawnPosition(ref, aheadMs), + }); + camera.setViewRectangle(viewRectangle); + camera.port.setView({ + destination: { lon: -97.7431, lat: 30.2672, height: 15000000 }, + }); + pendingReasons.length = 0; + + // --- Frame ----------------------------------------------------------------- + + function runPhase(phase, t) { + deliver(phaseListeners[phase], t); + } + + const signalListeners = []; + + function tick() { + if (destroyed || suspended) return false; + clock += FRAME_MS; + frameCount += 1; + stepLog.length = 0; + const step = (name) => stepLog.push(name); + step('flight'); + camera.steps.stepFlight(); + step('input'); + step('changed'); + camera.steps.stepChanged(); + step('follow'); + camera.steps.stepFollow(); + const rendering = pendingReasons.length > 0 || continuous.size > 0; + const t = { + frame: frameCount, + time: sceneTime, + now: clock, + dt: FRAME_MS / 1000, + rendering, + }; + step('preUpdate'); + runPhase('preUpdate', t); + step('moveDetection'); + camera.steps.stepMoveDetection(); + if (!rendering) return false; + const requested = pendingReasons.length > 0; + pendingReasons.length = 0; + step('preRender'); + runPhase('preRender', t); + step('render'); + rendered += 1; + step('postRender'); + runPhase('postRender', t); + for (const waiter of onceWaiters.splice(0)) waiter(); + const signals = { + cpuMs: 0, + pixelRatio: qualitySettings.pixelRatio || 1, + width: viewport.width, + height: viewport.height, + mode: 'demand', + requested, + holds: [...continuous.values()], + drawCalls: sets.size, + }; + deliver(signalListeners, signals); + return true; + } + + const frame = { + on(phase, cb) { + const entry = { cb, live: true }; + phaseListeners[phase].push(entry); + return () => { + entry.live = false; + const list = phaseListeners[phase]; + const i = list.indexOf(entry); + if (i >= 0) list.splice(i, 1); + }; + }, + requestRender(reason) { + requestRender(String(reason)); + }, + holdContinuous(reason) { + const key = ++holdSeq; + continuous.set(key, String(reason)); + return () => continuous.delete(key); + }, + mode: () => 'demand', + diagnostics: () => ({ + mode: 'demand', + frames: frameCount, + rendered, + pendingReasons: pendingReasons.slice(), + holds: [...continuous.values()], + suspended, + targetFps, + }), + suspend(hidden) { + suspended = Boolean(hidden); + }, + renderNow() { + requestRender('renderNow'); + tick(); + }, + renderOnce({ timeoutMs } = {}) { + requestRender('renderOnce'); + return new Promise((resolve, reject) => { + let timer = null; + const waiter = () => { + clearTimeout(timer); + resolve(); + }; + onceWaiters.push(waiter); + if (timeoutMs !== undefined) + timer = setTimeout(() => { + const i = onceWaiters.indexOf(waiter); + if (i >= 0) onceWaiters.splice(i, 1); + reject(new Error('renderOnce timed out')); + }, timeoutMs); + onceTimers.add(() => clearTimeout(timer)); + }); + }, + setTargetFps(fps) { + targetFps = fps; + }, + setTime(ms) { + sceneTime = ms; + requestRender('time'); + }, + time: () => sceneTime, + }; + + // --- Draw ------------------------------------------------------------------ + + /** @type {Map, columns: object|null, show: boolean, ready: Function[]}>} */ + const sets = new Map(); + const atlases = new Map(); + + function makeSet(kind, spec) { + if (!spec?.id) throw new TypeError('A draw set needs an id'); + if (sets.has(spec.id)) + throw new Error(`Draw set ${spec.id} already exists`); + const record = { + kind, + spec: { ...spec }, + items: new Map(), + show: spec.show ?? true, + readyListeners: [], + }; + sets.set(spec.id, record); + log('draw', kind, [spec]); + const ready = (ids) => { + if (kind !== 'models') return; + for (const id of ids) deliver(record.readyListeners, id); + }; + const handle = { + id: spec.id, + set(items) { + record.items.clear(); + for (const item of items) + record.items.set(String(item.id), { ...item }); + requestRender('draw'); + ready(items.map((item) => String(item.id))); + }, + upsert(items) { + for (const item of items) + record.items.set(String(item.id), { ...item }); + requestRender('draw'); + ready(items.map((item) => String(item.id))); + }, + remove(ids) { + for (const id of ids) record.items.delete(String(id)); + requestRender('draw'); + }, + clear() { + record.items.clear(); + requestRender('draw'); + }, + setOptions(patch) { + Object.assign(record.spec, patch); + if ('show' in patch) record.show = patch.show; + requestRender('draw'); + }, + size: () => record.items.size, + get show() { + return record.show; + }, + set show(value) { + record.show = Boolean(value); + requestRender('draw'); + }, + on(event, cb) { + if (event !== 'ready') return () => {}; + const entry = { cb, live: true }; + record.readyListeners.push(entry); + return () => { + entry.live = false; + }; + }, + dispose() { + // A newer set under the same id is not this handle's to remove. + if (sets.get(spec.id) === record) sets.delete(spec.id); + requestRender('draw'); + }, + }; + if (capabilities.has('columns')) + // Columns become rows like any other, so remove, size and picks see them. + handle.setColumns = (columns) => { + const next = new Map(); + columns.ids.forEach((id, i) => { + const key = String(id); + const row = { ...(record.items.get(key) ?? { id: key }) }; + const p = columns.positions; + if (p) row.pos = vec(p[i * 3], p[i * 3 + 1], p[i * 3 + 2]); + if (columns.courses) row.course = columns.courses[i]; + if (columns.shown) row.show = columns.shown[i] !== 0; + if (columns.scales) row.scale = columns.scales[i]; + if (columns.alphas) row.alpha = columns.alphas[i]; + if (columns.colors) + row.color = [0, 1, 2, 3].map( + (k) => columns.colors[i * 4 + k] / 255, + ); + if (columns.meta) row.meta = columns.meta[i]; + next.set(key, row); + }); + record.items = next; + requestRender('draw'); + }; + return handle; + } + + function simpleHandle(kind, spec) { + const record = { + kind, + spec: { ...spec }, + items: new Map(), + show: true, + readyListeners: [], + }; + sets.set(spec.id, record); + log('draw', kind, [spec]); + requestRender('draw'); + const handle = { + get show() { + return record.show; + }, + set show(value) { + record.show = Boolean(value); + requestRender('draw'); + }, + dispose() { + // A newer set under the same id is not this handle's to remove. + if (sets.get(spec.id) === record) sets.delete(spec.id); + requestRender('draw'); + }, + }; + return handle; + } + + const draw = { + icons: (spec) => makeSet('icons', spec), + points: (spec) => makeSet('points', spec), + lines: (spec) => makeSet('lines', spec), + shapes: (spec) => makeSet('shapes', spec), + atlas(id, images) { + atlases.set(id, { ...images }); + log('draw', 'atlas', [id]); + }, + }; + if (capabilities.has('models')) + draw.models = (spec) => makeSet('models', spec); + if (capabilities.has('quads')) draw.quads = (spec) => makeSet('quads', spec); + if (capabilities.has('meshes')) + draw.meshes = (spec) => makeSet('meshes', spec); + if (capabilities.has('agents')) + draw.agents = (spec) => makeSet('agents', spec); + if (capabilities.has('fields')) + draw.field = (spec) => { + const handle = simpleHandle('field', spec); + handle.update = (patch) => { + Object.assign(sets.get(spec.id).spec, patch); + requestRender('draw'); + }; + return handle; + }; + if (capabilities.has('celestial')) + draw.celestial = (spec) => simpleHandle('celestial', spec); + + // --- Query ----------------------------------------------------------------- + + function eye() { + return camera.eye(); + } + + function project(position) { + const e = eye(); + const v = sub(position, e.position); + const depth = dot(v, e.direction); + if (depth <= 0) return null; + const right = e.right; + const tanPhi = Math.tan(e.fovy * 0.5); + const tanTheta = e.aspectRatio * tanPhi; + const x = dot(v, right) / (depth * tanTheta); + const y = dot(v, e.up) / (depth * tanPhi); + return { + x: ((x + 1) / 2) * viewport.width, + y: ((1 - y) / 2) * viewport.height, + }; + } + + function pickRay(x, y) { + const e = eye(); + const tanPhi = Math.tan(e.fovy * 0.5); + const tanTheta = e.aspectRatio * tanPhi; + const nx = (2 / viewport.width) * x - 1; + const ny = (2 / viewport.height) * (viewport.height - y) - 1; + const near = e.near; + const target = add( + add( + add(e.position, scale(e.direction, near)), + scale(e.right, nx * near * tanTheta), + ), + scale(e.up, ny * near * tanPhi), + ); + return { + origin: e.position, + direction: normalize(sub(target, e.position)), + }; + } + + /** Nearest hit of a ray on the ellipsoid. */ + function rayEllipsoid(ray) { + const r = WGS84.oneOverRadii; + const q = vec(ray.origin.x * r.x, ray.origin.y * r.y, ray.origin.z * r.z); + const w = vec( + ray.direction.x * r.x, + ray.direction.y * r.y, + ray.direction.z * r.z, + ); + const a = dot(w, w); + const b = 2 * dot(q, w); + const c = dot(q, q) - 1; + const disc = b * b - 4 * a * c; + if (disc < 0) return null; + const sq = Math.sqrt(disc); + const t0 = (-b - sq) / (2 * a); + const t1 = (-b + sq) / (2 * a); + const t = t0 >= 0 ? t0 : t1 >= 0 ? t1 : null; + if (t === null) return null; + return add(ray.origin, scale(ray.direction, t)); + } + + function isOccluded(position) { + const e = eye(); + const r = WGS84.oneOverRadii; + const cv = vec(e.position.x * r.x, e.position.y * r.y, e.position.z * r.z); + const vh = dot(cv, cv) - 1; + const t = vec(position.x * r.x, position.y * r.y, position.z * r.z); + const vt = sub(t, cv); + const vtDotVc = -dot(vt, cv); + if (vh < 0) return vtDotVc > 0; + return vtDotVc > vh && (vtDotVc * vtDotVc) / dot(vt, vt) > vh; + } + + let scriptedPicks = null; + const heightCache = new Map(); + let heightSource = null; + const cacheKey = (p, against = 'surface') => + `${against}:${p.lon.toFixed(7)},${p.lat.toFixed(7)}`; + + /** The time a set's motion segments are evaluated at. */ + function motionTime(set) { + const clockSpec = set.spec.motionClock; + return clockSpec?.kind === 'scene' + ? sceneTime + : wallTime() - (clockSpec?.delayMs ?? 0); + } + + function itemPosition(set, item, aheadMs = 0) { + if (!item.pos) return null; + const base = toEcef(item.pos); + if ( + set.kind === 'icons' && + item.motion && + capabilities.has('motionSegments') + ) + return motionPosition(item.motion, base, motionTime(set) + aheadMs); + return base; + } + + /** Where a drawn item is now (or `aheadMs` from now), or null. */ + function drawnPosition(ref, aheadMs = 0) { + const set = sets.get(ref.set); + const item = set?.items.get(String(ref.id)); + return set && item ? itemPosition(set, item, aheadMs) : null; + } + + function hitsAt(x, y, win = {}) { + if (scriptedPicks) return scriptedPicks(x, y, win) ?? []; + const halfW = (win.width ?? 3) / 2; + const halfH = (win.height ?? 3) / 2; + const hits = []; + for (const [setId, set] of sets) { + if (!set.show || set.spec.pickable === false) continue; + if ( + set.kind !== 'icons' && + set.kind !== 'points' && + set.kind !== 'models' + ) + continue; + for (const item of set.items.values()) { + if (item.show === false || item.pickable === false) continue; + const p = itemPosition(set, item); + if (!p || isOccluded(p)) continue; + const s = project(p); + if (!s) continue; + const size = item.pickProxy?.size ?? + item.size ?? [item.pixelSize ?? 10, item.pixelSize ?? 10]; + if ( + Math.abs(s.x - x) <= size[0] / 2 + halfW && + Math.abs(s.y - y) <= size[1] / 2 + halfH + ) + hits.push({ + set: setId, + id: item.id, + meta: item.meta, + surface: false, + position: p, + depth: magnitude(sub(p, eye().position)), + }); + } + } + hits.sort((a, b) => a.depth - b.depth); + for (const hit of hits) delete hit.depth; + return hits; + } + + function surfaceHit(x, y) { + const position = rayEllipsoid(pickRay(x, y)); + return position + ? { set: '', id: '', meta: undefined, surface: true, position } + : null; + } + + /** + * The geodetic box of the ellipsoid under the screen's border, or null + * when any of the border looks past the globe. A pole in view widens it to + * that pole and every longitude. + */ + function viewRectangle() { + return computeViewRectangle(camera.port.pose(), viewport, (x, y) => + rayEllipsoid(pickRay(x, y)), + ); + } + + function lookupHeight(point, against) { + const key = cacheKey(point, against); + if (heightCache.has(key)) return heightCache.get(key); + if (heightSource) return heightSource(point, against); + return null; + } + + const query = { + pick(x, y, win) { + return hitsAt(x, y, win)[0] ?? surfaceHit(x, y); + }, + drillPick(x, y, opts = {}) { + const hits = hitsAt(x, y, opts); + return opts.limit === undefined ? hits : hits.slice(0, opts.limit); + }, + pickPosition(x, y) { + if (!capabilities.has('pickPosition')) return null; + return rayEllipsoid(pickRay(x, y)); + }, + pickEllipsoid: (x, y) => rayEllipsoid(pickRay(x, y)), + pickGlobe: (ray) => rayEllipsoid(ray), + pickRay, + project, + projectMany(source, out) { + for (let i = 0, j = 0; i + 2 < source.length; i += 3, j += 2) { + const s = project(vec(source[i], source[i + 1], source[i + 2])); + out[j] = s ? s.x : NaN; + out[j + 1] = s ? s.y : NaN; + } + }, + isOccluded, + sampleHeightNow(point, opts = {}) { + if (!capabilities.has('sampleHeight')) return null; + const key = cacheKey(point, opts.against); + return heightCache.has(key) ? heightCache.get(key) : null; + }, + sampleHeightNowMany(points, out, opts = {}) { + for (let i = 0; i < out.length; i++) { + const height = query.sampleHeightNow( + { lon: points[i * 2], lat: points[i * 2 + 1] }, + opts, + ); + out[i] = height ?? NaN; + } + }, + async sampleHeight(points, opts = {}) { + if (!capabilities.has('sampleHeight')) return points.map(() => null); + return points.map((point) => { + const height = lookupHeight(point, opts.against); + if (height === null || height === undefined) return null; + heightCache.set(cacheKey(point, opts.against), height); + const lod = + opts.mostDetailed && capabilities.has('finestHeights') + ? 'finest' + : 'visible'; + return { height, confidence: 1, source: 'terrain', lod }; + }); + }, + async clampToSurface(positions, opts = {}) { + const points = positions.map((p) => { + const c = toCartographicRadians(p); + return { lon: toDegrees(c.lon), lat: toDegrees(c.lat) }; + }); + const samples = await query.sampleHeight(points, opts); + return samples.map((sample, i) => + sample ? toEcef({ ...points[i], height: sample.height }) : null, + ); + }, + async rayCast(ray) { + if (!capabilities.has('rayCast')) return null; + return rayEllipsoid(ray); + }, + }; + + // --- Input ----------------------------------------------------------------- + + const pointerListeners = []; + let currentLease = null; + const cameraHolds = new Map(); + const behaviourHolds = { select: 0, dblclickFollow: 0 }; + + function makeBehaviour(name) { + return { + get enabled() { + return behaviourHolds[name] === 0; + }, + suppress() { + behaviourHolds[name] += 1; + let released = false; + return () => { + if (released) return; + released = true; + behaviourHolds[name] -= 1; + }; + }, + }; + } + + const leaseOf = (entry) => + typeof entry.lease === 'function' ? entry.lease() : entry.lease; + + const input = { + on(type, cb, opts = {}) { + const entry = { type, cb, lease: opts.lease ?? null, live: true }; + pointerListeners.push(entry); + return () => { + entry.live = false; + const i = pointerListeners.indexOf(entry); + if (i >= 0) pointerListeners.splice(i, 1); + }; + }, + claimPointer(owner) { + if (currentLease) currentLease.release(); + let released = false; + const lease = { + owner: String(owner), + get current() { + return !released && currentLease === lease; + }, + release() { + if (released) return; + released = true; + if (currentLease === lease) currentLease = null; + }, + }; + currentLease = lease; + return lease; + }, + isPointerFree: () => currentLease === null, + suspendCameraControls(reason) { + return camera.port.controls.suspend(String(reason)); + }, + behaviours: { + select: makeBehaviour('select'), + dblclickFollow: makeBehaviour('dblclickFollow'), + }, + ctrlWheelZoom: false, + }; + + function dispatchPointer(type, x, y, extra = {}) { + const free = currentLease === null; + let picked; + const ev = { + type, + position: { x, y }, + button: extra.button ?? 0, + modifiers: { + shift: false, + ctrl: false, + alt: false, + meta: false, + ...(extra.modifiers ?? {}), + }, + timestamp: clock, + pointerType: extra.pointerType ?? 'mouse', + ...(extra.endPosition ? { endPosition: extra.endPosition } : {}), + ...(extra.delta !== undefined ? { delta: extra.delta } : {}), + }; + if (type === 'click' || type === 'dblclick') { + ev.gesture = extra.gesture ?? { travelPx: 0, durationMs: 120 }; + const hits = () => (picked ??= hitsAt(x, y)); + ev.pick = () => hits()[0] ?? surfaceHit(x, y); + ev.drill = (opts = {}) => + opts.limit === undefined ? hits().slice() : hits().slice(0, opts.limit); + ev.ground = () => rayEllipsoid(pickRay(x, y)); + } + const ambientAlways = type === 'move' || type === 'up'; + for (const entry of pointerListeners.slice()) { + if (!entry.live || entry.type !== type) continue; + if (entry.lease) { + const lease = leaseOf(entry); + if (!lease?.current) continue; + } else if (!free && !ambientAlways) continue; + try { + entry.cb(ev); + } catch (error) { + report(error); + } + } + requestRender('input'); + } + + // --- Globe ----------------------------------------------------------------- + + const imageryLayers = []; + let split = 0.5; + let imageryHost = 'globe'; + const hostListeners = []; + const progressListeners = []; + const settled = { base: true, tiles: true, terrain: true, imagery: true }; + let sky = { + atmosphere: true, + stars: true, + milkyWay: 0.5, + sun: true, + moon: true, + fog: true, + lighting: false, + ...(options.sky ?? {}), + }; + let photorealStatus = { route: 'none' }; + + function addImagery(spec, index) { + const listeners = { frameReady: [], tileError: [] }; + const layer = { + spec: { ...spec }, + alpha: 1, + show: true, + split: 'none', + drapeOn: 'active', + on(event, cb) { + listeners[event]?.push(cb); + return () => { + const list = listeners[event] ?? []; + const i = list.indexOf(cb); + if (i >= 0) list.splice(i, 1); + }; + }, + raise() { + const i = imageryLayers.indexOf(layer); + if (i >= 0 && i < imageryLayers.length - 1) { + imageryLayers.splice(i, 1); + imageryLayers.splice(i + 1, 0, layer); + } + requestRender('imagery'); + }, + lower() { + const i = imageryLayers.indexOf(layer); + if (i > 0) { + imageryLayers.splice(i, 1); + imageryLayers.splice(i - 1, 0, layer); + } + requestRender('imagery'); + }, + remove() { + const i = imageryLayers.indexOf(layer); + if (i >= 0) imageryLayers.splice(i, 1); + requestRender('imagery'); + }, + }; + if (capabilities.has('imageryFrames')) { + let frames = []; + layer.setFrames = (list) => { + frames = list.slice(); + }; + layer.setFrameTime = (ms) => { + const nearest = frames.reduce( + (best, f) => + !best || Math.abs(f.time - ms) < Math.abs(best.time - ms) + ? f + : best, + null, + ); + if (nearest) deliver(listeners.frameReady, nearest.time); + }; + } + if (index === undefined) imageryLayers.push(layer); + else imageryLayers.splice(index, 0, layer); + log('globe', 'imagery.add', [spec, index]); + requestRender('imagery'); + return layer; + } + + const globe = { + async setBase(base) { + log('globe', 'setBase', [base]); + for (const layer of imageryLayers.splice(0)) void layer; + if (base.photoreal !== undefined) + await globe.setPhotoreal(base.photoreal); + const layers = (base.imagery ?? []).map((spec) => addImagery(spec)); + return { photoreal: photorealStatus, imagery: layers, settled: true }; + }, + async setPhotoreal(spec) { + log('globe', 'setPhotoreal', [spec]); + photorealStatus = + spec && capabilities.has('photoreal') + ? { route: 'google' } + : { route: 'none' }; + const next = photorealStatus.route === 'none' ? 'globe' : 'tiles'; + if (next !== imageryHost) { + imageryHost = next; + deliver(hostListeners, next); + } + requestRender('base'); + return photorealStatus; + }, + async setTerrain(spec) { + log('globe', 'setTerrain', [spec]); + requestRender('base'); + }, + setGlobeVisible(visible) { + log('globe', 'setGlobeVisible', [visible]); + requestRender('base'); + }, + imagery: { + add: addImagery, + list: () => imageryLayers.slice(), + host: () => imageryHost, + onHostChange(cb) { + hostListeners.push(cb); + return () => { + const i = hostListeners.indexOf(cb); + if (i >= 0) hostListeners.splice(i, 1); + }; + }, + }, + sky: { + get: () => ({ ...sky }), + set(patch) { + sky = { ...sky, ...patch }; + requestRender('sky'); + }, + }, + isSettled: (kind) => settled[kind] ?? true, + on(event, cb) { + if (event !== 'progress') return () => {}; + progressListeners.push(cb); + return () => { + const i = progressListeners.indexOf(cb); + if (i >= 0) progressListeners.splice(i, 1); + }; + }, + }; + if (capabilities.has('surfaceShading')) + globe.setSurfaceShading = (shading) => { + log('globe', 'setSurfaceShading', [shading]); + requestRender('base'); + }; + if (capabilities.has('splitSwipe')) { + globe.imagery.getSplit = () => split; + globe.imagery.setSplit = (x01) => { + split = Math.min(1, Math.max(0, x01)); + requestRender('imagery'); + }; + } + + // --- Effects, credits, quality --------------------------------------------- + + const effectState = { + style: null, + params: {}, + intensity: {}, + bloom: null, + sharpen: null, + modulation: null, + }; + const effectListeners = []; + const STYLES = Object.freeze([ + { + id: 'retro', + params: [{ key: 'grain', min: 0, max: 1, default: 0.5 }], + animated: true, + }, + { id: 'thermal', params: [], animated: false }, + ]); + function effectsChanged() { + const d = effects.diagnostics(); + deliver(effectListeners, d); + requestRender('effects'); + } + const effects = { + styles: () => + capabilities.has('styles') ? STYLES.map((s) => ({ ...s })) : [], + setStyle(id, params) { + effectState.style = id; + if (params) effectState.params[id] = { ...params }; + effectsChanged(); + }, + setStyleIntensity(id, value) { + effectState.intensity[id] = value; + effectsChanged(); + }, + setStyleParams(id, params) { + effectState.params[id] = { ...(effectState.params[id] ?? {}), ...params }; + effectsChanged(); + }, + bloom(settings) { + effectState.bloom = capabilities.has('bloom') ? { ...settings } : null; + effectsChanged(); + }, + sharpen(settings) { + effectState.sharpen = capabilities.has('sharpen') + ? { ...settings } + : null; + effectsChanged(); + }, + diagnostics: () => ({ + style: effectState.style, + passes: [ + ...(effectState.style ? [`style:${effectState.style}`] : []), + ...(effectState.bloom?.enabled ? ['bloom'] : []), + ...(effectState.sharpen?.enabled ? ['sharpen'] : []), + ...(effectState.modulation ? ['modulation'] : []), + ], + }), + on(event, cb) { + if (event !== 'change') return () => {}; + effectListeners.push(cb); + return () => { + const i = effectListeners.indexOf(cb); + if (i >= 0) effectListeners.splice(i, 1); + }; + }, + }; + if (capabilities.has('screenModulation')) + effects.screenModulation = (sector) => { + effectState.modulation = sector ? { ...sector } : null; + effectsChanged(); + }; + + const creditList = []; + const creditListeners = []; + const creditsChanged = () => { + const list = creditList.map((c) => ({ ...c })); + deliver(creditListeners, list); + }; + const credits = { + addStatic(credit) { + const entry = { ...credit }; + creditList.push(entry); + creditsChanged(); + return () => { + const i = creditList.indexOf(entry); + if (i < 0) return; + creditList.splice(i, 1); + creditsChanged(); + }; + }, + list: () => creditList.map((c) => ({ ...c })), + on(event, cb) { + if (event !== 'change') return () => {}; + creditListeners.push(cb); + return () => { + const i = creditListeners.indexOf(cb); + if (i >= 0) creditListeners.splice(i, 1); + }; + }, + }; + + const qualitySettings = Object.fromEntries( + KNOBS.map((k) => [k.key, k.default]), + ); + const device = () => ({ + backend: 'webgl2', + maxTextureSize: 16384, + msaaSamples: [0, 2, 4, 8], + gpuTimer: capabilities.has('gpuTiming'), + }); + const quality = { + knobs: () => KNOBS.map((k) => ({ ...k })), + get: () => ({ ...qualitySettings }), + set(patch) { + const ignored = []; + for (const [key, value] of Object.entries(patch)) { + if (key in qualitySettings) qualitySettings[key] = value; + else ignored.push(key); + } + requestRender('quality'); + return ignored; + }, + device, + probe: () => ({ + device: device(), + browser: { + devicePixelRatio: 1, + screen: { width: viewport.width, height: viewport.height }, + touchOnly: false, + }, + knobs: quality.knobs(), + }), + signals: { + gpuTiming: capabilities.has('gpuTiming') ? 'frame' : 'none', + on(event, cb) { + const entry = { cb, live: true }; + if (event === 'frame') signalListeners.push(entry); + return () => { + entry.live = false; + const i = signalListeners.indexOf(entry); + if (i >= 0) signalListeners.splice(i, 1); + }; + }, + }, + }; + if (capabilities.has('benchmark')) + quality.benchmark = async ({ durationMs }) => ({ + frames: Math.round(durationMs / FRAME_MS), + cpuMs: { median: 0, p95: 0 }, + }); + + // --- Renderer -------------------------------------------------------------- + + const canvas = /** @type {HTMLCanvasElement} */ ( + /** @type {unknown} */ ({ + width: viewport.width, + height: viewport.height, + clientWidth: viewport.width, + clientHeight: viewport.height, + }) + ); + + const renderer = { + id: 'stub', + contractVersion: CONTRACT_VERSION, + capabilities, + canvas, + frame, + globe, + draw, + camera: camera.port, + query, + input, + effects, + credits, + quality, + async capture(opts = {}) { + return new Blob([], { type: `image/${opts.type ?? 'png'}` }); + }, + readPixels(opts = {}) { + const width = Math.round( + (opts.crop?.width ?? viewport.width) * (opts.scale ?? 1), + ); + const height = Math.round( + (opts.crop?.height ?? viewport.height) * (opts.scale ?? 1), + ); + return /** @type {ImageData} */ ( + /** @type {unknown} */ ({ + width, + height, + data: new Uint8ClampedArray(width * height * 4), + }) + ); + }, + async whenSettled() { + const blockers = Object.keys(settled).filter((kind) => !settled[kind]); + return { state: blockers.length ? 'timeout' : 'settled', blockers }; + }, + resize() { + requestRender('resize'); + }, + releaseUnused() { + log('renderer', 'releaseUnused', []); + }, + on(event, cb) { + const list = rendererListeners[event]; + if (!list) return () => {}; + // Each subscription is its own entry, so one callback subscribed + // again during delivery waits for the next event like any other. + const entry = { cb, live: true }; + list.push(entry); + return () => { + entry.live = false; + const i = list.indexOf(entry); + if (i >= 0) list.splice(i, 1); + }; + }, + destroy() { + // A flight in progress ends cancelled and gives back its input hold. + camera.endFlight(); + destroyed = true; + for (const clear of onceTimers) clear(); + onceTimers.clear(); + sets.clear(); + }, + testing: { + /** Run one frame of the loop; true when it drew. */ + tick, + /** Run frames until `ms` of the clock has passed. */ + advance(ms) { + // By frame count: a suspended or destroyed loop does not move the clock. + const frames = Math.floor(ms / FRAME_MS + 1e-9); + let drew = 0; + for (let i = 0; i < frames; i++) if (tick()) drew += 1; + return drew; + }, + /** Run frames until a condition holds, at most `maxFrames`. */ + until(condition, maxFrames = 10000) { + for (let i = 0; i < maxFrames; i++) { + if (condition()) return i; + tick(); + } + throw new Error('condition not met'); + }, + now: () => clock, + /** Wall time, epoch ms: what a set's `live` motion clock reads before its delay. */ + wallTime, + /** The steps of the last frame, in order. */ + lastSteps: () => stepLog.slice(), + /** A pointer event at `(x, y)`. */ + pointer: dispatchPointer, + /** Answer picks from a function instead of the drawn items. */ + scriptPicks(fn) { + scriptedPicks = fn; + }, + /** Heights: a function answering async samples, and cached values. */ + setHeights(fn) { + heightSource = fn; + }, + /** A cached height, against `against` (default `'surface'`). */ + cacheHeight(point, height, against) { + heightCache.set(cacheKey(point, against), height); + }, + setSettled(kind, value) { + settled[kind] = value; + }, + setViewport(size) { + Object.assign(viewport, size); + canvas.width = viewport.width; + canvas.height = viewport.height; + }, + /** What a set holds. */ + setRecord: (id) => sets.get(id) ?? null, + sets: () => [...sets.keys()], + calls, + effects: () => ({ ...effectState }), + emitError: (error) => report(error), + }, + }; + return renderer; +} + +/** + * @typedef {object} StubTesting + * @property {() => boolean} tick + * @property {(ms: number) => number} advance + * @property {(condition: () => boolean, maxFrames?: number) => number} until + * @property {() => number} now + * @property {() => string[]} lastSteps + * @property {(type: string, x: number, y: number, extra?: object) => void} pointer + * @property {(fn: ((x: number, y: number, win: object) => object[])|null) => void} scriptPicks + * @property {(fn: ((point: object, against?: string) => number|null)|null) => void} setHeights + * @property {(point: object, height: number) => void} cacheHeight + * @property {(kind: string, value: boolean) => void} setSettled + * @property {(size: {width?: number, height?: number}) => void} setViewport + * @property {(id: string) => object|null} setRecord + * @property {() => string[]} sets + * @property {Array<{port: string, member: string, args: unknown[]}>} calls + * @property {() => object} effects + * @property {(error: Error) => void} emitError + */ diff --git a/src/render/stub/math.js b/src/render/stub/math.js new file mode 100644 index 000000000..391f1b0cf --- /dev/null +++ b/src/render/stub/math.js @@ -0,0 +1,310 @@ +/** + * @module render/stub/math + * @description The geodesy and vector maths the stub renderer poses its + * camera with: WGS84, east-north-up frames, rigid transforms and the camera + * angle conventions of the contract (heading clockwise from north, pitch + * negative looking down). Plain objects and numbers, no engine. + * + * The formulas follow the default renderer's, step for step, so the stub's + * poses match its reference traces to floating-point noise. + */ + +export const WGS84 = Object.freeze({ + radii: Object.freeze({ x: 6378137.0, y: 6378137.0, z: 6356752.3142451793 }), + radiiSquared: Object.freeze({ + x: 6378137.0 * 6378137.0, + y: 6378137.0 * 6378137.0, + z: 6356752.3142451793 * 6356752.3142451793, + }), + oneOverRadii: Object.freeze({ + x: 1 / 6378137.0, + y: 1 / 6378137.0, + z: 1 / 6356752.3142451793, + }), + oneOverRadiiSquared: Object.freeze({ + x: 1 / (6378137.0 * 6378137.0), + y: 1 / (6378137.0 * 6378137.0), + z: 1 / (6356752.3142451793 * 6356752.3142451793), + }), +}); + +export const TWO_PI = Math.PI * 2; +export const PI_OVER_TWO = Math.PI / 2; +const EPSILON3 = 1e-3; +const EPSILON10 = 1e-10; +const EPSILON12 = 1e-12; +const EPSILON14 = 1e-14; +const CENTER_TOLERANCE_SQUARED = 0.1; + +export const vec = (x = 0, y = 0, z = 0) => ({ x, y, z }); +export const clone = (a) => ({ x: a.x, y: a.y, z: a.z }); +export const add = (a, b) => vec(a.x + b.x, a.y + b.y, a.z + b.z); +export const sub = (a, b) => vec(a.x - b.x, a.y - b.y, a.z - b.z); +export const scale = (a, s) => vec(a.x * s, a.y * s, a.z * s); +export const negate = (a) => vec(-a.x, -a.y, -a.z); +export const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z; +export const cross = (a, b) => + vec(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x); +export const magnitude = (a) => Math.sqrt(dot(a, a)); +export const distance = (a, b) => magnitude(sub(a, b)); +export function normalize(a) { + const m = magnitude(a); + return vec(a.x / m, a.y / m, a.z / m); +} + +export const toRadians = (degrees) => (degrees * Math.PI) / 180; +export const toDegrees = (radians) => (radians * 180) / Math.PI; +export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); +export const acosClamped = (v) => Math.acos(clamp(v, -1, 1)); +export const equalsEpsilon = (a, b, eps) => Math.abs(a - b) <= eps; + +/** An angle in [0, 2π). */ +export function zeroToTwoPi(angle) { + if (angle >= 0 && angle <= TWO_PI) return angle; + const mod = ((angle % TWO_PI) + TWO_PI) % TWO_PI; + if (Math.abs(mod) < EPSILON14 && Math.abs(angle) > EPSILON14) return TWO_PI; + return mod; +} + +/** An angle in [-π, π]. */ +export function negativePiToPi(angle) { + if (angle >= -Math.PI && angle <= Math.PI) return angle; + return zeroToTwoPi(angle + Math.PI) - Math.PI; +} + +// --- Geodesy ----------------------------------------------------------------- + +export function geodeticSurfaceNormal(p) { + const o = WGS84.oneOverRadiiSquared; + return normalize(vec(p.x * o.x, p.y * o.y, p.z * o.z)); +} + +function geodeticSurfaceNormalCartographic(lon, lat) { + const cosLat = Math.cos(lat); + return normalize( + vec(cosLat * Math.cos(lon), cosLat * Math.sin(lon), Math.sin(lat)), + ); +} + +/** Geodetic radians and metres to ECEF. */ +export function fromRadians(lon, lat, height = 0) { + const n = geodeticSurfaceNormalCartographic(lon, lat); + const r = WGS84.radiiSquared; + let k = vec(r.x * n.x, r.y * n.y, r.z * n.z); + const gamma = Math.sqrt(dot(n, k)); + k = scale(k, 1 / gamma); + return add(k, scale(n, height)); +} + +/** Geodetic degrees and metres to ECEF. */ +export const fromDegrees = (lon, lat, height = 0) => + fromRadians(toRadians(lon), toRadians(lat), height); + +/** A position in either contract form, as ECEF. */ +export function toEcef(position) { + if ('lon' in position && 'lat' in position) + return fromDegrees(position.lon, position.lat, position.height ?? 0); + return clone(position); +} + +/** The point on the ellipsoid along the geodetic normal, or null at the centre. */ +export function scaleToGeodeticSurface(p) { + const { x: positionX, y: positionY, z: positionZ } = p; + const oneOverRadiiX = WGS84.oneOverRadii.x; + const oneOverRadiiY = WGS84.oneOverRadii.y; + const oneOverRadiiZ = WGS84.oneOverRadii.z; + const x2 = positionX * positionX * oneOverRadiiX * oneOverRadiiX; + const y2 = positionY * positionY * oneOverRadiiY * oneOverRadiiY; + const z2 = positionZ * positionZ * oneOverRadiiZ * oneOverRadiiZ; + const squaredNorm = x2 + y2 + z2; + const ratio = Math.sqrt(1.0 / squaredNorm); + const intersection = scale(p, ratio); + if (squaredNorm < CENTER_TOLERANCE_SQUARED) + return Number.isFinite(ratio) ? intersection : null; + + const oneOverRadiiSquaredX = WGS84.oneOverRadiiSquared.x; + const oneOverRadiiSquaredY = WGS84.oneOverRadiiSquared.y; + const oneOverRadiiSquaredZ = WGS84.oneOverRadiiSquared.z; + const gradient = vec( + intersection.x * oneOverRadiiSquaredX * 2.0, + intersection.y * oneOverRadiiSquaredY * 2.0, + intersection.z * oneOverRadiiSquaredZ * 2.0, + ); + let lambda = ((1.0 - ratio) * magnitude(p)) / (0.5 * magnitude(gradient)); + let correction = 0.0; + let func, denominator, xMultiplier, yMultiplier, zMultiplier; + let xMultiplier2, yMultiplier2, zMultiplier2; + let xMultiplier3, yMultiplier3, zMultiplier3; + do { + lambda -= correction; + xMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredX); + yMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredY); + zMultiplier = 1.0 / (1.0 + lambda * oneOverRadiiSquaredZ); + xMultiplier2 = xMultiplier * xMultiplier; + yMultiplier2 = yMultiplier * yMultiplier; + zMultiplier2 = zMultiplier * zMultiplier; + xMultiplier3 = xMultiplier2 * xMultiplier; + yMultiplier3 = yMultiplier2 * yMultiplier; + zMultiplier3 = zMultiplier2 * zMultiplier; + func = x2 * xMultiplier2 + y2 * yMultiplier2 + z2 * zMultiplier2 - 1.0; + denominator = + x2 * xMultiplier3 * oneOverRadiiSquaredX + + y2 * yMultiplier3 * oneOverRadiiSquaredY + + z2 * zMultiplier3 * oneOverRadiiSquaredZ; + const derivative = -2.0 * denominator; + correction = func / derivative; + } while (Math.abs(func) > EPSILON12); + return vec( + positionX * xMultiplier, + positionY * yMultiplier, + positionZ * zMultiplier, + ); +} + +/** ECEF to geodetic `{lon, lat}` radians and `height` metres, or null at the centre. */ +export function toCartographicRadians(p) { + const surface = scaleToGeodeticSurface(p); + if (!surface) return null; + const n = geodeticSurfaceNormal(surface); + const h = sub(p, surface); + return { + lon: Math.atan2(n.y, n.x), + lat: Math.asin(n.z), + height: Math.sign(dot(h, p)) * magnitude(h), + }; +} + +/** ECEF to `{lon, lat}` degrees and `height` metres. */ +export function toLonLatH(p) { + const c = toCartographicRadians(p); + if (!c) return null; + return { lon: toDegrees(c.lon), lat: toDegrees(c.lat), height: c.height }; +} + +// --- Rigid frames ------------------------------------------------------------ + +/** + * A rigid frame: unit axes `x`, `y`, `z` (columns) and an `origin`, mapping + * local coordinates to ECEF. + * @typedef {{x: object, y: object, z: object, origin: object}} Frame + */ + +export const IDENTITY = Object.freeze({ + x: Object.freeze(vec(1, 0, 0)), + y: Object.freeze(vec(0, 1, 0)), + z: Object.freeze(vec(0, 0, 1)), + origin: Object.freeze(vec(0, 0, 0)), +}); + +export const isIdentity = (frame) => + frame === IDENTITY || + (frame.x.x === 1 && + frame.y.y === 1 && + frame.z.z === 1 && + frame.x.y === 0 && + frame.x.z === 0 && + frame.y.x === 0 && + frame.y.z === 0 && + frame.z.x === 0 && + frame.z.y === 0 && + frame.origin.x === 0 && + frame.origin.y === 0 && + frame.origin.z === 0); + +/** East-north-up at `origin`. */ +export function eastNorthUp(origin) { + if ( + equalsEpsilon(origin.x, 0, EPSILON14) && + equalsEpsilon(origin.y, 0, EPSILON14) + ) { + // At a pole: east is +y, north points away from the pole. + const sign = Math.sign(origin.z) || 1; + return { + x: vec(0, 1, 0), + y: vec(-sign, 0, 0), + z: vec(0, 0, sign), + origin: clone(origin), + }; + } + const up = geodeticSurfaceNormal(origin); + const east = normalize(vec(-origin.y, origin.x, 0)); + const north = cross(up, east); + return { x: east, y: north, z: up, origin: clone(origin) }; +} + +export const frameVector = (frame, v) => + vec( + frame.x.x * v.x + frame.y.x * v.y + frame.z.x * v.z, + frame.x.y * v.x + frame.y.y * v.y + frame.z.y * v.z, + frame.x.z * v.x + frame.y.z * v.y + frame.z.z * v.z, + ); +export const framePoint = (frame, p) => + add(frameVector(frame, p), frame.origin); +export const inverseFrameVector = (frame, v) => + vec(dot(frame.x, v), dot(frame.y, v), dot(frame.z, v)); +export const inverseFramePoint = (frame, p) => + inverseFrameVector(frame, sub(p, frame.origin)); + +// --- Rotations --------------------------------------------------------------- + +/** Rotate `v` about unit `axis` by `angle` (right-handed). */ +export function rotate(v, axis, angle) { + const c = Math.cos(angle); + const s = Math.sin(angle); + const d = dot(axis, v); + const k = cross(axis, v); + return vec( + v.x * c + k.x * s + axis.x * d * (1 - c), + v.y * c + k.y * s + axis.y * d * (1 - c), + v.z * c + k.z * s + axis.z * d * (1 - c), + ); +} + +/** + * The rotation the contract's heading, pitch and roll describe in a local + * east-north-up frame, as its three columns: the direction, the left axis + * and the up vector. + */ +export function headingPitchRollColumns(heading, pitch, roll) { + const h = heading - PI_OVER_TWO; + const rotateAll = (v) => + rotate( + rotate(rotate(v, vec(1, 0, 0), roll), vec(0, 1, 0), -pitch), + vec(0, 0, 1), + -h, + ); + return { + direction: rotateAll(vec(1, 0, 0)), + left: rotateAll(vec(0, 1, 0)), + up: rotateAll(vec(0, 0, 1)), + }; +} + +/** Heading of a local direction and up, radians clockwise from north. */ +export function headingOf(direction, up) { + const h = !equalsEpsilon(Math.abs(direction.z), 1.0, EPSILON3) + ? Math.atan2(direction.y, direction.x) - PI_OVER_TWO + : Math.atan2(up.y, up.x) - PI_OVER_TWO; + return TWO_PI - zeroToTwoPi(h); +} + +export const pitchOf = (direction) => PI_OVER_TWO - acosClamped(direction.z); + +export function rollOf(direction, up, right) { + if (equalsEpsilon(Math.abs(direction.z), 1.0, EPSILON3)) return 0; + return zeroToTwoPi(Math.atan2(-right.z, up.z) + TWO_PI); +} + +/** The local offset a heading, pitch and range puts the eye at. */ +export function offsetFromHeadingPitchRange(heading, pitch, range) { + const p = clamp(pitch, -PI_OVER_TWO, PI_OVER_TWO); + const h = zeroToTwoPi(heading) - PI_OVER_TWO; + const offset = rotate( + rotate(vec(1, 0, 0), vec(0, 1, 0), -p), + vec(0, 0, 1), + -h, + ); + return scale(negate(offset), range); +} + +export { EPSILON10 }; diff --git a/src/render/stub/motion.js b/src/render/stub/motion.js new file mode 100644 index 000000000..d8bd5ad48 --- /dev/null +++ b/src/render/stub/motion.js @@ -0,0 +1,53 @@ +/** + * @module render/stub/motion + * @description Where a motion segment puts an icon at a time, exactly as + * the contract's `MotionSegment` describes it. + */ +import { add, eastNorthUp, scale, sub } from './math.js'; + +const STRAIGHT_RAD_PER_S = 1e-4; + +/** + * @param {import('../contract/draw').MotionSegment} segment + * @param {{x: number, y: number, z: number}} pos The item's own position (for `hold`). + * @param {number} t Epoch ms on the set's motion clock. + * @returns {{x: number, y: number, z: number}} + */ +export function motionPosition(segment, pos, t) { + switch (segment.kind) { + case 'hold': + return pos; + case 'lerp': { + const span = segment.t1 - segment.t0; + const s = + span > 0 + ? Math.min(1, Math.max(0, (t - segment.t0) / span)) + : t >= segment.t1 + ? 1 + : 0; + return add(segment.from, scale(sub(segment.to, segment.from), s)); + } + case 'arc': { + const dt = Math.min( + segment.maxDtS, + Math.max(segment.minDtS, (t - segment.t0) / 1000), + ); + const track = (segment.trackDeg * Math.PI) / 180; + const w = (segment.turnRateDps * Math.PI) / 180; + const v = segment.speedMps; + let east; + let north; + if (Math.abs(w) < STRAIGHT_RAD_PER_S) { + east = v * dt * Math.sin(track); + north = v * dt * Math.cos(track); + } else { + east = (v / w) * (Math.cos(track) - Math.cos(track + w * dt)); + north = (v / w) * (Math.sin(track + w * dt) - Math.sin(track)); + } + const enu = eastNorthUp(segment.fix); + return add(segment.fix, add(scale(enu.x, east), scale(enu.y, north))); + } + default: + throw new Error(`unknown motion segment ${segment.kind}`); + } +} diff --git a/src/render/stub/stub.test.mjs b/src/render/stub/stub.test.mjs new file mode 100644 index 000000000..5b4595b85 --- /dev/null +++ b/src/render/stub/stub.test.mjs @@ -0,0 +1,77 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createStubRenderer } from './index.js'; +import { createStubHarness } from './harness.js'; +import { runConformance } from '../conformance/index.js'; + +test('the stub passes the conformance suite', async () => { + const report = await runConformance(createStubHarness()); + const failed = report.results.filter((r) => r.status === 'fail'); + assert.deepEqual( + failed.map((r) => `${r.id}: ${r.detail}`), + [], + ); + const skipped = report.results + .filter((r) => r.status === 'skip') + .map((r) => r.id); + // Only the model check, which needs a glTF asset the stub has no use for. + assert.deepEqual(skipped, ['draw.models']); +}); + +test('a frame runs the contract’s steps in order', () => { + const r = createStubRenderer(); + r.frame.requestRender('test'); + r.testing.tick(); + assert.deepEqual(r.testing.lastSteps(), [ + 'flight', + 'input', + 'changed', + 'follow', + 'preUpdate', + 'moveDetection', + 'preRender', + 'render', + 'postRender', + ]); + r.testing.tick(); + assert.deepEqual( + r.testing.lastSteps().at(-1), + 'moveDetection', + 'an idle frame stops before preRender', + ); +}); + +test('a throwing listener with no error listener is reported and the frame goes on', async () => { + const reported = []; + const saved = globalThis.reportError; + globalThis.reportError = (error) => reported.push(error.message); + try { + const r = createStubRenderer(); + r.frame.on('preUpdate', () => { + throw new Error('phase'); + }); + let after = false; + r.frame.on('preUpdate', () => (after = true)); + r.frame.requestRender('test'); + r.testing.tick(); + assert.ok(after, 'the next listener still ran'); + assert.equal( + r.testing.lastSteps().at(-1), + 'postRender', + 'the frame ran on', + ); + const flight = r.camera.flyTo( + { lon: 0, lat: 0, height: 1e6 }, + { + durationS: 0, + onComplete: () => { + throw new Error('complete'); + }, + }, + ); + assert.equal(await flight.done, 'complete', 'the flight still resolved'); + assert.deepEqual(reported, ['phase', 'complete']); + } finally { + globalThis.reportError = saved; + } +}); diff --git a/src/render/stub/viewRectangle.js b/src/render/stub/viewRectangle.js new file mode 100644 index 000000000..40aaf57d4 --- /dev/null +++ b/src/render/stub/viewRectangle.js @@ -0,0 +1,167 @@ +/** + * @module render/stub/viewRectangle + * @description The geodetic rectangle in view, as the default renderer + * computes it: the canvas corners picked on the ellipsoid, with the horizon + * standing in for a corner that misses, the whole globe when fewer than two + * corners land, and the full longitude span when the view goes over a pole. + */ +import { + TWO_PI, + WGS84, + add, + cross, + dot, + magnitude, + normalize, + scale, + sub, + toCartographicRadians, + toDegrees, + vec, +} from './math.js'; + +const EPSILON9 = 1e-9; +const EPSILON10 = 1e-10; +/** The reference renderer's epsilon test: absolute and relative at once. */ +const equalsEpsilon = (a, b, eps) => + Math.abs(a - b) <= eps * Math.max(1, Math.abs(a), Math.abs(b)); + +const WHOLE_GLOBE = Object.freeze({ + west: -180, + south: -90, + east: 180, + north: 90, +}); + +/** Whether the ellipsoid's bounding sphere lies wholly outside the view frustum. */ +function globeOutsideFrustum(pose) { + const { position, direction, up, near, far } = pose; + const top = near * Math.tan(pose.fovy * 0.5); + const right = pose.aspectRatio * top; + const r = cross(direction, up); + const nearCenter = add(position, scale(direction, near)); + const farCenter = add(position, scale(direction, far)); + const sideNormals = [ + normalize( + cross(normalize(sub(add(nearCenter, scale(r, -right)), position)), up), + ), + normalize(cross(up, sub(add(nearCenter, scale(r, right)), position))), + normalize(cross(r, sub(add(nearCenter, scale(up, -top)), position))), + normalize(cross(sub(add(nearCenter, scale(up, top)), position), r)), + ]; + // Each plane's distance from the globe's centre (the origin). + const distances = [ + ...sideNormals.map((n) => -dot(n, position)), + -dot(direction, nearCenter), + dot(direction, farCenter), + ]; + const radius = WGS84.radii.x; + return distances.some((d) => d < -radius); +} + +/** Where the ellipsoid's limb meets the view's four quadrants, from the eye. */ +function horizonQuad(p) { + const radii = WGS84.radii; + const q = vec( + p.x * WGS84.oneOverRadii.x, + p.y * WGS84.oneOverRadii.y, + p.z * WGS84.oneOverRadii.z, + ); + const qMagnitude = magnitude(q); + const qUnit = normalize(q); + let eUnit; + let nUnit; + const z = vec(0, 0, 1); + if ( + equalsEpsilon(qUnit.x, 0, EPSILON10) && + equalsEpsilon(qUnit.y, 0, EPSILON10) && + equalsEpsilon(qUnit.z, 1, EPSILON10) + ) { + eUnit = vec(0, 1, 0); + nUnit = vec(0, 0, 1); + } else { + eUnit = normalize(cross(z, qUnit)); + nUnit = normalize(cross(qUnit, eUnit)); + } + const wMagnitude = Math.sqrt(dot(q, q) - 1); + const center = scale(qUnit, 1 / qMagnitude); + const s = wMagnitude / qMagnitude; + const eastOffset = scale(eUnit, s); + const northOffset = scale(nUnit, s); + const toWorld = (v) => vec(v.x * radii.x, v.y * radii.y, v.z * radii.z); + return [ + toWorld(sub(add(center, northOffset), eastOffset)), + toWorld(sub(sub(center, northOffset), eastOffset)), + toWorld(add(sub(center, northOffset), eastOffset)), + toWorld(add(add(center, northOffset), eastOffset)), + ]; +} + +/** + * @param {{position: object, direction: object, up: object, fovy: number, + * aspectRatio: number, near: number, far: number}} pose The camera's pose. + * @param {{width: number, height: number}} viewport CSS pixels. + * @param {(x: number, y: number) => object|null} pickEllipsoid + * @returns {{west: number, south: number, east: number, north: number}|null} Degrees. + */ +export function computeViewRectangle(pose, viewport, pickEllipsoid) { + if (globeOutsideFrustum(pose)) return null; + const { width, height } = viewport; + const horizon = horizonQuad(pose.position); + const corners = [ + [0, 0], + [0, height], + [width, height], + [width, 0], + ]; + let hits = 0; + const cartos = corners.map(([x, y], i) => { + const hit = pickEllipsoid(x, y); + if (hit) hits += 1; + return toCartographicRadians(hit ?? horizon[i]); + }); + if (hits < 2) return { ...WHOLE_GLOBE }; + + let west = Infinity; + let east = -Infinity; + let westOverIDL = Infinity; + let eastOverIDL = -Infinity; + let south = Infinity; + let north = -Infinity; + for (const c of cartos) { + west = Math.min(west, c.lon); + east = Math.max(east, c.lon); + south = Math.min(south, c.lat); + north = Math.max(north, c.lat); + const lonAdjusted = c.lon >= 0 ? c.lon : c.lon + TWO_PI; + westOverIDL = Math.min(westOverIDL, lonAdjusted); + eastOverIDL = Math.max(eastOverIDL, lonAdjusted); + } + if (east - west > eastOverIDL - westOverIDL) { + west = westOverIDL; + east = eastOverIDL; + if (east > Math.PI) east -= TWO_PI; + if (west > Math.PI) west -= TWO_PI; + } + + // Over a pole the corners circle it: the longitude turns a full circle. + let turned = 0; + let lastLon = cartos[3].lon; + for (const c of cartos) { + const diff = Math.abs(c.lon - lastLon); + turned += diff > Math.PI ? TWO_PI - diff : diff; + lastLon = c.lon; + } + if (equalsEpsilon(Math.abs(turned), TWO_PI, EPSILON9)) { + west = -Math.PI; + east = Math.PI; + if (cartos[0].lat >= 0) north = Math.PI / 2; + else south = -Math.PI / 2; + } + return { + west: toDegrees(west), + south: toDegrees(south), + east: toDegrees(east), + north: toDegrees(north), + }; +} diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index c118dd4f0..185488550 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -145,7 +145,7 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }, { nominatimStorageDir }); const standaloneProviders = config.plugins - .slice(4, -6) + .slice(4, -7) .map((plugin) => plugin.name); assert.equal( standaloneProviders.filter((name) => name === 'gemini-live-proxy').length, @@ -161,10 +161,12 @@ test('root config retains existing named exports and standalone provider order', .localProviderPlugins({ nominatimStorageDir }) .map((plugin) => plugin.name), ); - assert.equal(config.plugins.at(-7).name, 'gev-key-setup'); + assert.equal(config.plugins.at(-8).name, 'gev-key-setup'); // Token fallback follows real providers and local MCP before the API 404. - assert.equal(config.plugins.at(-6).name, 'local-mcp'); - assert.equal(config.plugins.at(-5).name, 'google-token-unavailable'); + assert.equal(config.plugins.at(-7).name, 'local-mcp'); + assert.equal(config.plugins.at(-6).name, 'google-token-unavailable'); + // Dev-only, under its own path. + assert.equal(config.plugins.at(-5).name, 'conformance-references'); assert.equal(config.plugins.at(-4).name, 'api-not-found'); // Config-only, so it sits after the provider slice rather than in it. assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs');