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:
German Khokhlov committed 2026-10-15 17:01:08 +04:00
1 parent f2fcd82290
commit 528ec75f32
9 files changed
+2036 -1

No files matched your search

+6 -1
View File
@@ -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/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/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/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/services/` | Supplied application operations; scene construction owns caches and cancellation |
| `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters | | `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/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 interface, lifecycle and feed state, and every runtime file in `src/geo/` (the
engine-neutral geometry ported from Cesium). The browser-global rule reserves engine-neutral geometry ported from Cesium). The browser-global rule reserves
platform names such as `document` and `window` in these modules; it is an 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, Realtime protocol implementation. Negative fixtures cover indirect helpers,
self-package imports, symlinks and unreachable files. self-package imports, symlinks and unreachable files.
+36
View File
@@ -21,6 +21,13 @@ const source = (file) =>
/^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file); /^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file);
// Engine-neutral geometry ported from Cesium; every runtime file is a portable root. // Engine-neutral geometry ported from Cesium; every runtime file is a portable root.
const geo = (file) => file.startsWith('src/geo/'); 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) => const renderer = (file) =>
/^src\/(?:ui|app|standalone|overlays)\//.test(file) || /^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test( /^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
@@ -231,6 +238,35 @@ export function checkImportDirections(root) {
} }
visit(start); 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. // Common voice controls may use their DOM view, but never a protocol implementation.
const voiceSeen = new Set(); const voiceSeen = new Set();
function voice(file) { function voice(file) {
+2
View File
@@ -719,6 +719,8 @@
"scripts/module-analysis.mjs", "scripts/module-analysis.mjs",
"scripts/check-import-directions.mjs", "scripts/check-import-directions.mjs",
"src/tooling/importDirections.test.mjs", "src/tooling/importDirections.test.mjs",
"src/globe/contract.test.mjs",
"src/globe/testing/fakeEngine.test.mjs",
"src/sources/reference.js", "src/sources/reference.js",
"src/sources/reference.test.mjs", "src/sources/reference.test.mjs",
"src/search/nominatim.js", "src/search/nominatim.js",
+236
View File
@@ -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;
}
+170
View File
@@ -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));
});
+675
View File
@@ -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;
}
+667
View File
@@ -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;
}
+186
View File
@@ -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);
});
+58
View File
@@ -100,6 +100,47 @@ for (const [name, files, pattern] of [
}, },
/src\/data\/helper\.js: geo reaches platform\/rendering dependency: cesium/, /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', 'browser imports Node without prefix',
{ 'src/unused.mjs': "import 'fs/promises';" }, { '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)); 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) => { test('self package imports cannot evade portable ownership', (t) => {
const { root, write } = fixture(t, { './view': './src/ui/view.js' }); const { root, write } = fixture(t, { './view': './src/ui/view.js' });
write('src/sources/demo.js', "import '@gods-eye-view/core/view';"); write('src/sources/demo.js', "import '@gods-eye-view/core/view';");