mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
feat(globe): add the engine contract and its test suite
src/globe/contract.js defines the engine-neutral GlobeEngine capabilities (JSDoc), createEngineEvent with Cesium.Event semantics and assertGlobeEngine. src/globe/testing adds a geo-based fake engine for shell tests and an engine-agnostic contract suite that the fake passes in Node. check-import-directions keeps the contract free of cesium, three and @navaramap, and each engine directory free of the other engine.
This commit is contained in:
1 parent
f2fcd82290
commit
528ec75f32
9 files changed
+2036
-1
No files matched your search
@@ -33,6 +33,7 @@ export inventory. Use declared exports rather than reaching into internal files.
|
||||
| `src/layers/<family>/` | Source acquisition, records and Cesium resources with explicit controller/renderer owners |
|
||||
| `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance |
|
||||
| `src/geo/` | Engine-neutral WGS84 maths, ported from Cesium, never imports Cesium |
|
||||
| `src/globe/` | Engine-neutral globe contract and its test support; engines only in `globe/cesium`, `globe/navara` |
|
||||
| `src/services/` | Supplied application operations; scene construction owns caches and cancellation |
|
||||
| `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 |
|
||||
@@ -63,7 +64,11 @@ flight/military/vessel record and ingestion exports, action schemas, the session
|
||||
interface, lifecycle and feed state, and every runtime file in `src/geo/` (the
|
||||
engine-neutral geometry ported from Cesium). The browser-global rule reserves
|
||||
platform names such as `document` and `window` in these modules; it is an
|
||||
architectural check, not a JavaScript sandbox. Common voice controls cannot depend on a
|
||||
architectural check, not a JavaScript sandbox. The globe contract
|
||||
(`src/globe/*.js` and `src/globe/testing/**`) cannot reach `cesium`,
|
||||
`@cesium/*`, `three`, `postprocessing` or `@navaramap/*`, directly or through
|
||||
helpers, nor import an engine implementation; `src/globe/cesium/**` imports no
|
||||
Navara/three package and `src/globe/navara/**` no Cesium package. Common voice controls cannot depend on a
|
||||
Realtime protocol implementation. Negative fixtures cover indirect helpers,
|
||||
self-package imports, symlinks and unreachable files.
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ const source = (file) =>
|
||||
/^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file);
|
||||
// Engine-neutral geometry ported from Cesium; every runtime file is a portable root.
|
||||
const geo = (file) => file.startsWith('src/geo/');
|
||||
// The engine-neutral globe contract and its test support; engines live below it.
|
||||
const globeContract = (file) =>
|
||||
/^src\/globe\/[^/]+$/.test(file) || file.startsWith('src/globe/testing/');
|
||||
const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file);
|
||||
const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/;
|
||||
// Navara renders through three.js and its postprocessing peer.
|
||||
const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
|
||||
const renderer = (file) =>
|
||||
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
|
||||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
|
||||
@@ -231,6 +238,35 @@ export function checkImportDirections(root) {
|
||||
}
|
||||
visit(start);
|
||||
}
|
||||
// The globe contract reaches no engine, directly or through helpers; each
|
||||
// engine implementation imports only its own engine's packages.
|
||||
const globeSeen = new Set();
|
||||
function globe(file) {
|
||||
if (globeSeen.has(file) || !code.test(file)) return;
|
||||
globeSeen.add(file);
|
||||
const info = load(file);
|
||||
if (!info) return;
|
||||
for (const specifier of info.external) {
|
||||
if (cesiumPackage.test(specifier) || navaraPackage.test(specifier))
|
||||
report(file, `globe contract reaches engine dependency: ${specifier}`);
|
||||
}
|
||||
for (const to of info.edges) {
|
||||
if (globeEngine(to))
|
||||
report(file, `globe contract imports engine implementation: ${to}`);
|
||||
else globe(to);
|
||||
}
|
||||
}
|
||||
for (const file of files.filter(globeContract)) globe(file);
|
||||
for (const file of files.filter(globeEngine)) {
|
||||
const navara = file.startsWith('src/globe/navara/');
|
||||
for (const specifier of load(file)?.external || []) {
|
||||
if ((navara ? cesiumPackage : navaraPackage).test(specifier))
|
||||
report(
|
||||
file,
|
||||
`${navara ? 'Navara' : 'Cesium'} globe engine imports ${specifier}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Common voice controls may use their DOM view, but never a protocol implementation.
|
||||
const voiceSeen = new Set();
|
||||
function voice(file) {
|
||||
|
||||
@@ -719,6 +719,8 @@
|
||||
"scripts/module-analysis.mjs",
|
||||
"scripts/check-import-directions.mjs",
|
||||
"src/tooling/importDirections.test.mjs",
|
||||
"src/globe/contract.test.mjs",
|
||||
"src/globe/testing/fakeEngine.test.mjs",
|
||||
"src/sources/reference.js",
|
||||
"src/sources/reference.test.mjs",
|
||||
"src/search/nominatim.js",
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// The engine-neutral globe contract. The application shell talks to a globe
|
||||
// engine (Cesium today, Navara next) only through these small capability
|
||||
// objects. Positions are src/geo values, angles are radians and screen
|
||||
// coordinates are CSS pixels from the canvas's top-left corner. This module
|
||||
// and src/globe/testing/** never import cesium, three or @navaramap/*; see
|
||||
// docs/CODE-BOUNDARIES.md.
|
||||
|
||||
/**
|
||||
* @typedef {{ x: number, y: number }} Cartesian2
|
||||
* @typedef {{ x: number, y: number, z: number }} Cartesian3
|
||||
* @typedef {{ longitude: number, latitude: number, height: number }} Cartographic
|
||||
* @typedef {(...args: any[]) => void} Listener
|
||||
*
|
||||
* @typedef {object} EngineEvent
|
||||
* @property {(listener: Listener) => () => void} addEventListener
|
||||
* Returns a function that removes this registration (idempotent).
|
||||
* @property {(listener: Listener) => boolean} removeEventListener
|
||||
*
|
||||
* @typedef {object} CameraPose
|
||||
* @property {Cartesian3} position
|
||||
* @property {Cartographic} cartographic
|
||||
* @property {number} heading
|
||||
* @property {number} pitch
|
||||
* @property {number} roll
|
||||
* @property {Cartesian3} direction
|
||||
* @property {Cartesian3} up
|
||||
* @property {Cartesian3} right
|
||||
* @property {number} fovy Vertical field of view.
|
||||
*
|
||||
* @typedef {{ heading: number, pitch: number, roll: number }
|
||||
* | { direction: Cartesian3, up: Cartesian3 }} Orientation
|
||||
*
|
||||
* @typedef {object} FlightOptions
|
||||
* @property {Cartesian3} destination
|
||||
* @property {Orientation} [orientation]
|
||||
* @property {number} [duration] Seconds; 0 lands on the next frame.
|
||||
* @property {'cubicInOut'|'sinusoidalInOut'|'linear'} [easing]
|
||||
* @property {() => void} [complete]
|
||||
* @property {() => void} [cancel] Called by cancelFlight or a newer flight.
|
||||
*
|
||||
* @typedef {object} GlobeCamera
|
||||
* @property {() => CameraPose} pose Fresh values on every call.
|
||||
* @property {(view: { destination: Cartesian3, orientation?: Orientation }) => void} setView
|
||||
* @property {(options: FlightOptions) => void} flyTo
|
||||
* @property {(sphere: { center: Cartesian3, radius: number }, options?: { heading?: number, pitch?: number, range?: number, duration?: number, complete?: () => void, cancel?: () => void }) => void} flyToSphere
|
||||
* @property {(target: Cartesian3, offset: { heading: number, pitch: number, range: number }) => void} lookAt
|
||||
* @property {() => void} release Leaves lookAt (Cesium's lookAtTransform(IDENTITY)).
|
||||
* @property {() => void} cancelFlight
|
||||
* @property {() => ({ west: number, south: number, east: number, north: number } | undefined)} viewRectangle
|
||||
* @property {(screen: Cartesian2) => (Cartesian3 | undefined)} pickEllipsoid
|
||||
* @property {(screen: Cartesian2) => { origin: Cartesian3, direction: Cartesian3 }} pickRay
|
||||
* @property {EngineEvent} changed Listener receives the change fraction.
|
||||
* @property {EngineEvent} moveStart
|
||||
* @property {EngineEvent} moveEnd
|
||||
* @property {number} percentageChanged
|
||||
*
|
||||
* @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType
|
||||
* @typedef {{ position?: Cartesian2, startPosition?: Cartesian2, endPosition?: Cartesian2 }} InputEvent
|
||||
*
|
||||
* @typedef {object} GlobeEngine
|
||||
* @property {'cesium'|'navara'} kind
|
||||
* @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host
|
||||
* @property {GlobeCamera} camera
|
||||
* @property {{ pick: (screen: Cartesian2, width?: number, height?: number) => ({ id: any, primitive: any } | undefined), drillPick: (screen: Cartesian2, limit?: number) => any[], pickPosition: (screen: Cartesian2) => (Cartesian3 | undefined), pickPositionSupported: boolean }} picker
|
||||
* @property {{ worldToWindow: (position: Cartesian3, result?: Cartesian2) => (Cartesian2 | undefined) }} projector
|
||||
* @property {{ preUpdate: EngineEvent, preRender: EngineEvent, postRender: EngineEvent }} frames Listeners receive a timestamp in ms.
|
||||
* @property {{ request: () => void, setContinuous: (enabled: boolean) => void, isContinuous: () => boolean }} render
|
||||
* @property {{ sampleHeight: (cartographic: Cartographic, exclude?: any[]) => (number | undefined), supported: boolean, tilesLoaded: () => boolean }} surface
|
||||
* @property {{ on: (type: InputType, listener: (event: InputEvent) => void) => () => void, setCameraInputs: (enabled: boolean) => void }} input
|
||||
* @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits
|
||||
* @property {{ setBaseImagery: (source: object) => void, setTerrain: (source: object) => void, setPhotorealTiles: (source: object | null) => void, photorealActive: () => boolean }} maps
|
||||
* Sources are maps/registry.js descriptions; the engine builds its own providers.
|
||||
* @property {() => void} destroy
|
||||
*/
|
||||
|
||||
export const ENGINE_KINDS = Object.freeze(['cesium', 'navara']);
|
||||
export const INPUT_TYPES = Object.freeze([
|
||||
'click',
|
||||
'doubleClick',
|
||||
'move',
|
||||
'down',
|
||||
'up',
|
||||
]);
|
||||
export const FLIGHT_EASINGS = Object.freeze([
|
||||
'cubicInOut',
|
||||
'sinusoidalInOut',
|
||||
'linear',
|
||||
]);
|
||||
|
||||
/**
|
||||
* An event with Cesium.Event semantics: listeners run in registration order,
|
||||
* removal during dispatch is deferred safely, and listeners added during
|
||||
* dispatch run from the next raise. `raiseEvent` is for engine implementations.
|
||||
*/
|
||||
export function createEngineEvent() {
|
||||
let entries = [];
|
||||
let depth = 0;
|
||||
let removedDuringRaise = false;
|
||||
const compact = () => {
|
||||
if (depth || !removedDuringRaise) return;
|
||||
entries = entries.filter((entry) => entry.listener);
|
||||
removedDuringRaise = false;
|
||||
};
|
||||
const removeEntry = (entry) => {
|
||||
if (!entry.listener) return false;
|
||||
entry.listener = null;
|
||||
if (depth) removedDuringRaise = true;
|
||||
else entries.splice(entries.indexOf(entry), 1);
|
||||
return true;
|
||||
};
|
||||
return {
|
||||
get numberOfListeners() {
|
||||
return entries.reduce(
|
||||
(count, entry) => count + (entry.listener ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
},
|
||||
addEventListener(listener) {
|
||||
if (typeof listener !== 'function')
|
||||
throw new TypeError('Engine event listener must be a function');
|
||||
const entry = { listener };
|
||||
entries.push(entry);
|
||||
return () => {
|
||||
removeEntry(entry);
|
||||
};
|
||||
},
|
||||
removeEventListener(listener) {
|
||||
const entry = entries.find(
|
||||
(candidate) => candidate.listener === listener,
|
||||
);
|
||||
return entry ? removeEntry(entry) : false;
|
||||
},
|
||||
raiseEvent(...args) {
|
||||
const snapshot = entries;
|
||||
const length = snapshot.length;
|
||||
depth++;
|
||||
try {
|
||||
for (let index = 0; index < length; index++) {
|
||||
const listener = snapshot[index].listener;
|
||||
if (listener) listener(...args);
|
||||
}
|
||||
} finally {
|
||||
depth--;
|
||||
compact();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const isFunction = (value) => typeof value === 'function';
|
||||
const isObject = (value) =>
|
||||
value != null && (typeof value === 'object' || typeof value === 'function');
|
||||
const isEvent = (value) =>
|
||||
isObject(value) &&
|
||||
isFunction(value.addEventListener) &&
|
||||
isFunction(value.removeEventListener);
|
||||
const isBoolean = (value) => typeof value === 'boolean';
|
||||
const isNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
// Capability → member → predicate. Members not listed here are free extensions.
|
||||
const SHAPE = {
|
||||
host: { container: isObject, canvas: isObject, size: isFunction },
|
||||
camera: {
|
||||
pose: isFunction,
|
||||
setView: isFunction,
|
||||
flyTo: isFunction,
|
||||
flyToSphere: isFunction,
|
||||
lookAt: isFunction,
|
||||
release: isFunction,
|
||||
cancelFlight: isFunction,
|
||||
viewRectangle: isFunction,
|
||||
pickEllipsoid: isFunction,
|
||||
pickRay: isFunction,
|
||||
changed: isEvent,
|
||||
moveStart: isEvent,
|
||||
moveEnd: isEvent,
|
||||
percentageChanged: isNumber,
|
||||
},
|
||||
picker: {
|
||||
pick: isFunction,
|
||||
drillPick: isFunction,
|
||||
pickPosition: isFunction,
|
||||
pickPositionSupported: isBoolean,
|
||||
},
|
||||
projector: { worldToWindow: isFunction },
|
||||
frames: { preUpdate: isEvent, preRender: isEvent, postRender: isEvent },
|
||||
render: {
|
||||
request: isFunction,
|
||||
setContinuous: isFunction,
|
||||
isContinuous: isFunction,
|
||||
},
|
||||
surface: {
|
||||
sampleHeight: isFunction,
|
||||
supported: isBoolean,
|
||||
tilesLoaded: isFunction,
|
||||
},
|
||||
input: { on: isFunction, setCameraInputs: isFunction },
|
||||
credits: { addStatic: isFunction },
|
||||
maps: {
|
||||
setBaseImagery: isFunction,
|
||||
setTerrain: isFunction,
|
||||
setPhotorealTiles: isFunction,
|
||||
photorealActive: isFunction,
|
||||
},
|
||||
};
|
||||
|
||||
/** The capability paths the contract requires, e.g. 'camera.flyTo'. */
|
||||
export const GLOBE_ENGINE_MEMBERS = Object.freeze([
|
||||
'kind',
|
||||
...Object.entries(SHAPE).flatMap(([capability, members]) =>
|
||||
Object.keys(members).map((member) => `${capability}.${member}`),
|
||||
),
|
||||
'destroy',
|
||||
]);
|
||||
|
||||
/** Throws a TypeError naming every missing or mistyped member; returns the engine. */
|
||||
export function assertGlobeEngine(engine) {
|
||||
if (!isObject(engine)) throw new TypeError('GlobeEngine must be an object');
|
||||
const problems = [];
|
||||
if (!ENGINE_KINDS.includes(engine.kind)) problems.push('kind');
|
||||
for (const [capability, members] of Object.entries(SHAPE)) {
|
||||
const value = engine[capability];
|
||||
if (!isObject(value)) {
|
||||
problems.push(capability);
|
||||
continue;
|
||||
}
|
||||
for (const [member, valid] of Object.entries(members))
|
||||
if (!valid(value[member])) problems.push(`${capability}.${member}`);
|
||||
}
|
||||
if (!isFunction(engine.destroy)) problems.push('destroy');
|
||||
if (problems.length)
|
||||
throw new TypeError(
|
||||
`GlobeEngine is missing or mistypes: ${problems.join(', ')}`,
|
||||
);
|
||||
return engine;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
ENGINE_KINDS,
|
||||
INPUT_TYPES,
|
||||
assertGlobeEngine,
|
||||
createEngineEvent,
|
||||
} from './contract.js';
|
||||
import { createFakeEngine } from './testing/fakeEngine.js';
|
||||
import {
|
||||
createContractSuite,
|
||||
runContractSuite,
|
||||
} from './testing/contractSuite.mjs';
|
||||
|
||||
test('engine event adds, raises and removes listeners in registration order', () => {
|
||||
const event = createEngineEvent();
|
||||
const seen = [];
|
||||
const a = (value) => seen.push(['a', value]);
|
||||
const b = (value) => seen.push(['b', value]);
|
||||
const removeA = event.addEventListener(a);
|
||||
event.addEventListener(b);
|
||||
assert.equal(typeof removeA, 'function');
|
||||
assert.equal(event.numberOfListeners, 2);
|
||||
event.raiseEvent(1);
|
||||
removeA();
|
||||
event.raiseEvent(2);
|
||||
assert.equal(event.removeEventListener(b), true);
|
||||
assert.equal(event.removeEventListener(b), false);
|
||||
event.raiseEvent(3);
|
||||
assert.deepEqual(seen, [
|
||||
['a', 1],
|
||||
['b', 1],
|
||||
['b', 2],
|
||||
]);
|
||||
assert.equal(event.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('engine event passes every argument and rejects non-functions', () => {
|
||||
const event = createEngineEvent();
|
||||
let args;
|
||||
event.addEventListener((...values) => (args = values));
|
||||
event.raiseEvent(1, 'two', null);
|
||||
assert.deepEqual(args, [1, 'two', null]);
|
||||
assert.throws(() => event.addEventListener(null), TypeError);
|
||||
});
|
||||
|
||||
test('removing listeners during dispatch is safe, as with Cesium.Event', () => {
|
||||
const event = createEngineEvent();
|
||||
const seen = [];
|
||||
let removeLater;
|
||||
const removeSelf = event.addEventListener(() => {
|
||||
seen.push('self');
|
||||
removeSelf();
|
||||
});
|
||||
event.addEventListener(() => {
|
||||
seen.push('remover');
|
||||
removeLater();
|
||||
});
|
||||
removeLater = event.addEventListener(() => seen.push('later'));
|
||||
event.addEventListener(() => seen.push('last'));
|
||||
event.raiseEvent();
|
||||
event.raiseEvent();
|
||||
assert.deepEqual(seen, ['self', 'remover', 'last', 'remover', 'last']);
|
||||
assert.equal(event.numberOfListeners, 2);
|
||||
});
|
||||
|
||||
test('listeners added during dispatch run from the next raise', () => {
|
||||
const event = createEngineEvent();
|
||||
const seen = [];
|
||||
event.addEventListener(() => {
|
||||
seen.push('first');
|
||||
if (seen.length === 1) event.addEventListener(() => seen.push('added'));
|
||||
});
|
||||
event.raiseEvent();
|
||||
event.raiseEvent();
|
||||
assert.deepEqual(seen, ['first', 'first', 'added']);
|
||||
});
|
||||
|
||||
test('a listener can be added again after removal, and each handle removes its own entry', () => {
|
||||
const event = createEngineEvent();
|
||||
let count = 0;
|
||||
const listener = () => count++;
|
||||
const first = event.addEventListener(listener);
|
||||
first();
|
||||
first();
|
||||
const second = event.addEventListener(listener);
|
||||
const third = event.addEventListener(listener);
|
||||
event.raiseEvent();
|
||||
assert.equal(count, 2);
|
||||
second();
|
||||
event.raiseEvent();
|
||||
assert.equal(count, 3);
|
||||
third();
|
||||
event.raiseEvent();
|
||||
assert.equal(count, 3);
|
||||
});
|
||||
|
||||
test('a throwing listener does not leave the event stuck in dispatch', () => {
|
||||
const event = createEngineEvent();
|
||||
const remove = event.addEventListener(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
assert.throws(() => event.raiseEvent(), /boom/);
|
||||
remove();
|
||||
assert.equal(event.numberOfListeners, 0);
|
||||
let called = false;
|
||||
event.addEventListener(() => (called = true));
|
||||
event.raiseEvent();
|
||||
assert.equal(called, true);
|
||||
});
|
||||
|
||||
test('assertGlobeEngine accepts a complete engine and names every missing capability', () => {
|
||||
const engine = createFakeEngine();
|
||||
assert.equal(assertGlobeEngine(engine), engine);
|
||||
assert.deepEqual(ENGINE_KINDS, ['cesium', 'navara']);
|
||||
assert.deepEqual(INPUT_TYPES, ['click', 'doubleClick', 'move', 'down', 'up']);
|
||||
|
||||
const broken = {
|
||||
...engine,
|
||||
kind: 'webgl',
|
||||
camera: { ...engine.camera, flyTo: undefined, moveEnd: {} },
|
||||
frames: undefined,
|
||||
surface: { ...engine.surface, supported: 'yes' },
|
||||
};
|
||||
delete broken.maps;
|
||||
assert.throws(
|
||||
() => assertGlobeEngine(broken),
|
||||
(error) => {
|
||||
assert.ok(error instanceof TypeError);
|
||||
for (const path of [
|
||||
'kind',
|
||||
'camera.flyTo',
|
||||
'camera.moveEnd',
|
||||
'frames',
|
||||
'surface.supported',
|
||||
'maps',
|
||||
])
|
||||
assert.match(
|
||||
error.message,
|
||||
new RegExp(`\\b${path.replace('.', '\\.')}\\b`),
|
||||
);
|
||||
assert.doesNotMatch(error.message, /camera\.setView/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.throws(() => assertGlobeEngine(null), TypeError);
|
||||
});
|
||||
|
||||
test('the fake engine passes the engine contract suite', async (t) => {
|
||||
const engine = createFakeEngine();
|
||||
const checks = createContractSuite(engine, {
|
||||
waitFrames: async (count) => engine.fake.frames(count),
|
||||
});
|
||||
assert.ok(checks.length >= 15);
|
||||
assert.equal(new Set(checks.map((check) => check.name)).size, checks.length);
|
||||
for (const check of checks) await t.test(check.name, () => check.run());
|
||||
});
|
||||
|
||||
test('the contract suite reports failures of a misbehaving engine', async () => {
|
||||
const engine = createFakeEngine();
|
||||
// An engine whose projection ignores the camera must fail the projector checks.
|
||||
engine.fake.script('worldToWindow', () => ({ x: 0, y: 0 }));
|
||||
const results = await runContractSuite(engine, {
|
||||
waitFrames: async (count) => engine.fake.frames(count),
|
||||
});
|
||||
const failed = results.filter((result) => !result.ok);
|
||||
assert.ok(failed.some((result) => /worldToWindow/.test(result.name)));
|
||||
assert.ok(failed.every((result) => result.error instanceof Error));
|
||||
assert.ok(results.some((result) => result.ok));
|
||||
});
|
||||
@@ -0,0 +1,675 @@
|
||||
// Behavioural checks every GlobeEngine must pass. The suite is engine-agnostic
|
||||
// and imports nothing from Node, so it runs in Node against the fake engine and
|
||||
// in a headless browser against the Cesium and Navara engines:
|
||||
//
|
||||
// const checks = createContractSuite(engine, { waitFrames });
|
||||
// for (const { name, run } of checks) await run();
|
||||
//
|
||||
// `waitFrames(count)` resolves after the engine has rendered `count` frames.
|
||||
// Checks request renders themselves, so engines in request-render mode work.
|
||||
// Each check sets the view it needs; checks may leave the camera anywhere.
|
||||
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { INPUT_TYPES, assertGlobeEngine } from '../contract.js';
|
||||
|
||||
const { cartesian2, cartesian3, cartographic, ellipsoid, math } = geo;
|
||||
const WGS84 = ellipsoid.WGS84;
|
||||
|
||||
class ContractError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'ContractError';
|
||||
}
|
||||
}
|
||||
|
||||
function ensure(condition, message) {
|
||||
if (!condition) throw new ContractError(message);
|
||||
}
|
||||
|
||||
function near(actual, expected, tolerance, label) {
|
||||
ensure(
|
||||
typeof actual === 'number' && Math.abs(actual - expected) <= tolerance,
|
||||
`${label}: ${actual} is not within ${tolerance} of ${expected}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Smallest difference between two angles, in radians.
|
||||
const angleBetween = (a, b) => Math.abs(math.negativePiToPi(a - b));
|
||||
|
||||
const isCartesian3 = (value) =>
|
||||
value != null &&
|
||||
Number.isFinite(value.x) &&
|
||||
Number.isFinite(value.y) &&
|
||||
Number.isFinite(value.z);
|
||||
|
||||
const unit = (vector) => cartesian3.normalize(vector, cartesian3.create());
|
||||
const along = (origin, direction, distance) =>
|
||||
cartesian3.add(
|
||||
origin,
|
||||
cartesian3.multiplyByScalar(direction, distance, cartesian3.create()),
|
||||
cartesian3.create(),
|
||||
);
|
||||
|
||||
/**
|
||||
* @param {import('../contract.js').GlobeEngine} engine
|
||||
* @param {{ waitFrames: (count: number) => Promise<void>, maxWaitFrames?: number }} options
|
||||
* @returns {{ name: string, run: () => Promise<void> }[]}
|
||||
*/
|
||||
export function createContractSuite(
|
||||
engine,
|
||||
{ waitFrames, maxWaitFrames = 240 },
|
||||
) {
|
||||
if (typeof waitFrames !== 'function')
|
||||
throw new TypeError('createContractSuite needs a waitFrames(count) helper');
|
||||
const { camera } = engine;
|
||||
|
||||
const settle = async (count = 2) => {
|
||||
for (let index = 0; index < count; index++) {
|
||||
engine.render.request();
|
||||
await waitFrames(1);
|
||||
}
|
||||
};
|
||||
const until = async (predicate, label) => {
|
||||
for (let frame = 0; frame < maxWaitFrames; frame++) {
|
||||
if (predicate()) return;
|
||||
await settle(1);
|
||||
}
|
||||
ensure(
|
||||
predicate(),
|
||||
`${label} did not happen within ${maxWaitFrames} frames`,
|
||||
);
|
||||
};
|
||||
|
||||
// A view 1000 km above (20°E, 10°N) looking straight down, heading north.
|
||||
const nadirView = async (height = 1e6) => {
|
||||
camera.setView({
|
||||
destination: cartesian3.fromDegrees(20, 10, height),
|
||||
orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 },
|
||||
});
|
||||
await settle();
|
||||
};
|
||||
const centre = () => {
|
||||
const { width, height } = engine.host.size();
|
||||
return cartesian2.create(width / 2, height / 2);
|
||||
};
|
||||
|
||||
const checks = [
|
||||
[
|
||||
'shape: the engine has every contract capability',
|
||||
async () => {
|
||||
assertGlobeEngine(engine);
|
||||
},
|
||||
],
|
||||
[
|
||||
'host.size: a positive CSS size',
|
||||
async () => {
|
||||
const { width, height } = engine.host.size();
|
||||
ensure(
|
||||
width > 0 && height > 0,
|
||||
`size ${width}x${height} is not positive`,
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.setView: heading, pitch and roll round-trip through pose',
|
||||
async () => {
|
||||
const destination = cartesian3.fromDegrees(-70, 45, 25000);
|
||||
const orientation = { heading: 1.2, pitch: -0.6, roll: 0.05 };
|
||||
camera.setView({ destination, orientation });
|
||||
await settle();
|
||||
const pose = camera.pose();
|
||||
near(
|
||||
cartesian3.distance(pose.position, destination),
|
||||
0,
|
||||
0.01,
|
||||
'position error (m)',
|
||||
);
|
||||
near(
|
||||
angleBetween(pose.heading, orientation.heading),
|
||||
0,
|
||||
1e-6,
|
||||
'heading error',
|
||||
);
|
||||
near(pose.pitch, orientation.pitch, 1e-6, 'pitch');
|
||||
near(angleBetween(pose.roll, orientation.roll), 0, 1e-6, 'roll error');
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.setView: direction and up orientation',
|
||||
async () => {
|
||||
const destination = cartesian3.fromDegrees(5, 50, 3e5);
|
||||
const direction = unit(
|
||||
cartesian3.negate(destination, cartesian3.create()),
|
||||
);
|
||||
const north = cartesian3.cross(
|
||||
cartesian3.cross(direction, cartesian3.UNIT_Z, cartesian3.create()),
|
||||
direction,
|
||||
cartesian3.create(),
|
||||
);
|
||||
const up = unit(north);
|
||||
camera.setView({ destination, orientation: { direction, up } });
|
||||
await settle();
|
||||
const pose = camera.pose();
|
||||
near(
|
||||
cartesian3.dot(pose.direction, direction),
|
||||
1,
|
||||
1e-9,
|
||||
'direction·expected',
|
||||
);
|
||||
near(cartesian3.dot(pose.up, up), 1, 1e-9, 'up·expected');
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.pose: cartographic, frame vectors and fovy are consistent',
|
||||
async () => {
|
||||
camera.setView({
|
||||
destination: cartesian3.fromDegrees(139.7, 35.6, 8000),
|
||||
orientation: { heading: 4, pitch: -0.3, roll: 0 },
|
||||
});
|
||||
await settle();
|
||||
const pose = camera.pose();
|
||||
const expected = cartographic.fromCartesian(pose.position, WGS84);
|
||||
near(
|
||||
pose.cartographic.longitude,
|
||||
expected.longitude,
|
||||
1e-9,
|
||||
'longitude',
|
||||
);
|
||||
near(pose.cartographic.latitude, expected.latitude, 1e-9, 'latitude');
|
||||
near(pose.cartographic.height, expected.height, 0.01, 'height');
|
||||
for (const [name, vector] of [
|
||||
['direction', pose.direction],
|
||||
['up', pose.up],
|
||||
['right', pose.right],
|
||||
])
|
||||
near(cartesian3.magnitude(vector), 1, 1e-6, `|${name}|`);
|
||||
near(cartesian3.dot(pose.direction, pose.up), 0, 1e-6, 'direction·up');
|
||||
const right = cartesian3.cross(
|
||||
pose.direction,
|
||||
pose.up,
|
||||
cartesian3.create(),
|
||||
);
|
||||
near(
|
||||
cartesian3.dot(right, pose.right),
|
||||
1,
|
||||
1e-6,
|
||||
'right = direction × up',
|
||||
);
|
||||
ensure(
|
||||
pose.fovy > 0 && pose.fovy < Math.PI,
|
||||
`fovy ${pose.fovy} out of range`,
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.percentageChanged: readable and writable',
|
||||
async () => {
|
||||
const previous = camera.percentageChanged;
|
||||
try {
|
||||
camera.percentageChanged = 0.125;
|
||||
near(camera.percentageChanged, 0.125, 0, 'percentageChanged');
|
||||
} finally {
|
||||
camera.percentageChanged = previous;
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera events: changed, moveStart and moveEnd follow a move',
|
||||
async () => {
|
||||
await nadirView();
|
||||
const previous = camera.percentageChanged;
|
||||
const seen = { changed: 0, moveStart: 0, moveEnd: 0 };
|
||||
const removers = Object.keys(seen).map((name) =>
|
||||
camera[name].addEventListener(() => seen[name]++),
|
||||
);
|
||||
try {
|
||||
camera.percentageChanged = 0.01;
|
||||
camera.setView({
|
||||
destination: cartesian3.fromDegrees(60, -30, 2e6),
|
||||
orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 },
|
||||
});
|
||||
await until(() => seen.moveEnd > 0, 'moveEnd');
|
||||
ensure(seen.moveStart > 0, 'moveStart did not fire');
|
||||
ensure(seen.changed > 0, 'changed did not fire');
|
||||
} finally {
|
||||
for (const remove of removers) remove();
|
||||
camera.percentageChanged = previous;
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
'events: removed listeners are not called',
|
||||
async () => {
|
||||
await nadirView();
|
||||
let viaHandle = 0;
|
||||
let viaRemove = 0;
|
||||
const remove = camera.moveStart.addEventListener(() => viaHandle++);
|
||||
const listener = () => viaRemove++;
|
||||
engine.frames.postRender.addEventListener(listener);
|
||||
remove();
|
||||
ensure(
|
||||
engine.frames.postRender.removeEventListener(listener) === true,
|
||||
'removeEventListener did not report removal',
|
||||
);
|
||||
await nadirView(3e6);
|
||||
await settle(3);
|
||||
ensure(
|
||||
viaHandle === 0 && viaRemove === 0,
|
||||
'a removed listener was called',
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'frames: preUpdate, preRender and postRender fire in order with timestamps',
|
||||
async () => {
|
||||
const order = [];
|
||||
const removers = ['preUpdate', 'preRender', 'postRender'].map((name) =>
|
||||
engine.frames[name].addEventListener((time) =>
|
||||
order.push([name, time]),
|
||||
),
|
||||
);
|
||||
try {
|
||||
await until(
|
||||
() => order.some(([name]) => name === 'postRender'),
|
||||
'postRender',
|
||||
);
|
||||
} finally {
|
||||
for (const remove of removers) remove();
|
||||
}
|
||||
const firstPost = order.findIndex(([name]) => name === 'postRender');
|
||||
const frame = order.slice(0, firstPost + 1);
|
||||
const index = (name) => frame.map(([entry]) => entry).lastIndexOf(name);
|
||||
ensure(index('preRender') >= 0, 'preRender did not precede postRender');
|
||||
ensure(index('preUpdate') >= 0, 'preUpdate did not precede postRender');
|
||||
ensure(
|
||||
index('preUpdate') < index('preRender'),
|
||||
'preUpdate ran after preRender',
|
||||
);
|
||||
for (const [name, time] of order)
|
||||
ensure(
|
||||
Number.isFinite(time),
|
||||
`${name} timestamp ${time} is not a number`,
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'render: continuous mode toggles',
|
||||
async () => {
|
||||
const previous = engine.render.isContinuous();
|
||||
try {
|
||||
engine.render.setContinuous(true);
|
||||
ensure(
|
||||
engine.render.isContinuous() === true,
|
||||
'continuous not enabled',
|
||||
);
|
||||
engine.render.setContinuous(false);
|
||||
ensure(
|
||||
engine.render.isContinuous() === false,
|
||||
'continuous not disabled',
|
||||
);
|
||||
} finally {
|
||||
engine.render.setContinuous(previous);
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.flyTo: duration 0 lands on the destination and completes',
|
||||
async () => {
|
||||
await nadirView();
|
||||
const destination = cartesian3.fromDegrees(-3.7, 40.4, 50000);
|
||||
const outcome = [];
|
||||
camera.flyTo({
|
||||
destination,
|
||||
orientation: { heading: 0.5, pitch: -0.8, roll: 0 },
|
||||
duration: 0,
|
||||
complete: () => outcome.push('complete'),
|
||||
cancel: () => outcome.push('cancel'),
|
||||
});
|
||||
await until(() => outcome.length > 0, 'flight completion');
|
||||
ensure(outcome.join() === 'complete', `flight ended with ${outcome}`);
|
||||
const pose = camera.pose();
|
||||
near(
|
||||
cartesian3.distance(pose.position, destination),
|
||||
0,
|
||||
0.01,
|
||||
'position error (m)',
|
||||
);
|
||||
near(angleBetween(pose.heading, 0.5), 0, 1e-6, 'heading error');
|
||||
near(pose.pitch, -0.8, 1e-6, 'pitch');
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.flyTo: cancelFlight calls cancel and never complete',
|
||||
async () => {
|
||||
await nadirView();
|
||||
const outcome = [];
|
||||
camera.flyTo({
|
||||
destination: cartesian3.fromDegrees(100, 0, 5e6),
|
||||
duration: 5,
|
||||
easing: 'linear',
|
||||
complete: () => outcome.push('complete'),
|
||||
cancel: () => outcome.push('cancel'),
|
||||
});
|
||||
await settle(2);
|
||||
camera.cancelFlight();
|
||||
await until(() => outcome.length > 0, 'flight cancellation');
|
||||
await settle(3);
|
||||
ensure(outcome.join() === 'cancel', `flight ended with ${outcome}`);
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.flyTo: a newer flight cancels the previous one',
|
||||
async () => {
|
||||
await nadirView();
|
||||
const outcome = [];
|
||||
camera.flyTo({
|
||||
destination: cartesian3.fromDegrees(100, 0, 5e6),
|
||||
duration: 5,
|
||||
complete: () => outcome.push('first complete'),
|
||||
cancel: () => outcome.push('first cancel'),
|
||||
});
|
||||
await settle(1);
|
||||
camera.flyTo({
|
||||
destination: cartesian3.fromDegrees(20, 10, 2e6),
|
||||
duration: 0,
|
||||
complete: () => outcome.push('second complete'),
|
||||
});
|
||||
await until(() => outcome.includes('second complete'), 'second flight');
|
||||
ensure(
|
||||
outcome.join() === 'first cancel,second complete',
|
||||
`flights ended with ${outcome}`,
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.flyToSphere: ends at the range, looking at the centre',
|
||||
async () => {
|
||||
await nadirView();
|
||||
const center = cartesian3.fromDegrees(2.35, 48.85, 100);
|
||||
let done = false;
|
||||
camera.flyToSphere(
|
||||
{ center, radius: 500 },
|
||||
{
|
||||
heading: 0.3,
|
||||
pitch: -0.5,
|
||||
range: 5000,
|
||||
duration: 0,
|
||||
complete: () => (done = true),
|
||||
},
|
||||
);
|
||||
await until(() => done, 'sphere flight completion');
|
||||
const pose = camera.pose();
|
||||
near(cartesian3.distance(pose.position, center), 5000, 5, 'range (m)');
|
||||
const toCenter = unit(
|
||||
cartesian3.subtract(center, pose.position, cartesian3.create()),
|
||||
);
|
||||
near(
|
||||
cartesian3.dot(toCenter, pose.direction),
|
||||
1,
|
||||
1e-6,
|
||||
'direction·toCentre',
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.lookAt: holds the range toward the target until release',
|
||||
async () => {
|
||||
await nadirView();
|
||||
const target = cartesian3.fromDegrees(-0.12, 51.5, 0);
|
||||
camera.lookAt(target, { heading: 0.4, pitch: -0.7, range: 2000 });
|
||||
await settle();
|
||||
const pose = camera.pose();
|
||||
near(cartesian3.distance(pose.position, target), 2000, 1, 'range (m)');
|
||||
const toTarget = unit(
|
||||
cartesian3.subtract(target, pose.position, cartesian3.create()),
|
||||
);
|
||||
near(
|
||||
cartesian3.dot(toTarget, pose.direction),
|
||||
1,
|
||||
1e-6,
|
||||
'direction·toTarget',
|
||||
);
|
||||
camera.release();
|
||||
await settle();
|
||||
near(
|
||||
cartesian3.distance(camera.pose().position, pose.position),
|
||||
0,
|
||||
0.01,
|
||||
'release moved the camera (m)',
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'projector.worldToWindow: a point ahead projects to the canvas centre',
|
||||
async () => {
|
||||
await nadirView();
|
||||
const pose = camera.pose();
|
||||
const window = engine.projector.worldToWindow(
|
||||
along(pose.position, pose.direction, 1000),
|
||||
);
|
||||
ensure(window, 'a point ahead did not project');
|
||||
const expected = centre();
|
||||
near(window.x, expected.x, 1, 'x (px)');
|
||||
near(window.y, expected.y, 1, 'y (px)');
|
||||
},
|
||||
],
|
||||
[
|
||||
'projector.worldToWindow: a point behind the camera is undefined',
|
||||
async () => {
|
||||
await nadirView();
|
||||
const pose = camera.pose();
|
||||
const behind = along(pose.position, pose.direction, -1000);
|
||||
ensure(
|
||||
engine.projector.worldToWindow(behind) === undefined,
|
||||
'a point behind the camera projected',
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'projector.worldToWindow: up on screen is camera up, and result is filled',
|
||||
async () => {
|
||||
await nadirView();
|
||||
const pose = camera.pose();
|
||||
const ahead = along(pose.position, pose.direction, 1000);
|
||||
const result = cartesian2.create();
|
||||
const upper = engine.projector.worldToWindow(
|
||||
along(ahead, pose.up, 100),
|
||||
result,
|
||||
);
|
||||
ensure(
|
||||
upper === result,
|
||||
'worldToWindow did not return its result argument',
|
||||
);
|
||||
const lefter = engine.projector.worldToWindow(
|
||||
along(ahead, pose.right, -100),
|
||||
);
|
||||
const expected = centre();
|
||||
ensure(
|
||||
upper.y < expected.y,
|
||||
`camera up projected below the centre (${upper.y})`,
|
||||
);
|
||||
ensure(
|
||||
lefter.x < expected.x,
|
||||
`camera left projected right of the centre (${lefter.x})`,
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.pickRay: the centre ray starts at the camera along its direction',
|
||||
async () => {
|
||||
await nadirView();
|
||||
const pose = camera.pose();
|
||||
const pickRay = camera.pickRay(centre());
|
||||
ensure(isCartesian3(pickRay?.origin), 'pickRay has no origin');
|
||||
near(
|
||||
cartesian3.distance(pickRay.origin, pose.position),
|
||||
0,
|
||||
1,
|
||||
'origin error (m)',
|
||||
);
|
||||
near(
|
||||
cartesian3.dot(unit(pickRay.direction), pose.direction),
|
||||
1,
|
||||
1e-6,
|
||||
'direction·pose',
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.pickEllipsoid: the nadir hits the surface and the sky misses',
|
||||
async () => {
|
||||
await nadirView();
|
||||
const hit = camera.pickEllipsoid(centre());
|
||||
ensure(isCartesian3(hit), 'the nadir missed the ellipsoid');
|
||||
const point = cartographic.fromCartesian(hit, WGS84);
|
||||
near(point.height, 0, 1, 'surface height (m)');
|
||||
// Off the surface, Cesium's local up is the scaled-surface normal,
|
||||
// which misses the geodetic nadir by about 0.0015° at 1000 km.
|
||||
near(math.toDegrees(point.longitude), 20, 0.01, 'longitude (deg)');
|
||||
near(math.toDegrees(point.latitude), 10, 0.01, 'latitude (deg)');
|
||||
camera.setView({
|
||||
destination: cartesian3.fromDegrees(20, 10, 1e6),
|
||||
orientation: { heading: 0, pitch: 0.6, roll: 0 },
|
||||
});
|
||||
await settle();
|
||||
ensure(
|
||||
camera.pickEllipsoid(centre()) === undefined,
|
||||
'the sky hit the ellipsoid',
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'projector.worldToWindow: round-trips camera.pickEllipsoid',
|
||||
async () => {
|
||||
await nadirView();
|
||||
const { width, height } = engine.host.size();
|
||||
const screen = cartesian2.create(width / 4, height / 3);
|
||||
const hit = camera.pickEllipsoid(screen);
|
||||
ensure(isCartesian3(hit), 'the screen point missed the ellipsoid');
|
||||
const back = engine.projector.worldToWindow(hit);
|
||||
ensure(back, 'the picked point did not project');
|
||||
near(back.x, screen.x, 1, 'x (px)');
|
||||
near(back.y, screen.y, 1, 'y (px)');
|
||||
},
|
||||
],
|
||||
[
|
||||
'camera.viewRectangle: contains the nadir point',
|
||||
async () => {
|
||||
await nadirView();
|
||||
const rectangle = camera.viewRectangle();
|
||||
ensure(rectangle, 'no view rectangle looking straight down');
|
||||
const lon = math.toRadians(20);
|
||||
const lat = math.toRadians(10);
|
||||
ensure(
|
||||
rectangle.west < lon && lon < rectangle.east,
|
||||
`longitude outside ${rectangle.west}..${rectangle.east}`,
|
||||
);
|
||||
ensure(
|
||||
rectangle.south < lat && lat < rectangle.north,
|
||||
`latitude outside ${rectangle.south}..${rectangle.north}`,
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'picker: pick, drillPick and pickPosition return contract shapes',
|
||||
async () => {
|
||||
await nadirView();
|
||||
const screen = cartesian2.create(1, 1);
|
||||
const picked = engine.picker.pick(screen);
|
||||
ensure(
|
||||
picked === undefined || 'id' in picked,
|
||||
'pick returned no id field',
|
||||
);
|
||||
const drilled = engine.picker.drillPick(screen, 3);
|
||||
ensure(Array.isArray(drilled), 'drillPick did not return an array');
|
||||
ensure(drilled.length <= 3, 'drillPick ignored its limit');
|
||||
ensure(
|
||||
typeof engine.picker.pickPositionSupported === 'boolean',
|
||||
'pickPositionSupported is not boolean',
|
||||
);
|
||||
const position = engine.picker.pickPosition(centre());
|
||||
ensure(
|
||||
position === undefined || isCartesian3(position),
|
||||
'pickPosition is not a Cartesian3',
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'surface: supported, tilesLoaded and sampleHeight shapes',
|
||||
async () => {
|
||||
ensure(
|
||||
typeof engine.surface.supported === 'boolean',
|
||||
'supported is not boolean',
|
||||
);
|
||||
ensure(
|
||||
typeof engine.surface.tilesLoaded() === 'boolean',
|
||||
'tilesLoaded is not boolean',
|
||||
);
|
||||
if (!engine.surface.supported) return;
|
||||
const value = engine.surface.sampleHeight(
|
||||
cartographic.fromDegrees(20, 10, 0),
|
||||
);
|
||||
ensure(
|
||||
value === undefined || Number.isFinite(value),
|
||||
`sampleHeight returned ${value}`,
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'input: every type subscribes and unsubscribes; camera inputs toggle',
|
||||
async () => {
|
||||
for (const type of INPUT_TYPES) {
|
||||
const off = engine.input.on(type, () => {});
|
||||
ensure(
|
||||
typeof off === 'function',
|
||||
`input.on('${type}') returned no remover`,
|
||||
);
|
||||
off();
|
||||
off();
|
||||
}
|
||||
engine.input.setCameraInputs(false);
|
||||
engine.input.setCameraInputs(true);
|
||||
},
|
||||
],
|
||||
[
|
||||
'credits.addStatic: returns an idempotent remover',
|
||||
async () => {
|
||||
const remove = engine.credits.addStatic('Contract check', {
|
||||
onScreen: false,
|
||||
});
|
||||
ensure(typeof remove === 'function', 'addStatic returned no remover');
|
||||
remove();
|
||||
remove();
|
||||
},
|
||||
],
|
||||
[
|
||||
'maps.setPhotorealTiles(null): photoreal is inactive',
|
||||
async () => {
|
||||
engine.maps.setPhotorealTiles(null);
|
||||
ensure(
|
||||
engine.maps.photorealActive() === false,
|
||||
'photoreal still active',
|
||||
);
|
||||
},
|
||||
],
|
||||
];
|
||||
return checks.map(([name, run]) => ({ name, run }));
|
||||
}
|
||||
|
||||
/** Runs every check in order; never throws for a failing check. */
|
||||
export async function runContractSuite(engine, options) {
|
||||
const results = [];
|
||||
for (const { name, run } of createContractSuite(engine, options)) {
|
||||
try {
|
||||
await run();
|
||||
results.push({ name, ok: true });
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
// A GlobeEngine built on src/geo alone, for tests of shell modules. It keeps a
|
||||
// controllable perspective camera on the WGS84 ellipsoid, projects and picks
|
||||
// against it, records every contract call and lets tests emit events, drive
|
||||
// input, advance frames and script pick/height/projection results through
|
||||
// `engine.fake`. It passes src/globe/testing/contractSuite.mjs.
|
||||
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { INPUT_TYPES, createEngineEvent } from '../contract.js';
|
||||
|
||||
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
|
||||
const WGS84 = ellipsoid.WGS84;
|
||||
const SCRIPTABLE = new Set([
|
||||
'pick',
|
||||
'drillPick',
|
||||
'pickPosition',
|
||||
'pickEllipsoid',
|
||||
'viewRectangle',
|
||||
'sampleHeight',
|
||||
'tilesLoaded',
|
||||
'worldToWindow',
|
||||
]);
|
||||
const EASINGS = {
|
||||
linear: (t) => t,
|
||||
cubicInOut: (t) =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
|
||||
sinusoidalInOut: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
|
||||
};
|
||||
|
||||
const copy3 = (value) => cartesian3.create(value.x, value.y, value.z);
|
||||
|
||||
// The local east/north/up axes at a position, in Earth-fixed coordinates.
|
||||
function localAxes(position) {
|
||||
const frame = matrix4.create();
|
||||
geo.transforms.eastNorthUpToFixedFrame(position, WGS84, frame);
|
||||
return {
|
||||
east: cartesian3.create(frame[0], frame[1], frame[2]),
|
||||
north: cartesian3.create(frame[4], frame[5], frame[6]),
|
||||
up: cartesian3.create(frame[8], frame[9], frame[10]),
|
||||
};
|
||||
}
|
||||
|
||||
const combine = (axes, e, n, u) =>
|
||||
cartesian3.create(
|
||||
axes.east.x * e + axes.north.x * n + axes.up.x * u,
|
||||
axes.east.y * e + axes.north.y * n + axes.up.y * u,
|
||||
axes.east.z * e + axes.north.z * n + axes.up.z * u,
|
||||
);
|
||||
|
||||
// Direction and up for a heading/pitch/roll in the local frame at position.
|
||||
// Heading 0 looks north, positive pitch looks up, as in Cesium.
|
||||
function orientationVectors(position, heading, pitch, roll) {
|
||||
const axes = localAxes(position);
|
||||
const cosPitch = Math.cos(pitch);
|
||||
const direction = combine(
|
||||
axes,
|
||||
Math.sin(heading) * cosPitch,
|
||||
Math.cos(heading) * cosPitch,
|
||||
Math.sin(pitch),
|
||||
);
|
||||
const levelRight = combine(axes, Math.cos(heading), -Math.sin(heading), 0);
|
||||
const levelUp = cartesian3.cross(levelRight, direction, cartesian3.create());
|
||||
const up = cartesian3.create();
|
||||
cartesian3.add(
|
||||
cartesian3.multiplyByScalar(levelUp, Math.cos(roll), cartesian3.create()),
|
||||
cartesian3.multiplyByScalar(
|
||||
levelRight,
|
||||
-Math.sin(roll),
|
||||
cartesian3.create(),
|
||||
),
|
||||
up,
|
||||
);
|
||||
return { direction, up: cartesian3.normalize(up, up) };
|
||||
}
|
||||
|
||||
function headingPitchRoll(position, direction, up) {
|
||||
const axes = localAxes(position);
|
||||
const e = cartesian3.dot(direction, axes.east);
|
||||
const n = cartesian3.dot(direction, axes.north);
|
||||
const u = cartesian3.dot(direction, axes.up);
|
||||
const pitch = Math.asin(math.clamp(u, -1, 1));
|
||||
// Looking straight up or down, heading follows the camera's up vector.
|
||||
const heading =
|
||||
Math.hypot(e, n) > 1e-9
|
||||
? math.zeroToTwoPi(Math.atan2(e, n))
|
||||
: math.zeroToTwoPi(
|
||||
Math.atan2(
|
||||
cartesian3.dot(up, axes.east) * -Math.sign(u),
|
||||
cartesian3.dot(up, axes.north) * -Math.sign(u),
|
||||
),
|
||||
);
|
||||
const level = orientationVectors(position, heading, pitch, 0);
|
||||
const levelRight = cartesian3.cross(
|
||||
level.direction,
|
||||
level.up,
|
||||
cartesian3.create(),
|
||||
);
|
||||
const roll = Math.atan2(
|
||||
-cartesian3.dot(up, levelRight),
|
||||
cartesian3.dot(up, level.up),
|
||||
);
|
||||
return { heading, pitch, roll: Math.abs(roll) < 1e-12 ? 0 : roll };
|
||||
}
|
||||
|
||||
// Nearest intersection of a ray with the WGS84 ellipsoid, if any.
|
||||
function intersectEllipsoid(origin, direction) {
|
||||
const r = WGS84.radii;
|
||||
const o = [origin.x / r.x, origin.y / r.y, origin.z / r.z];
|
||||
const d = [direction.x / r.x, direction.y / r.y, direction.z / r.z];
|
||||
const a = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
const b = 2 * (o[0] * d[0] + o[1] * d[1] + o[2] * d[2]);
|
||||
const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - 1;
|
||||
const discriminant = b * b - 4 * a * c;
|
||||
if (discriminant < 0) return undefined;
|
||||
const root = Math.sqrt(discriminant);
|
||||
const near = (-b - root) / (2 * a);
|
||||
const t = near > 0 ? near : (-b + root) / (2 * a);
|
||||
if (!(t > 0)) return undefined;
|
||||
return cartesian3.add(
|
||||
origin,
|
||||
cartesian3.multiplyByScalar(direction, t, cartesian3.create()),
|
||||
cartesian3.create(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ kind?: 'cesium'|'navara', width?: number, height?: number, fovy?: number,
|
||||
* longitude?: number, latitude?: number, altitude?: number }} [options]
|
||||
* Canvas size in CSS px, angles in radians. The initial pose looks straight
|
||||
* down from `altitude` metres (10 000 km).
|
||||
* @returns {import('../contract.js').GlobeEngine & { fake: object }}
|
||||
*/
|
||||
export function createFakeEngine(options = {}) {
|
||||
let width = options.width ?? 1024;
|
||||
let height = options.height ?? 768;
|
||||
// Cesium's default: 60 degrees across the wider canvas dimension.
|
||||
const fovy =
|
||||
options.fovy ??
|
||||
(width > height
|
||||
? 2 * Math.atan(Math.tan(Math.PI / 6) * (height / width))
|
||||
: Math.PI / 3);
|
||||
const state = {
|
||||
position: cartesian3.create(),
|
||||
direction: cartesian3.create(),
|
||||
up: cartesian3.create(),
|
||||
};
|
||||
const events = {
|
||||
'camera.changed': createEngineEvent(),
|
||||
'camera.moveStart': createEngineEvent(),
|
||||
'camera.moveEnd': createEngineEvent(),
|
||||
'frames.preUpdate': createEngineEvent(),
|
||||
'frames.preRender': createEngineEvent(),
|
||||
'frames.postRender': createEngineEvent(),
|
||||
};
|
||||
const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()]));
|
||||
const scripts = new Map();
|
||||
let percentageChanged = 0.5;
|
||||
let continuous = false;
|
||||
let flight = null;
|
||||
let lookAtTarget = null;
|
||||
let clock = 0;
|
||||
let moving = false;
|
||||
let lastFramePose = null;
|
||||
let lastChangedPose = null;
|
||||
|
||||
const fake = {
|
||||
calls: [],
|
||||
renderRequests: 0,
|
||||
cameraInputsEnabled: true,
|
||||
credits: [],
|
||||
maps: { imagery: undefined, terrain: undefined, photoreal: null },
|
||||
destroyed: false,
|
||||
};
|
||||
const record = (method, args) => fake.calls.push({ method, args });
|
||||
|
||||
function setOrientation(position, orientation) {
|
||||
if (orientation && 'direction' in orientation) {
|
||||
state.position = copy3(position);
|
||||
state.direction = cartesian3.normalize(
|
||||
orientation.direction,
|
||||
cartesian3.create(),
|
||||
);
|
||||
state.up = cartesian3.normalize(orientation.up, cartesian3.create());
|
||||
return;
|
||||
}
|
||||
// Missing angles keep the current ones, measured where the camera is now.
|
||||
const current = poseAngles();
|
||||
state.position = copy3(position);
|
||||
const vectors = orientationVectors(
|
||||
state.position,
|
||||
orientation?.heading ?? current.heading,
|
||||
orientation?.pitch ?? current.pitch,
|
||||
orientation?.roll ?? current.roll,
|
||||
);
|
||||
state.direction = vectors.direction;
|
||||
state.up = vectors.up;
|
||||
}
|
||||
|
||||
function poseAngles() {
|
||||
if (cartesian3.magnitudeSquared(state.direction) === 0)
|
||||
return { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 };
|
||||
return headingPitchRoll(state.position, state.direction, state.up);
|
||||
}
|
||||
|
||||
function right() {
|
||||
const result = cartesian3.cross(
|
||||
state.direction,
|
||||
state.up,
|
||||
cartesian3.create(),
|
||||
);
|
||||
return cartesian3.normalize(result, result);
|
||||
}
|
||||
|
||||
// Camera placed at `range` from target, looking at it, with heading/pitch
|
||||
// measured in the target's local frame (Cesium's HeadingPitchRange).
|
||||
function offsetView(target, heading, pitch, range) {
|
||||
const toTarget = orientationVectors(target, heading, pitch, 0);
|
||||
const position = cartesian3.subtract(
|
||||
target,
|
||||
cartesian3.multiplyByScalar(
|
||||
toTarget.direction,
|
||||
range,
|
||||
cartesian3.create(),
|
||||
),
|
||||
cartesian3.create(),
|
||||
);
|
||||
return { position, direction: toTarget.direction, up: toTarget.up };
|
||||
}
|
||||
|
||||
function scripted(name, args, fallback) {
|
||||
if (!scripts.has(name)) return fallback();
|
||||
const value = scripts.get(name);
|
||||
return typeof value === 'function' ? value(...args) : value;
|
||||
}
|
||||
|
||||
function startFlight(options, target) {
|
||||
if (flight) cancelCurrentFlight();
|
||||
const duration = Math.max(0, options.duration ?? 3);
|
||||
flight = {
|
||||
start: copy3(state.position),
|
||||
target,
|
||||
duration: duration * 1000,
|
||||
elapsed: 0,
|
||||
ease: EASINGS[options.easing] ?? EASINGS.cubicInOut,
|
||||
complete: options.complete,
|
||||
cancel: options.cancel,
|
||||
};
|
||||
if (duration === 0) applyFlightTarget(flight.target);
|
||||
}
|
||||
|
||||
function applyFlightTarget(target) {
|
||||
state.position = copy3(target.position);
|
||||
state.direction = copy3(target.direction);
|
||||
state.up = copy3(target.up);
|
||||
}
|
||||
|
||||
function cancelCurrentFlight() {
|
||||
const current = flight;
|
||||
flight = null;
|
||||
current?.cancel?.();
|
||||
}
|
||||
|
||||
function advanceFlight(dt) {
|
||||
if (!flight) return;
|
||||
flight.elapsed += dt;
|
||||
const t = flight.duration
|
||||
? Math.min(1, flight.elapsed / flight.duration)
|
||||
: 1;
|
||||
if (t < 1) {
|
||||
cartesian3.lerp(
|
||||
flight.start,
|
||||
flight.target.position,
|
||||
flight.ease(t),
|
||||
state.position,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const current = flight;
|
||||
flight = null;
|
||||
applyFlightTarget(current.target);
|
||||
current.complete?.();
|
||||
}
|
||||
|
||||
function cameraEvents() {
|
||||
const pose = {
|
||||
position: copy3(state.position),
|
||||
direction: copy3(state.direction),
|
||||
};
|
||||
const differs = (a, b) =>
|
||||
!b ||
|
||||
!cartesian3.equalsEpsilon(a.position, b.position, 0, 1e-9) ||
|
||||
!cartesian3.equalsEpsilon(a.direction, b.direction, 0, 1e-12);
|
||||
if (differs(pose, lastFramePose)) {
|
||||
if (!moving) {
|
||||
moving = true;
|
||||
events['camera.moveStart'].raiseEvent();
|
||||
}
|
||||
} else if (moving) {
|
||||
moving = false;
|
||||
events['camera.moveEnd'].raiseEvent();
|
||||
}
|
||||
lastFramePose = pose;
|
||||
if (!lastChangedPose) {
|
||||
lastChangedPose = pose;
|
||||
return;
|
||||
}
|
||||
const turn =
|
||||
Math.acos(
|
||||
math.clamp(
|
||||
cartesian3.dot(pose.direction, lastChangedPose.direction),
|
||||
-1,
|
||||
1,
|
||||
),
|
||||
) / Math.PI;
|
||||
const altitude = Math.max(
|
||||
1,
|
||||
cartographic.fromCartesian(pose.position, WGS84).height,
|
||||
);
|
||||
const shift =
|
||||
cartesian3.distance(pose.position, lastChangedPose.position) / altitude;
|
||||
const amount = Math.max(turn, shift);
|
||||
if (amount > percentageChanged) {
|
||||
lastChangedPose = pose;
|
||||
events['camera.changed'].raiseEvent(amount);
|
||||
}
|
||||
}
|
||||
|
||||
function project(position, result) {
|
||||
const offset = cartesian3.subtract(
|
||||
position,
|
||||
state.position,
|
||||
cartesian3.create(),
|
||||
);
|
||||
const depth = cartesian3.dot(offset, state.direction);
|
||||
if (!(depth > 0)) return undefined;
|
||||
const tangent = Math.tan(fovy / 2);
|
||||
const ndcX =
|
||||
cartesian3.dot(offset, right()) / (depth * tangent * (width / height));
|
||||
const ndcY = cartesian3.dot(offset, state.up) / (depth * tangent);
|
||||
const x = ((ndcX + 1) / 2) * width;
|
||||
const y = ((1 - ndcY) / 2) * height;
|
||||
if (!result) return cartesian2.create(x, y);
|
||||
result.x = x;
|
||||
result.y = y;
|
||||
return result;
|
||||
}
|
||||
|
||||
function ray(screen) {
|
||||
const tangent = Math.tan(fovy / 2);
|
||||
const ndcX = (2 * screen.x) / width - 1;
|
||||
const ndcY = 1 - (2 * screen.y) / height;
|
||||
const direction = combine(
|
||||
{ east: right(), north: state.up, up: state.direction },
|
||||
ndcX * tangent * (width / height),
|
||||
ndcY * tangent,
|
||||
1,
|
||||
);
|
||||
return {
|
||||
origin: copy3(state.position),
|
||||
direction: cartesian3.normalize(direction, direction),
|
||||
};
|
||||
}
|
||||
|
||||
function computedViewRectangle() {
|
||||
const corners = [
|
||||
[0, 0],
|
||||
[width, 0],
|
||||
[0, height],
|
||||
[width, height],
|
||||
[width / 2, 0],
|
||||
[width / 2, height],
|
||||
[0, height / 2],
|
||||
[width, height / 2],
|
||||
].map(([x, y]) =>
|
||||
intersectEllipsoid(
|
||||
state.position,
|
||||
ray(cartesian2.create(x, y)).direction,
|
||||
),
|
||||
);
|
||||
if (corners.some((corner) => !corner)) return undefined;
|
||||
const points = corners.map((corner) =>
|
||||
cartographic.fromCartesian(corner, WGS84),
|
||||
);
|
||||
return {
|
||||
west: Math.min(...points.map((point) => point.longitude)),
|
||||
south: Math.min(...points.map((point) => point.latitude)),
|
||||
east: Math.max(...points.map((point) => point.longitude)),
|
||||
north: Math.max(...points.map((point) => point.latitude)),
|
||||
};
|
||||
}
|
||||
|
||||
const camera = {
|
||||
pose() {
|
||||
const angles = poseAngles();
|
||||
return {
|
||||
position: copy3(state.position),
|
||||
cartographic: cartographic.fromCartesian(state.position, WGS84),
|
||||
heading: angles.heading,
|
||||
pitch: angles.pitch,
|
||||
roll: angles.roll,
|
||||
direction: copy3(state.direction),
|
||||
up: copy3(state.up),
|
||||
right: right(),
|
||||
fovy,
|
||||
};
|
||||
},
|
||||
setView(view) {
|
||||
record('camera.setView', [view]);
|
||||
if (!view?.destination)
|
||||
throw new TypeError('setView needs a destination');
|
||||
setOrientation(view.destination, view.orientation);
|
||||
},
|
||||
flyTo(flightOptions) {
|
||||
record('camera.flyTo', [flightOptions]);
|
||||
if (!flightOptions?.destination)
|
||||
throw new TypeError('flyTo needs a destination');
|
||||
const saved = {
|
||||
position: state.position,
|
||||
direction: state.direction,
|
||||
up: state.up,
|
||||
};
|
||||
setOrientation(flightOptions.destination, flightOptions.orientation);
|
||||
const target = {
|
||||
position: state.position,
|
||||
direction: state.direction,
|
||||
up: state.up,
|
||||
};
|
||||
Object.assign(state, saved);
|
||||
startFlight(flightOptions, target);
|
||||
},
|
||||
flyToSphere(sphere, sphereOptions = {}) {
|
||||
record('camera.flyToSphere', [sphere, sphereOptions]);
|
||||
const range =
|
||||
sphereOptions.range ??
|
||||
Math.max(sphere.radius / Math.sin(fovy / 2), 100);
|
||||
const target = offsetView(
|
||||
sphere.center,
|
||||
sphereOptions.heading ?? 0,
|
||||
sphereOptions.pitch ?? -0.5,
|
||||
range,
|
||||
);
|
||||
startFlight(sphereOptions, target);
|
||||
},
|
||||
lookAt(target, offset) {
|
||||
record('camera.lookAt', [target, offset]);
|
||||
lookAtTarget = copy3(target);
|
||||
applyFlightTarget(
|
||||
offsetView(target, offset.heading, offset.pitch, offset.range),
|
||||
);
|
||||
},
|
||||
release() {
|
||||
record('camera.release', []);
|
||||
lookAtTarget = null;
|
||||
},
|
||||
cancelFlight() {
|
||||
record('camera.cancelFlight', []);
|
||||
cancelCurrentFlight();
|
||||
},
|
||||
viewRectangle() {
|
||||
record('camera.viewRectangle', []);
|
||||
return scripted('viewRectangle', [], computedViewRectangle);
|
||||
},
|
||||
pickEllipsoid(screen) {
|
||||
return scripted('pickEllipsoid', [screen], () =>
|
||||
intersectEllipsoid(state.position, ray(screen).direction),
|
||||
);
|
||||
},
|
||||
pickRay(screen) {
|
||||
return ray(screen);
|
||||
},
|
||||
changed: events['camera.changed'],
|
||||
moveStart: events['camera.moveStart'],
|
||||
moveEnd: events['camera.moveEnd'],
|
||||
get percentageChanged() {
|
||||
return percentageChanged;
|
||||
},
|
||||
set percentageChanged(value) {
|
||||
percentageChanged = value;
|
||||
},
|
||||
};
|
||||
|
||||
const host = {
|
||||
container: { kind: 'fake-container' },
|
||||
canvas: { kind: 'fake-canvas' },
|
||||
size: () => ({ width, height }),
|
||||
};
|
||||
|
||||
const engine = {
|
||||
kind: options.kind ?? 'cesium',
|
||||
host,
|
||||
camera,
|
||||
picker: {
|
||||
pick(screen, w, h) {
|
||||
record('picker.pick', [screen, w, h]);
|
||||
return scripted('pick', [screen, w, h], () => undefined);
|
||||
},
|
||||
drillPick(screen, limit) {
|
||||
record('picker.drillPick', [screen, limit]);
|
||||
return scripted('drillPick', [screen, limit], () => []);
|
||||
},
|
||||
pickPosition(screen) {
|
||||
record('picker.pickPosition', [screen]);
|
||||
return scripted('pickPosition', [screen], () =>
|
||||
camera.pickEllipsoid(screen),
|
||||
);
|
||||
},
|
||||
pickPositionSupported: true,
|
||||
},
|
||||
projector: {
|
||||
worldToWindow(position, result) {
|
||||
const value = scripted('worldToWindow', [position, result], () =>
|
||||
project(position, result),
|
||||
);
|
||||
if (value && result && value !== result)
|
||||
return cartesian2.clone(value, result);
|
||||
return value;
|
||||
},
|
||||
},
|
||||
frames: {
|
||||
preUpdate: events['frames.preUpdate'],
|
||||
preRender: events['frames.preRender'],
|
||||
postRender: events['frames.postRender'],
|
||||
},
|
||||
render: {
|
||||
request() {
|
||||
record('render.request', []);
|
||||
fake.renderRequests++;
|
||||
},
|
||||
setContinuous(enabled) {
|
||||
record('render.setContinuous', [enabled]);
|
||||
continuous = Boolean(enabled);
|
||||
},
|
||||
isContinuous: () => continuous,
|
||||
},
|
||||
surface: {
|
||||
sampleHeight(position, exclude) {
|
||||
record('surface.sampleHeight', [position, exclude]);
|
||||
return scripted('sampleHeight', [position, exclude], () => undefined);
|
||||
},
|
||||
supported: true,
|
||||
tilesLoaded: () => scripted('tilesLoaded', [], () => true),
|
||||
},
|
||||
input: {
|
||||
on(type, listener) {
|
||||
record('input.on', [type]);
|
||||
const listeners = inputs.get(type);
|
||||
if (!listeners) throw new TypeError(`Unknown input type: ${type}`);
|
||||
const entry = (event) => listener(event);
|
||||
listeners.add(entry);
|
||||
return () => listeners.delete(entry);
|
||||
},
|
||||
setCameraInputs(enabled) {
|
||||
record('input.setCameraInputs', [enabled]);
|
||||
fake.cameraInputsEnabled = Boolean(enabled);
|
||||
},
|
||||
},
|
||||
credits: {
|
||||
addStatic(html, creditOptions = {}) {
|
||||
record('credits.addStatic', [html, creditOptions]);
|
||||
const credit = { html, onScreen: Boolean(creditOptions.onScreen) };
|
||||
fake.credits.push(credit);
|
||||
return () => {
|
||||
const index = fake.credits.indexOf(credit);
|
||||
if (index >= 0) fake.credits.splice(index, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
maps: {
|
||||
setBaseImagery(source) {
|
||||
record('maps.setBaseImagery', [source]);
|
||||
fake.maps.imagery = source;
|
||||
},
|
||||
setTerrain(source) {
|
||||
record('maps.setTerrain', [source]);
|
||||
fake.maps.terrain = source;
|
||||
},
|
||||
setPhotorealTiles(source) {
|
||||
record('maps.setPhotorealTiles', [source]);
|
||||
fake.maps.photoreal = source ?? null;
|
||||
},
|
||||
photorealActive: () => fake.maps.photoreal != null,
|
||||
},
|
||||
destroy() {
|
||||
record('destroy', []);
|
||||
cancelCurrentFlight();
|
||||
fake.destroyed = true;
|
||||
},
|
||||
fake,
|
||||
};
|
||||
|
||||
Object.assign(fake, {
|
||||
/** Places the camera; angles in radians, missing ones keep the current value. */
|
||||
setPose(pose) {
|
||||
const position =
|
||||
pose.position ??
|
||||
cartesian3.fromRadians(
|
||||
pose.longitude ?? 0,
|
||||
pose.latitude ?? 0,
|
||||
pose.height ?? 1e7,
|
||||
WGS84,
|
||||
);
|
||||
setOrientation(
|
||||
position,
|
||||
pose.direction
|
||||
? { direction: pose.direction, up: pose.up }
|
||||
: { heading: pose.heading, pitch: pose.pitch, roll: pose.roll },
|
||||
);
|
||||
},
|
||||
setSize(nextWidth, nextHeight) {
|
||||
width = nextWidth;
|
||||
height = nextHeight;
|
||||
},
|
||||
callsOf: (method) => fake.calls.filter((call) => call.method === method),
|
||||
resetCalls() {
|
||||
fake.calls.length = 0;
|
||||
fake.renderRequests = 0;
|
||||
},
|
||||
/** Raises an engine event by path, e.g. 'camera.changed' or 'frames.preRender'. */
|
||||
emit(name, ...args) {
|
||||
const event = events[name];
|
||||
if (!event) throw new TypeError(`Unknown engine event: ${name}`);
|
||||
event.raiseEvent(...args);
|
||||
},
|
||||
/** Delivers an input event to `engine.input.on(type)` subscribers. */
|
||||
input(type, event) {
|
||||
const listeners = inputs.get(type);
|
||||
if (!listeners) throw new TypeError(`Unknown input type: ${type}`);
|
||||
for (const listener of [...listeners]) listener(event);
|
||||
},
|
||||
/** Replaces a result with a value or a function of the call's arguments. */
|
||||
script(name, valueOrFunction) {
|
||||
if (!SCRIPTABLE.has(name))
|
||||
throw new TypeError(`Unknown scripted result: ${name}`);
|
||||
scripts.set(name, valueOrFunction);
|
||||
},
|
||||
unscript(name) {
|
||||
scripts.delete(name);
|
||||
},
|
||||
flying: () => flight != null,
|
||||
lookAtTarget: () => (lookAtTarget ? copy3(lookAtTarget) : undefined),
|
||||
/** Runs one frame: preUpdate, flight progress, preRender, camera events, postRender. */
|
||||
frame(dt = 16) {
|
||||
clock += dt;
|
||||
events['frames.preUpdate'].raiseEvent(clock);
|
||||
advanceFlight(dt);
|
||||
events['frames.preRender'].raiseEvent(clock);
|
||||
cameraEvents();
|
||||
events['frames.postRender'].raiseEvent(clock);
|
||||
},
|
||||
frames(count = 1, dt = 16) {
|
||||
for (let index = 0; index < count; index++) fake.frame(dt);
|
||||
},
|
||||
});
|
||||
|
||||
fake.setPose({
|
||||
longitude: options.longitude ?? 0,
|
||||
latitude: options.latitude ?? 0,
|
||||
height: options.altitude ?? 1e7,
|
||||
heading: 0,
|
||||
pitch: -math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
});
|
||||
lastFramePose = {
|
||||
position: copy3(state.position),
|
||||
direction: copy3(state.direction),
|
||||
};
|
||||
return engine;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { createFakeEngine } from './fakeEngine.js';
|
||||
|
||||
const close = (actual, expected, tolerance, message) =>
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
`${message ?? 'value'}: ${actual} is not within ${tolerance} of ${expected}`,
|
||||
);
|
||||
|
||||
test('the fake pose is controllable and returned as fresh geo values', () => {
|
||||
const engine = createFakeEngine({ width: 800, height: 600 });
|
||||
engine.fake.setPose({
|
||||
longitude: 0.5,
|
||||
latitude: 0.25,
|
||||
height: 2000,
|
||||
heading: 1,
|
||||
pitch: -0.4,
|
||||
});
|
||||
const pose = engine.camera.pose();
|
||||
close(pose.cartographic.longitude, 0.5, 1e-12, 'longitude');
|
||||
close(pose.cartographic.latitude, 0.25, 1e-12, 'latitude');
|
||||
close(pose.cartographic.height, 2000, 1e-6, 'height');
|
||||
close(pose.heading, 1, 1e-9, 'heading');
|
||||
close(pose.pitch, -0.4, 1e-9, 'pitch');
|
||||
close(pose.roll, 0, 1e-9, 'roll');
|
||||
pose.position.x = 0;
|
||||
assert.notEqual(engine.camera.pose().position.x, 0);
|
||||
assert.deepEqual(engine.host.size(), { width: 800, height: 600 });
|
||||
engine.fake.setSize(320, 200);
|
||||
assert.deepEqual(engine.host.size(), { width: 320, height: 200 });
|
||||
});
|
||||
|
||||
test('the fake records contract calls for shell-module tests', () => {
|
||||
const engine = createFakeEngine();
|
||||
const destination = geo.cartesian3.fromDegrees(10, 20, 3000);
|
||||
engine.camera.setView({ destination });
|
||||
engine.camera.flyTo({ destination, duration: 2 });
|
||||
engine.camera.cancelFlight();
|
||||
engine.render.request();
|
||||
engine.render.request();
|
||||
assert.deepEqual(
|
||||
engine.fake.calls.map((call) => call.method),
|
||||
[
|
||||
'camera.setView',
|
||||
'camera.flyTo',
|
||||
'camera.cancelFlight',
|
||||
'render.request',
|
||||
'render.request',
|
||||
],
|
||||
);
|
||||
assert.equal(engine.fake.callsOf('camera.flyTo')[0].args[0].duration, 2);
|
||||
assert.equal(engine.fake.renderRequests, 2);
|
||||
engine.fake.resetCalls();
|
||||
assert.deepEqual(engine.fake.calls, []);
|
||||
assert.equal(engine.fake.renderRequests, 0);
|
||||
});
|
||||
|
||||
test('fake flights advance with frames and finish or cancel exactly once', () => {
|
||||
const engine = createFakeEngine();
|
||||
const destination = geo.cartesian3.fromDegrees(30, 40, 5000);
|
||||
const outcomes = [];
|
||||
engine.camera.flyTo({
|
||||
destination,
|
||||
duration: 1,
|
||||
complete: () => outcomes.push('complete'),
|
||||
cancel: () => outcomes.push('cancel'),
|
||||
});
|
||||
assert.equal(engine.fake.flying(), true);
|
||||
engine.fake.frames(30, 16);
|
||||
assert.deepEqual(outcomes, []);
|
||||
engine.fake.frames(40, 16);
|
||||
assert.deepEqual(outcomes, ['complete']);
|
||||
assert.ok(
|
||||
geo.cartesian3.distance(engine.camera.pose().position, destination) < 1e-6,
|
||||
);
|
||||
engine.camera.cancelFlight();
|
||||
assert.deepEqual(outcomes, ['complete']);
|
||||
|
||||
engine.camera.flyTo({
|
||||
destination: geo.cartesian3.fromDegrees(0, 0, 1e6),
|
||||
cancel: () => outcomes.push('cancel'),
|
||||
});
|
||||
engine.camera.flyTo({
|
||||
destination,
|
||||
duration: 0,
|
||||
complete: () => outcomes.push('second'),
|
||||
});
|
||||
assert.deepEqual(outcomes, ['complete', 'cancel']);
|
||||
engine.fake.frame();
|
||||
assert.deepEqual(outcomes, ['complete', 'cancel', 'second']);
|
||||
});
|
||||
|
||||
test('fake events can be emitted by hand and frames fire in order', () => {
|
||||
const engine = createFakeEngine();
|
||||
const seen = [];
|
||||
engine.camera.changed.addEventListener((value) =>
|
||||
seen.push(['changed', value]),
|
||||
);
|
||||
engine.frames.preUpdate.addEventListener((time) =>
|
||||
seen.push(['preUpdate', time]),
|
||||
);
|
||||
engine.frames.preRender.addEventListener((time) =>
|
||||
seen.push(['preRender', time]),
|
||||
);
|
||||
engine.frames.postRender.addEventListener((time) =>
|
||||
seen.push(['postRender', time]),
|
||||
);
|
||||
engine.fake.emit('camera.changed', 0.7);
|
||||
engine.fake.frame(10);
|
||||
assert.deepEqual(seen, [
|
||||
['changed', 0.7],
|
||||
['preUpdate', 10],
|
||||
['preRender', 10],
|
||||
['postRender', 10],
|
||||
]);
|
||||
assert.throws(() => engine.fake.emit('camera.nope'), /Unknown engine event/);
|
||||
});
|
||||
|
||||
test('fake input dispatches to subscribers and off removes them', () => {
|
||||
const engine = createFakeEngine();
|
||||
const seen = [];
|
||||
const off = engine.input.on('click', (event) => seen.push(event.position));
|
||||
engine.fake.input('click', { position: geo.cartesian2.create(4, 5) });
|
||||
off();
|
||||
engine.fake.input('click', { position: geo.cartesian2.create(6, 7) });
|
||||
assert.deepEqual(seen, [geo.cartesian2.create(4, 5)]);
|
||||
assert.throws(() => engine.input.on('wheel', () => {}), /Unknown input type/);
|
||||
engine.input.setCameraInputs(false);
|
||||
assert.equal(engine.fake.cameraInputsEnabled, false);
|
||||
});
|
||||
|
||||
test('pick, sampleHeight and worldToWindow results can be scripted', () => {
|
||||
const engine = createFakeEngine();
|
||||
const screen = geo.cartesian2.create(10, 10);
|
||||
assert.equal(engine.picker.pick(screen), undefined);
|
||||
engine.fake.script('pick', { id: 'aircraft', primitive: {} });
|
||||
assert.equal(engine.picker.pick(screen).id, 'aircraft');
|
||||
engine.fake.script('pick', (position) =>
|
||||
position.x > 5 ? { id: 'x' } : undefined,
|
||||
);
|
||||
assert.equal(engine.picker.pick(screen).id, 'x');
|
||||
engine.fake.script('sampleHeight', 123);
|
||||
assert.equal(
|
||||
engine.surface.sampleHeight(geo.cartographic.fromDegrees(0, 0)),
|
||||
123,
|
||||
);
|
||||
engine.fake.script('worldToWindow', geo.cartesian2.create(1, 2));
|
||||
const result = geo.cartesian2.create();
|
||||
assert.equal(
|
||||
engine.projector.worldToWindow(geo.cartesian3.UNIT_X, result),
|
||||
result,
|
||||
);
|
||||
assert.deepEqual(result, geo.cartesian2.create(1, 2));
|
||||
engine.fake.unscript('worldToWindow');
|
||||
const { position, direction } = engine.camera.pose();
|
||||
const behind = geo.cartesian3.subtract(
|
||||
position,
|
||||
geo.cartesian3.multiplyByScalar(direction, 1000, geo.cartesian3.create()),
|
||||
geo.cartesian3.create(),
|
||||
);
|
||||
assert.equal(engine.projector.worldToWindow(behind), undefined);
|
||||
assert.throws(
|
||||
() => engine.fake.script('teleport', 1),
|
||||
/Unknown scripted result/,
|
||||
);
|
||||
});
|
||||
|
||||
test('fake credits, maps and destroy keep inspectable state', () => {
|
||||
const engine = createFakeEngine();
|
||||
const remove = engine.credits.addStatic('<b>OSM</b>', { onScreen: true });
|
||||
assert.deepEqual(engine.fake.credits, [
|
||||
{ html: '<b>OSM</b>', onScreen: true },
|
||||
]);
|
||||
remove();
|
||||
remove();
|
||||
assert.deepEqual(engine.fake.credits, []);
|
||||
engine.maps.setPhotorealTiles({ kind: 'google-3d' });
|
||||
assert.equal(engine.maps.photorealActive(), true);
|
||||
assert.deepEqual(engine.fake.maps.photoreal, { kind: 'google-3d' });
|
||||
engine.maps.setPhotorealTiles(null);
|
||||
assert.equal(engine.maps.photorealActive(), false);
|
||||
engine.destroy();
|
||||
assert.equal(engine.fake.destroyed, true);
|
||||
});
|
||||
@@ -100,6 +100,47 @@ for (const [name, files, pattern] of [
|
||||
},
|
||||
/src\/data\/helper\.js: geo reaches platform\/rendering dependency: cesium/,
|
||||
],
|
||||
[
|
||||
'globe contract imports cesium',
|
||||
{ 'src/globe/contract.js': "import 'cesium';" },
|
||||
/src\/globe\/contract\.js: globe contract reaches engine dependency: cesium/,
|
||||
],
|
||||
[
|
||||
'globe contract imports three',
|
||||
{ 'src/globe/contract.js': "import * as THREE from 'three';" },
|
||||
/globe contract reaches engine dependency: three/,
|
||||
],
|
||||
[
|
||||
'globe testing support imports a Navara package',
|
||||
{ 'src/globe/testing/fakeEngine.js': "import '@navaramap/three';" },
|
||||
/src\/globe\/testing\/fakeEngine\.js: globe contract reaches engine dependency: @navaramap\/three/,
|
||||
],
|
||||
[
|
||||
'globe contract reaches cesium through a helper',
|
||||
{
|
||||
'src/globe/contract.js': "import '../data/helper.js';",
|
||||
'src/data/helper.js': "export * from '@cesium/engine';",
|
||||
},
|
||||
/src\/data\/helper\.js: globe contract reaches engine dependency: @cesium\/engine/,
|
||||
],
|
||||
[
|
||||
'globe contract imports an engine implementation',
|
||||
{
|
||||
'src/globe/contract.js': "import('./navara/index.js');",
|
||||
'src/globe/navara/index.js': '',
|
||||
},
|
||||
/globe contract imports engine implementation: src\/globe\/navara\/index\.js/,
|
||||
],
|
||||
[
|
||||
'Cesium globe engine imports Navara',
|
||||
{ 'src/globe/cesium/engine.js': "import '@navaramap/three';" },
|
||||
/Cesium globe engine imports @navaramap\/three/,
|
||||
],
|
||||
[
|
||||
'Navara globe engine imports Cesium',
|
||||
{ 'src/globe/navara/engine.js': "import { Cartesian3 } from 'cesium';" },
|
||||
/Navara globe engine imports cesium/,
|
||||
],
|
||||
[
|
||||
'browser imports Node without prefix',
|
||||
{ 'src/unused.mjs': "import 'fs/promises';" },
|
||||
@@ -175,6 +216,23 @@ test('geo modules may import each other, and its tests may import cesium', (t) =
|
||||
assert.doesNotThrow(() => checkImportDirections(root));
|
||||
});
|
||||
|
||||
test('globe engines use their own dependencies, and globe tests may import any engine', (t) => {
|
||||
const { root, write } = fixture(t);
|
||||
write('src/geo/math.js', 'export const half = (value) => value / 2;');
|
||||
write('src/globe/contract.js', "export { half } from '../geo/math.js';");
|
||||
write('src/globe/testing/suite.mjs', "import '../contract.js';");
|
||||
write('src/globe/testing/suite.test.mjs', "import 'cesium'; import 'three';");
|
||||
write(
|
||||
'src/globe/cesium/engine.js',
|
||||
"import 'cesium'; import '../contract.js';",
|
||||
);
|
||||
write(
|
||||
'src/globe/navara/engine.js',
|
||||
"import 'three'; import '@navaramap/three'; import '../contract.js';",
|
||||
);
|
||||
assert.doesNotThrow(() => checkImportDirections(root));
|
||||
});
|
||||
|
||||
test('self package imports cannot evade portable ownership', (t) => {
|
||||
const { root, write } = fixture(t, { './view': './src/ui/view.js' });
|
||||
write('src/sources/demo.js', "import '@gods-eye-view/core/view';");
|
||||
|
||||
Reference in new issue
Block a user