From 528ec75f327220cf033bbf889cad239fae69fff4 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 15 Oct 2026 17:01:08 +0400 Subject: [PATCH] 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. --- docs/CODE-BOUNDARIES.md | 7 +- scripts/check-import-directions.mjs | 36 ++ scripts/format-scope.json | 2 + src/globe/contract.js | 236 +++++++++ src/globe/contract.test.mjs | 170 +++++++ src/globe/testing/contractSuite.mjs | 675 ++++++++++++++++++++++++++ src/globe/testing/fakeEngine.js | 667 +++++++++++++++++++++++++ src/globe/testing/fakeEngine.test.mjs | 186 +++++++ src/tooling/importDirections.test.mjs | 58 +++ 9 files changed, 2036 insertions(+), 1 deletion(-) create mode 100644 src/globe/contract.js create mode 100644 src/globe/contract.test.mjs create mode 100644 src/globe/testing/contractSuite.mjs create mode 100644 src/globe/testing/fakeEngine.js create mode 100644 src/globe/testing/fakeEngine.test.mjs diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 47ed184b9..2fda132e5 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -33,6 +33,7 @@ export inventory. Use declared exports rather than reaching into internal files. | `src/layers//` | Source acquisition, records and Cesium resources with explicit controller/renderer owners | | `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance | | `src/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. diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index bdbb7ff28..cdf0cf5e7 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -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) { diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 662d33a6b..7ebdd16c9 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/src/globe/contract.js b/src/globe/contract.js new file mode 100644 index 000000000..422ef3594 --- /dev/null +++ b/src/globe/contract.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; +} diff --git a/src/globe/contract.test.mjs b/src/globe/contract.test.mjs new file mode 100644 index 000000000..aa94bb14b --- /dev/null +++ b/src/globe/contract.test.mjs @@ -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)); +}); diff --git a/src/globe/testing/contractSuite.mjs b/src/globe/testing/contractSuite.mjs new file mode 100644 index 000000000..537ed3ba1 --- /dev/null +++ b/src/globe/testing/contractSuite.mjs @@ -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, maxWaitFrames?: number }} options + * @returns {{ name: string, run: () => Promise }[]} + */ +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; +} diff --git a/src/globe/testing/fakeEngine.js b/src/globe/testing/fakeEngine.js new file mode 100644 index 000000000..16a50a405 --- /dev/null +++ b/src/globe/testing/fakeEngine.js @@ -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; +} diff --git a/src/globe/testing/fakeEngine.test.mjs b/src/globe/testing/fakeEngine.test.mjs new file mode 100644 index 000000000..2b468fe03 --- /dev/null +++ b/src/globe/testing/fakeEngine.test.mjs @@ -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('OSM', { onScreen: true }); + assert.deepEqual(engine.fake.credits, [ + { html: 'OSM', 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); +}); diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index 0685c181b..0b5f07d54 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -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';");