fix(globe): tighten the engine contract after review

Engine events get their own documented semantics (single registration,
branded instances); the fake follows Cesium's roll sign, angle ranges,
synchronous zero-duration flights, flyToSphere defaults and
computeViewRectangle. The contract adds viewProjection, horizonVisible,
flightActive, lookAtTransform/transform, zoomIn/zoomOut, input modifiers,
terrainHeight and clampToHeight, with geometric suite checks. Engine
import rules now follow each engine graph transitively.
This commit is contained in:
German Khokhlov committed 2026-10-15 17:15:35 +04:00
1 parent 528ec75f32
commit 708c96b6f6
8 files changed
+1300 -280

No files matched your search

+4 -2
View File
@@ -67,8 +67,10 @@ platform names such as `document` and `window` in these modules; it is an
architectural check, not a JavaScript sandbox. The globe contract architectural check, not a JavaScript sandbox. The globe contract
(`src/globe/*.js` and `src/globe/testing/**`) cannot reach `cesium`, (`src/globe/*.js` and `src/globe/testing/**`) cannot reach `cesium`,
`@cesium/*`, `three`, `postprocessing` or `@navaramap/*`, directly or through `@cesium/*`, `three`, `postprocessing` or `@navaramap/*`, directly or through
helpers, nor import an engine implementation; `src/globe/cesium/**` imports no helpers, nor import an engine implementation; the `src/globe/cesium/**` graph
Navara/three package and `src/globe/navara/**` no Cesium package. Common voice controls cannot depend on a reaches no Navara/three package or `src/globe/navara/**` module, and the
`src/globe/navara/**` graph no Cesium package or `src/globe/cesium/**` module,
through helpers too. 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.
+22 -9
View File
@@ -257,15 +257,28 @@ export function checkImportDirections(root) {
} }
} }
for (const file of files.filter(globeContract)) globe(file); for (const file of files.filter(globeContract)) globe(file);
for (const file of files.filter(globeEngine)) { // Each engine graph stays on its own engine, through helpers too.
const navara = file.startsWith('src/globe/navara/'); for (const start of files.filter(globeEngine)) {
for (const specifier of load(file)?.external || []) { const navara = start.startsWith('src/globe/navara/');
if ((navara ? cesiumPackage : navaraPackage).test(specifier)) const label = navara ? 'Navara globe engine' : 'Cesium globe engine';
report( const foreignPackage = navara ? cesiumPackage : navaraPackage;
file, const foreignEngine = navara ? 'src/globe/cesium/' : 'src/globe/navara/';
`${navara ? 'Navara' : 'Cesium'} globe engine imports ${specifier}`, const seen = new Set();
); const visit = (file) => {
} if (seen.has(file) || !code.test(file)) return;
seen.add(file);
const info = load(file);
if (!info) return;
for (const specifier of info.external)
if (foreignPackage.test(specifier))
report(file, `${label} reaches ${specifier}`);
for (const to of info.edges) {
if (to.startsWith(foreignEngine))
report(file, `${label} reaches ${to}`);
else visit(to);
}
};
visit(start);
} }
// 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();
+139 -41
View File
@@ -1,30 +1,44 @@
// The engine-neutral globe contract. The application shell talks to a globe // The engine-neutral globe contract. The application shell talks to a globe
// engine (Cesium today, Navara next) only through these small capability // engine (Cesium today, Navara next) only through these small capability
// objects. Positions are src/geo values, angles are radians and screen // objects. Positions are src/geo values in Earth-fixed metres, angles are
// coordinates are CSS pixels from the canvas's top-left corner. This module // radians and screen coordinates are CSS pixels from the canvas's top-left
// and src/globe/testing/** never import cesium, three or @navaramap/*; see // corner. This module and src/globe/testing/** never import cesium, three or
// docs/CODE-BOUNDARIES.md. // @navaramap/*; see docs/CODE-BOUNDARIES.md.
/** /**
* @typedef {{ x: number, y: number }} Cartesian2 * @typedef {{ x: number, y: number }} Cartesian2
* @typedef {{ x: number, y: number, z: number }} Cartesian3 * @typedef {{ x: number, y: number, z: number }} Cartesian3
* @typedef {{ longitude: number, latitude: number, height: number }} Cartographic * @typedef {{ longitude: number, latitude: number, height: number }} Cartographic
* @typedef {number[]} Matrix4 16 numbers, column-major (m[column * 4 + row]).
* @typedef {(...args: any[]) => void} Listener * @typedef {(...args: any[]) => void} Listener
* *
* @typedef {object} EngineEvent * @typedef {object} EngineEvent
* The contract's own event semantics (engines expose createEngineEvent()
* instances, never a raw Cesium.Event):
* - listeners run in registration order;
* - adding a function that is already registered is a no-op (it stays
* registered once and fires once), so re-initialisation cannot double-fire;
* - a listener removed during dispatch is not called in that dispatch;
* - a listener added during dispatch runs from the next dispatch, and one
* removed then re-added during dispatch stays registered;
* - every remover handle is idempotent and only removes the registration it
* was returned for.
* @property {(listener: Listener) => () => void} addEventListener * @property {(listener: Listener) => () => void} addEventListener
* Returns a function that removes this registration (idempotent).
* @property {(listener: Listener) => boolean} removeEventListener * @property {(listener: Listener) => boolean} removeEventListener
* *
* @typedef {object} CameraPose * @typedef {object} CameraPose
* @property {Cartesian3} position * Angles follow Cesium's camera: heading in [0, 2π) clockwise from north,
* pitch in [−π/2, π/2] (negative looks down), roll in [0, 2π) with positive
* roll tilting `up` toward the camera's right; roll is 0 and heading follows
* `up` when looking (nearly, within 1e-3) straight up or down.
* @property {Cartesian3} position World position (Cesium's positionWC).
* @property {Cartographic} cartographic * @property {Cartographic} cartographic
* @property {number} heading * @property {number} heading
* @property {number} pitch * @property {number} pitch
* @property {number} roll * @property {number} roll
* @property {Cartesian3} direction * @property {Cartesian3} direction
* @property {Cartesian3} up * @property {Cartesian3} up
* @property {Cartesian3} right * @property {Cartesian3} right direction × up
* @property {number} fovy Vertical field of view. * @property {number} fovy Vertical field of view.
* *
* @typedef {{ heading: number, pitch: number, roll: number } * @typedef {{ heading: number, pitch: number, roll: number }
@@ -32,21 +46,49 @@
* *
* @typedef {object} FlightOptions * @typedef {object} FlightOptions
* @property {Cartesian3} destination * @property {Cartesian3} destination
* @property {Orientation} [orientation] * @property {Orientation} [orientation] Missing angles keep the current ones.
* @property {number} [duration] Seconds; 0 lands on the next frame. * @property {number} [duration] Seconds. 0 (or less) sets the view and calls
* `complete` synchronously, before flyTo returns, as Cesium does.
* @property {'cubicInOut'|'sinusoidalInOut'|'linear'} [easing] * @property {'cubicInOut'|'sinusoidalInOut'|'linear'} [easing]
* @property {() => void} [complete] * @property {() => void} [complete]
* @property {() => void} [cancel] Called by cancelFlight or a newer flight. * @property {() => void} [cancel] Called by cancelFlight or a newer flight.
* *
* @typedef {{ heading: number, pitch: number, range: number }} HeadingPitchRange
* Offset from a target in its local east-north-up frame; the camera looks at
* the target along heading/pitch from `range` metres away.
*
* @typedef {object} SphereFlightOptions
* Defaults are Cesium's: heading 0, pitch −π/4 and, when range is missing or
* 0, range = max(r / tan(fovx/2), r / tan(fovy/2)), never below 100 m.
* @property {number} [heading]
* @property {number} [pitch]
* @property {number} [range]
* @property {number} [duration]
* @property {() => void} [complete]
* @property {() => void} [cancel]
*
* @typedef {object} GlobeCamera * @typedef {object} GlobeCamera
* @property {() => CameraPose} pose Fresh values on every call. * @property {() => CameraPose} pose Fresh values on every call.
* @property {(view: { destination: Cartesian3, orientation?: Orientation }) => void} setView * @property {(view: { destination: Cartesian3, orientation?: Orientation }) => void} setView
* @property {(options: FlightOptions) => void} flyTo * @property {(options: FlightOptions) => void} flyTo Cancels a running flight first.
* @property {(sphere: { center: Cartesian3, radius: number }, options?: { heading?: number, pitch?: number, range?: number, duration?: number, complete?: () => void, cancel?: () => void }) => void} flyToSphere * @property {(sphere: { center: Cartesian3, radius: number }, options?: SphereFlightOptions) => void} flyToSphere
* @property {(target: Cartesian3, offset: { heading: number, pitch: number, range: number }) => void} lookAt * Ends unlocked (identity transform).
* @property {() => void} release Leaves lookAt (Cesium's lookAtTransform(IDENTITY)). * @property {() => boolean} flightActive
* @property {(target: Cartesian3, offset: HeadingPitchRange) => void} lookAt
* lookAtTransform(eastNorthUp(target), offset).
* @property {(transform: Matrix4, offset?: HeadingPitchRange | Cartesian3) => void} lookAtTransform
* Locks the camera to `transform`; a Cartesian3 offset is the camera position
* in that frame, looking at its origin with the frame's +Z as "up". Without
* an offset the camera keeps its world pose.
* @property {() => Matrix4} transform A copy of the current camera transform.
* @property {() => void} release lookAtTransform(IDENTITY).
* @property {() => void} cancelFlight * @property {() => void} cancelFlight
* @property {(amount?: number) => void} zoomIn Moves `amount` metres along direction.
* @property {(amount?: number) => void} zoomOut Moves `amount` metres against direction.
* @property {() => ({ west: number, south: number, east: number, north: number } | undefined)} viewRectangle * @property {() => ({ west: number, south: number, east: number, north: number } | undefined)} viewRectangle
* Cesium's computeViewRectangle: undefined when the globe is out of view;
* west > east across the antimeridian; ±π longitudes and a ±π/2 latitude
* over a pole; the whole globe when fewer than two canvas corners hit it.
* @property {(screen: Cartesian2) => (Cartesian3 | undefined)} pickEllipsoid * @property {(screen: Cartesian2) => (Cartesian3 | undefined)} pickEllipsoid
* @property {(screen: Cartesian2) => { origin: Cartesian3, direction: Cartesian3 }} pickRay * @property {(screen: Cartesian2) => { origin: Cartesian3, direction: Cartesian3 }} pickRay
* @property {EngineEvent} changed Listener receives the change fraction. * @property {EngineEvent} changed Listener receives the change fraction.
@@ -55,18 +97,51 @@
* @property {number} percentageChanged * @property {number} percentageChanged
* *
* @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType * @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType
* @typedef {'ctrl'|'shift'|'alt'} InputModifier
* @typedef {{ position?: Cartesian2, startPosition?: Cartesian2, endPosition?: Cartesian2 }} InputEvent * @typedef {{ position?: Cartesian2, startPosition?: Cartesian2, endPosition?: Cartesian2 }} InputEvent
* *
* @typedef {object} GlobeProjector
* @property {(position: Cartesian3, result?: Cartesian2) => (Cartesian2 | undefined)} worldToWindow
* Undefined behind the camera; fills and returns `result` when given.
* @property {(result?: Matrix4) => Matrix4} viewProjection
* World → clip matrix consistent with worldToWindow: clip = M·[x, y, z, 1],
* in front when clip.w > 0, window = ((clip.x/clip.w + 1)/2 · width,
* (1 − clip.y/clip.w)/2 · height).
* @property {(position: Cartesian3) => boolean} horizonVisible
* False when the WGS84 ellipsoid hides the point from the camera
* (Cesium's EllipsoidalOccluder.isPointVisible).
*
* @typedef {object} GlobeSurface
* @property {(cartographic: Cartographic, exclude?: any[]) => (number | undefined)} sampleHeight
* Height of the rendered scene (tiles, models) under a point.
* @property {boolean} supported Whether sampleHeight works.
* @property {(cartographic: Cartographic) => (number | undefined)} terrainHeight
* Height of the terrain/globe surface only (Cesium's globe.getHeight).
* @property {boolean} terrainHeightSupported
* @property {(position: Cartesian3, exclude?: any[]) => (Cartesian3 | undefined)} clampToHeight
* The position moved onto the rendered scene along the geodetic normal.
* @property {boolean} clampToHeightSupported
* @property {() => boolean} tilesLoaded
*
* @typedef {object} GlobeRender
* @property {() => void} request Renders a frame soon in request mode.
* @property {(enabled: boolean) => void} setContinuous
* Owns whatever the engine needs to render every frame or only on request
* (Cesium: requestRenderMode and maximumRenderTimeChange = Infinity).
* @property {() => boolean} isContinuous
*
* @typedef {object} GlobeEngine * @typedef {object} GlobeEngine
* @property {'cesium'|'navara'} kind * @property {'cesium'|'navara'} kind
* @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host * @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host
* @property {GlobeCamera} camera * @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 {{ 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 {GlobeProjector} projector
* @property {{ preUpdate: EngineEvent, preRender: EngineEvent, postRender: EngineEvent }} frames Listeners receive a timestamp in ms. * @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 {GlobeRender} render
* @property {{ sampleHeight: (cartographic: Cartographic, exclude?: any[]) => (number | undefined), supported: boolean, tilesLoaded: () => boolean }} surface * @property {GlobeSurface} surface
* @property {{ on: (type: InputType, listener: (event: InputEvent) => void) => () => void, setCameraInputs: (enabled: boolean) => void }} input * @property {{ on: (type: InputType, listener: (event: InputEvent) => void, options?: { modifier?: InputModifier }) => () => void, setCameraInputs: (enabled: boolean) => void }} input
* As in Cesium, a listener with a modifier fires only while that key is
* held and one without fires only while no modifier is held.
* @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits * @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 * @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. * Sources are maps/registry.js descriptions; the engine builds its own providers.
@@ -81,26 +156,30 @@ export const INPUT_TYPES = Object.freeze([
'down', 'down',
'up', 'up',
]); ]);
export const MODIFIERS = Object.freeze(['ctrl', 'shift', 'alt']);
export const FLIGHT_EASINGS = Object.freeze([ export const FLIGHT_EASINGS = Object.freeze([
'cubicInOut', 'cubicInOut',
'sinusoidalInOut', 'sinusoidalInOut',
'linear', 'linear',
]); ]);
const ENGINE_EVENT = Symbol('gev.globe.engineEvent');
/** Whether a value is an event made by createEngineEvent. */
export const isEngineEvent = (value) =>
value != null && value[ENGINE_EVENT] === true;
/** /**
* An event with Cesium.Event semantics: listeners run in registration order, * An event with the contract's semantics (see EngineEvent). `raiseEvent` is
* removal during dispatch is deferred safely, and listeners added during * for engine implementations; a listener that throws propagates, and the
* dispatch run from the next raise. `raiseEvent` is for engine implementations. * event stays usable.
*/ */
export function createEngineEvent() { export function createEngineEvent() {
let entries = []; let entries = [];
let depth = 0; let depth = 0;
let removedDuringRaise = false; let removedDuringRaise = false;
const compact = () => { const active = (listener) =>
if (depth || !removedDuringRaise) return; entries.find((entry) => entry.listener === listener);
entries = entries.filter((entry) => entry.listener);
removedDuringRaise = false;
};
const removeEntry = (entry) => { const removeEntry = (entry) => {
if (!entry.listener) return false; if (!entry.listener) return false;
entry.listener = null; entry.listener = null;
@@ -108,7 +187,7 @@ export function createEngineEvent() {
else entries.splice(entries.indexOf(entry), 1); else entries.splice(entries.indexOf(entry), 1);
return true; return true;
}; };
return { const event = {
get numberOfListeners() { get numberOfListeners() {
return entries.reduce( return entries.reduce(
(count, entry) => count + (entry.listener ? 1 : 0), (count, entry) => count + (entry.listener ? 1 : 0),
@@ -118,16 +197,17 @@ export function createEngineEvent() {
addEventListener(listener) { addEventListener(listener) {
if (typeof listener !== 'function') if (typeof listener !== 'function')
throw new TypeError('Engine event listener must be a function'); throw new TypeError('Engine event listener must be a function');
const entry = { listener }; let entry = active(listener);
entries.push(entry); if (!entry) {
entry = { listener };
entries.push(entry);
}
return () => { return () => {
removeEntry(entry); removeEntry(entry);
}; };
}, },
removeEventListener(listener) { removeEventListener(listener) {
const entry = entries.find( const entry = active(listener);
(candidate) => candidate.listener === listener,
);
return entry ? removeEntry(entry) : false; return entry ? removeEntry(entry) : false;
}, },
raiseEvent(...args) { raiseEvent(...args) {
@@ -141,19 +221,20 @@ export function createEngineEvent() {
} }
} finally { } finally {
depth--; depth--;
compact(); if (!depth && removedDuringRaise) {
entries = entries.filter((entry) => entry.listener);
removedDuringRaise = false;
}
} }
}, },
}; };
Object.defineProperty(event, ENGINE_EVENT, { value: true });
return event;
} }
const isFunction = (value) => typeof value === 'function'; const isFunction = (value) => typeof value === 'function';
const isObject = (value) => const isObject = (value) =>
value != null && (typeof value === 'object' || typeof value === 'function'); 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 isBoolean = (value) => typeof value === 'boolean';
const isNumber = (value) => typeof value === 'number' && Number.isFinite(value); const isNumber = (value) => typeof value === 'number' && Number.isFinite(value);
@@ -165,15 +246,20 @@ const SHAPE = {
setView: isFunction, setView: isFunction,
flyTo: isFunction, flyTo: isFunction,
flyToSphere: isFunction, flyToSphere: isFunction,
flightActive: isFunction,
lookAt: isFunction, lookAt: isFunction,
lookAtTransform: isFunction,
transform: isFunction,
release: isFunction, release: isFunction,
cancelFlight: isFunction, cancelFlight: isFunction,
zoomIn: isFunction,
zoomOut: isFunction,
viewRectangle: isFunction, viewRectangle: isFunction,
pickEllipsoid: isFunction, pickEllipsoid: isFunction,
pickRay: isFunction, pickRay: isFunction,
changed: isEvent, changed: isEngineEvent,
moveStart: isEvent, moveStart: isEngineEvent,
moveEnd: isEvent, moveEnd: isEngineEvent,
percentageChanged: isNumber, percentageChanged: isNumber,
}, },
picker: { picker: {
@@ -182,8 +268,16 @@ const SHAPE = {
pickPosition: isFunction, pickPosition: isFunction,
pickPositionSupported: isBoolean, pickPositionSupported: isBoolean,
}, },
projector: { worldToWindow: isFunction }, projector: {
frames: { preUpdate: isEvent, preRender: isEvent, postRender: isEvent }, worldToWindow: isFunction,
viewProjection: isFunction,
horizonVisible: isFunction,
},
frames: {
preUpdate: isEngineEvent,
preRender: isEngineEvent,
postRender: isEngineEvent,
},
render: { render: {
request: isFunction, request: isFunction,
setContinuous: isFunction, setContinuous: isFunction,
@@ -192,6 +286,10 @@ const SHAPE = {
surface: { surface: {
sampleHeight: isFunction, sampleHeight: isFunction,
supported: isBoolean, supported: isBoolean,
terrainHeight: isFunction,
terrainHeightSupported: isBoolean,
clampToHeight: isFunction,
clampToHeightSupported: isBoolean,
tilesLoaded: isFunction, tilesLoaded: isFunction,
}, },
input: { on: isFunction, setCameraInputs: isFunction }, input: { on: isFunction, setCameraInputs: isFunction },
+77 -9
View File
@@ -3,8 +3,10 @@ import test from 'node:test';
import { import {
ENGINE_KINDS, ENGINE_KINDS,
INPUT_TYPES, INPUT_TYPES,
MODIFIERS,
assertGlobeEngine, assertGlobeEngine,
createEngineEvent, createEngineEvent,
isEngineEvent,
} from './contract.js'; } from './contract.js';
import { createFakeEngine } from './testing/fakeEngine.js'; import { createFakeEngine } from './testing/fakeEngine.js';
import { import {
@@ -44,7 +46,7 @@ test('engine event passes every argument and rejects non-functions', () => {
assert.throws(() => event.addEventListener(null), TypeError); assert.throws(() => event.addEventListener(null), TypeError);
}); });
test('removing listeners during dispatch is safe, as with Cesium.Event', () => { test('a listener removed during dispatch is not called in that dispatch', () => {
const event = createEngineEvent(); const event = createEngineEvent();
const seen = []; const seen = [];
let removeLater; let removeLater;
@@ -76,23 +78,60 @@ test('listeners added during dispatch run from the next raise', () => {
assert.deepEqual(seen, ['first', 'first', 'added']); assert.deepEqual(seen, ['first', 'first', 'added']);
}); });
test('a listener can be added again after removal, and each handle removes its own entry', () => { test('adding the same listener twice registers it once; every remover handle is idempotent', () => {
const event = createEngineEvent(); const event = createEngineEvent();
let count = 0; let count = 0;
const listener = () => count++; const listener = () => count++;
const first = event.addEventListener(listener); const first = event.addEventListener(listener);
first();
first();
const second = event.addEventListener(listener); const second = event.addEventListener(listener);
const third = event.addEventListener(listener); assert.equal(event.numberOfListeners, 1);
event.raiseEvent(); event.raiseEvent();
assert.equal(count, 2); assert.equal(count, 1);
second(); second();
second();
first();
event.raiseEvent(); event.raiseEvent();
assert.equal(count, 3); assert.equal(count, 1);
third(); assert.equal(event.numberOfListeners, 0);
const again = event.addEventListener(listener);
first();
event.raiseEvent(); event.raiseEvent();
assert.equal(count, 3); assert.equal(count, 2, 'a stale handle removed a newer registration');
again();
assert.equal(event.numberOfListeners, 0);
});
test('removing and re-adding a listener during dispatch leaves it registered', () => {
const event = createEngineEvent();
const seen = [];
const later = () => seen.push('later');
event.addEventListener(() => {
seen.push('first');
if (seen.length > 1) return;
event.removeEventListener(later);
event.addEventListener(later);
});
event.addEventListener(later);
event.raiseEvent();
assert.equal(event.numberOfListeners, 2);
event.raiseEvent();
assert.deepEqual(seen, ['first', 'first', 'later']);
});
test('engine events are branded so a raw Cesium.Event fails the shape check', () => {
assert.equal(isEngineEvent(createEngineEvent()), true);
const raw = { addEventListener() {}, removeEventListener() {} };
assert.equal(isEngineEvent(raw), false);
const engine = createFakeEngine();
assert.throws(
() =>
assertGlobeEngine({
...engine,
frames: { ...engine.frames, preRender: raw },
}),
/frames\.preRender/,
);
assert.deepEqual(MODIFIERS, ['ctrl', 'shift', 'alt']);
}); });
test('a throwing listener does not leave the event stuck in dispatch', () => { test('a throwing listener does not leave the event stuck in dispatch', () => {
@@ -168,3 +207,32 @@ test('the contract suite reports failures of a misbehaving engine', async () =>
assert.ok(failed.every((result) => result.error instanceof Error)); assert.ok(failed.every((result) => result.error instanceof Error));
assert.ok(results.some((result) => result.ok)); assert.ok(results.some((result) => result.ok));
}); });
test('the contract suite rejects a raw Cesium-style event that double-registers', async () => {
const engine = createFakeEngine();
// Cesium.Event pushes a listener again when it is added twice.
const listeners = [];
const raw = {
addEventListener(listener) {
listeners.push(listener);
return () => raw.removeEventListener(listener);
},
removeEventListener(listener) {
const index = listeners.indexOf(listener);
if (index >= 0) listeners.splice(index, 1);
return index >= 0;
},
};
engine.frames.postRender.addEventListener((time) => {
for (const listener of [...listeners]) listener(time);
});
const wrapped = { ...engine, frames: { ...engine.frames, postRender: raw } };
const results = await runContractSuite(wrapped, {
waitFrames: async (count) => engine.fake.frames(count),
});
const failed = results.filter((result) => !result.ok).map((r) => r.name);
assert.ok(
failed.some((name) => /twice/.test(name)),
failed.join('\n'),
);
});
+508 -13
View File
@@ -10,9 +10,9 @@
// Each check sets the view it needs; checks may leave the camera anywhere. // Each check sets the view it needs; checks may leave the camera anywhere.
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { INPUT_TYPES, assertGlobeEngine } from '../contract.js'; import { INPUT_TYPES, MODIFIERS, assertGlobeEngine } from '../contract.js';
const { cartesian2, cartesian3, cartographic, ellipsoid, math } = geo; const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
const WGS84 = ellipsoid.WGS84; const WGS84 = ellipsoid.WGS84;
class ContractError extends Error { class ContractError extends Error {
@@ -42,6 +42,23 @@ const isCartesian3 = (value) =>
Number.isFinite(value.y) && Number.isFinite(value.y) &&
Number.isFinite(value.z); Number.isFinite(value.z);
// The local east/north/up axes at a position.
function localAxes(position) {
const frame = geo.transforms.eastNorthUpToFixedFrame(position, WGS84);
return {
frame,
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 inRange = (value, low, high, label) =>
ensure(
typeof value === 'number' && value >= low && value < high,
`${label} ${value} is outside [${low}, ${high})`,
);
const unit = (vector) => cartesian3.normalize(vector, cartesian3.create()); const unit = (vector) => cartesian3.normalize(vector, cartesian3.create());
const along = (origin, direction, distance) => const along = (origin, direction, distance) =>
cartesian3.add( cartesian3.add(
@@ -313,7 +330,7 @@ export function createContractSuite(
}, },
], ],
[ [
'camera.flyTo: duration 0 lands on the destination and completes', 'camera.flyTo: duration 0 lands and completes before flyTo returns',
async () => { async () => {
await nadirView(); await nadirView();
const destination = cartesian3.fromDegrees(-3.7, 40.4, 50000); const destination = cartesian3.fromDegrees(-3.7, 40.4, 50000);
@@ -325,8 +342,11 @@ export function createContractSuite(
complete: () => outcome.push('complete'), complete: () => outcome.push('complete'),
cancel: () => outcome.push('cancel'), cancel: () => outcome.push('cancel'),
}); });
await until(() => outcome.length > 0, 'flight completion'); ensure(
ensure(outcome.join() === 'complete', `flight ended with ${outcome}`); outcome.join() === 'complete',
`flyTo returned with [${outcome}] instead of [complete]`,
);
ensure(!camera.flightActive(), 'a duration-0 flight is still active');
const pose = camera.pose(); const pose = camera.pose();
near( near(
cartesian3.distance(pose.position, destination), cartesian3.distance(pose.position, destination),
@@ -595,8 +615,30 @@ export function createContractSuite(
}, },
], ],
[ [
'surface: supported, tilesLoaded and sampleHeight shapes', 'surface: flags, tilesLoaded, sampleHeight, terrainHeight and clampToHeight shapes',
async () => { async () => {
for (const flag of ['terrainHeightSupported', 'clampToHeightSupported'])
ensure(
typeof engine.surface[flag] === 'boolean',
`${flag} is not boolean`,
);
const place = cartographic.fromDegrees(20, 10, 0);
if (engine.surface.terrainHeightSupported) {
const terrain = engine.surface.terrainHeight(place);
ensure(
terrain === undefined || Number.isFinite(terrain),
`terrainHeight returned ${terrain}`,
);
}
if (engine.surface.clampToHeightSupported) {
const clamped = engine.surface.clampToHeight(
cartesian3.fromDegrees(20, 10, 5000),
);
ensure(
clamped === undefined || isCartesian3(clamped),
'clampToHeight is not a Cartesian3',
);
}
ensure( ensure(
typeof engine.surface.supported === 'boolean', typeof engine.surface.supported === 'boolean',
'supported is not boolean', 'supported is not boolean',
@@ -619,13 +661,15 @@ export function createContractSuite(
'input: every type subscribes and unsubscribes; camera inputs toggle', 'input: every type subscribes and unsubscribes; camera inputs toggle',
async () => { async () => {
for (const type of INPUT_TYPES) { for (const type of INPUT_TYPES) {
const off = engine.input.on(type, () => {}); for (const modifier of [undefined, ...MODIFIERS]) {
ensure( const off = engine.input.on(type, () => {}, { modifier });
typeof off === 'function', ensure(
`input.on('${type}') returned no remover`, typeof off === 'function',
); `input.on('${type}', ${modifier}) returned no remover`,
off(); );
off(); off();
off();
}
} }
engine.input.setCameraInputs(false); engine.input.setCameraInputs(false);
engine.input.setCameraInputs(true); engine.input.setCameraInputs(true);
@@ -652,6 +696,457 @@ export function createContractSuite(
); );
}, },
], ],
[
'events: adding a listener twice registers it once',
async () => {
let once = 0;
let twice = 0;
const reference = () => once++;
const listener = () => twice++;
const removers = [
engine.frames.postRender.addEventListener(reference),
engine.frames.postRender.addEventListener(listener),
engine.frames.postRender.addEventListener(listener),
];
try {
await until(() => once >= 2, 'postRender');
} finally {
for (const remove of removers) remove();
}
ensure(
twice === once,
`a listener added twice fired ${twice} times in ${once} frames`,
);
removers[1]();
removers[2]();
await settle(2);
ensure(twice === once, 'a removed listener still fired');
},
],
[
'events: removal during dispatch skips the listener; re-adding keeps it',
async () => {
const event = engine.frames.postRender;
const seen = [];
const victim = () => seen.push('victim');
const survivor = () => seen.push('survivor');
let dispatches = 0;
const remover = () => {
dispatches++;
if (dispatches !== 1) return;
event.removeEventListener(victim);
event.removeEventListener(survivor);
event.addEventListener(survivor);
};
event.addEventListener(remover);
event.addEventListener(victim);
event.addEventListener(survivor);
try {
await until(() => dispatches >= 3, 'three postRender dispatches');
} finally {
for (const listener of [remover, victim, survivor])
event.removeEventListener(listener);
}
ensure(
!seen.includes('victim'),
'a listener removed during dispatch ran',
);
ensure(
seen.includes('survivor'),
'a listener removed and re-added during dispatch was lost',
);
},
],
[
'camera.pose: angle ranges follow Cesium',
async () => {
const destination = cartesian3.fromDegrees(30, 20, 20000);
camera.setView({
destination,
orientation: { heading: -0.5, pitch: -0.4, roll: -0.2 },
});
await settle();
let pose = camera.pose();
inRange(pose.heading, 0, math.TWO_PI, 'heading');
inRange(pose.roll, 0, math.TWO_PI, 'roll');
near(pose.heading, math.TWO_PI - 0.5, 1e-6, 'heading of −0.5');
near(pose.roll, math.TWO_PI - 0.2, 1e-6, 'roll of −0.2');
ensure(
pose.pitch >= -math.PI_OVER_TWO && pose.pitch <= math.PI_OVER_TWO,
`pitch ${pose.pitch} outside [−π/2, π/2]`,
);
camera.setView({
destination,
orientation: { heading: 0.7, pitch: -math.PI_OVER_TWO, roll: 0.3 },
});
await settle();
pose = camera.pose();
near(pose.roll, 0, 0, 'roll looking straight down');
inRange(pose.heading, 0, math.TWO_PI, 'heading looking straight down');
},
],
[
'camera.setView: heading π/2 at pitch 0 looks east',
async () => {
const destination = cartesian3.fromDegrees(-120, 35, 3000);
camera.setView({
destination,
orientation: { heading: math.PI_OVER_TWO, pitch: 0, roll: 0 },
});
await settle();
const pose = camera.pose();
const axes = localAxes(pose.position);
near(
cartesian3.dot(pose.direction, axes.east),
1,
1e-6,
'direction·east',
);
near(cartesian3.dot(pose.up, axes.up), 1, 1e-6, 'up·localUp');
},
],
[
'camera.setView: pitch −π/2 looks down the local up',
async () => {
const destination = cartesian3.fromDegrees(-120, 35, 3000);
camera.setView({
destination,
orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 },
});
await settle();
const pose = camera.pose();
const axes = localAxes(pose.position);
near(cartesian3.dot(pose.direction, axes.up), -1, 1e-6, 'direction·up');
near(
cartesian3.dot(pose.up, axes.north),
1,
1e-6,
'up·north at heading 0',
);
},
],
[
'camera.setView: positive roll tilts up toward the camera right',
async () => {
const destination = cartesian3.fromDegrees(10, 45, 3000);
camera.setView({
destination,
orientation: { heading: 0.4, pitch: -0.2, roll: 0 },
});
await settle();
const level = camera.pose();
camera.setView({
destination,
orientation: { heading: 0.4, pitch: -0.2, roll: 0.3 },
});
await settle();
const rolled = camera.pose();
near(
cartesian3.dot(rolled.up, level.right),
Math.sin(0.3),
1e-6,
'up·levelRight',
);
near(
cartesian3.dot(rolled.direction, level.direction),
1,
1e-9,
'roll changed the direction',
);
},
],
[
'camera.flightActive: true during a flight, false after it ends',
async () => {
await nadirView();
ensure(!camera.flightActive(), 'a flight is active before flyTo');
camera.flyTo({
destination: cartesian3.fromDegrees(100, 0, 5e6),
duration: 5,
});
ensure(camera.flightActive(), 'no flight is active after flyTo');
camera.cancelFlight();
ensure(
!camera.flightActive(),
'the flight is active after cancelFlight',
);
},
],
[
'camera.flyToSphere: Cesium default heading, pitch and range',
async () => {
await nadirView();
const center = cartesian3.fromDegrees(2.35, 48.85, 100);
const radius = 1000;
let done = false;
camera.flyToSphere(
{ center, radius },
{ duration: 0, complete: () => (done = true) },
);
ensure(
done,
'a duration-0 sphere flight did not complete synchronously',
);
const pose = camera.pose();
const { width, height } = engine.host.size();
const tanPhi = Math.tan(pose.fovy / 2);
const tanTheta = (width / height) * tanPhi;
const range = Math.max(100, radius / tanTheta, radius / tanPhi);
near(
cartesian3.distance(pose.position, center),
range,
range * 1e-6,
'range (m)',
);
const axes = localAxes(center);
const toCamera = unit(
cartesian3.subtract(pose.position, center, cartesian3.create()),
);
near(
cartesian3.dot(toCamera, axes.up),
Math.sin(Math.PI / 4),
1e-6,
'pitch −π/4',
);
near(
cartesian3.dot(toCamera, axes.east),
0,
1e-6,
'heading 0 (east offset)',
);
ensure(
cartesian3.dot(toCamera, axes.north) < 0,
'camera is not south of the centre',
);
camera.flyToSphere({ center, radius: 0 }, { duration: 0 });
near(
cartesian3.distance(camera.pose().position, center),
100,
1e-3,
'minimum range (m)',
);
const identity = camera.transform();
for (let index = 0; index < 16; index++)
near(
identity[index],
matrix4.IDENTITY[index],
0,
`transform[${index}]`,
);
},
],
[
'camera.lookAtTransform: heading/pitch/range and Cartesian offsets, transform and release',
async () => {
await nadirView();
const target = cartesian3.fromDegrees(-0.12, 51.5, 0);
const { frame, up } = localAxes(target);
camera.lookAtTransform(frame, {
heading: 0.4,
pitch: -0.7,
range: 2000,
});
await settle();
let pose = camera.pose();
near(
cartesian3.distance(pose.position, target),
2000,
1e-3,
'range (m)',
);
const aim = () =>
cartesian3.dot(
unit(
cartesian3.subtract(
target,
camera.pose().position,
cartesian3.create(),
),
),
camera.pose().direction,
);
near(aim(), 1, 1e-9, 'direction·toTarget');
const held = camera.transform();
for (let index = 0; index < 16; index++)
near(held[index], frame[index], 1e-6, `transform[${index}]`);
camera.lookAtTransform(frame, cartesian3.create(0, -1000, 1000));
await settle();
pose = camera.pose();
near(
cartesian3.distance(pose.position, target),
Math.SQRT2 * 1000,
1e-3,
'Cartesian offset range (m)',
);
near(aim(), 1, 1e-9, 'Cartesian offset direction·toTarget');
ensure(
cartesian3.dot(pose.up, up) > 0,
'up does not follow the frame +Z',
);
camera.release();
await settle();
near(
cartesian3.distance(camera.pose().position, pose.position),
0,
0.01,
'release moved the camera (m)',
);
const released = camera.transform();
for (let index = 0; index < 16; index++)
near(
released[index],
matrix4.IDENTITY[index],
0,
`released transform[${index}]`,
);
},
],
[
'camera.zoomIn and zoomOut move along the view direction',
async () => {
camera.setView({
destination: cartesian3.fromDegrees(20, 10, 50000),
orientation: { heading: 0.3, pitch: -0.9, roll: 0 },
});
await settle();
const start = camera.pose();
camera.zoomIn(2000);
await settle();
const zoomed = camera.pose().position;
const moved = cartesian3.subtract(
zoomed,
start.position,
cartesian3.create(),
);
near(
cartesian3.dot(moved, start.direction),
2000,
1e-3,
'zoomIn distance (m)',
);
near(
cartesian3.magnitude(moved),
2000,
1e-3,
'zoomIn stays on the view axis',
);
camera.zoomOut(500);
await settle();
near(
cartesian3.distance(camera.pose().position, start.position),
1500,
1e-3,
'zoomOut distance (m)',
);
},
],
[
'camera.viewRectangle: wraps west > east across the antimeridian',
async () => {
camera.setView({
destination: cartesian3.fromDegrees(180, 0, 1e6),
orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 },
});
await settle();
const rectangle = camera.viewRectangle();
ensure(rectangle, 'no view rectangle over the antimeridian');
ensure(
rectangle.west > rectangle.east,
`west ${rectangle.west} is not east of east ${rectangle.east}`,
);
ensure(
rectangle.west > 0 && rectangle.east < 0,
'the rectangle does not cross ±180°',
);
ensure(
rectangle.south < 0 && rectangle.north > 0,
'the rectangle misses the equator',
);
},
],
[
'camera.viewRectangle: spans all longitudes over a pole',
async () => {
camera.setView({
destination: cartesian3.fromDegrees(0, 89, 1e6),
orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 },
});
await settle();
const rectangle = camera.viewRectangle();
ensure(rectangle, 'no view rectangle over the pole');
near(rectangle.west, -Math.PI, 1e-9, 'west');
near(rectangle.east, Math.PI, 1e-9, 'east');
near(rectangle.north, math.PI_OVER_TWO, 1e-9, 'north');
ensure(
rectangle.south > 0,
`south ${rectangle.south} crosses the equator`,
);
},
],
[
'projector.viewProjection: agrees with worldToWindow',
async () => {
camera.setView({
destination: cartesian3.fromDegrees(20, 10, 30000),
orientation: { heading: 0.6, pitch: -0.5, roll: 0.1 },
});
await settle();
const { width, height } = engine.host.size();
const result = new Array(16).fill(0);
const matrix = engine.projector.viewProjection(result);
ensure(
matrix === result,
'viewProjection did not fill its result argument',
);
ensure(
matrix.length === 16 && matrix.every(Number.isFinite),
'not 16 finite numbers',
);
let compared = 0;
for (const [x, y] of [
[width / 2, height / 2],
[width / 5, height / 4],
[width * 0.9, height * 0.8],
]) {
const point = camera.pickEllipsoid(cartesian2.create(x, y));
if (!point) continue;
const window = engine.projector.worldToWindow(point);
ensure(window, 'a picked point did not project');
const clip = [0, 1, 2, 3].map(
(row) =>
matrix[row] * point.x +
matrix[4 + row] * point.y +
matrix[8 + row] * point.z +
matrix[12 + row],
);
ensure(clip[3] > 0, 'a visible point has clip.w <= 0');
near(((clip[0] / clip[3] + 1) / 2) * width, window.x, 0.5, 'x (px)');
near(((1 - clip[1] / clip[3]) / 2) * height, window.y, 0.5, 'y (px)');
compared++;
}
ensure(compared >= 2, 'too few screen points hit the globe');
},
],
[
'projector.horizonVisible: the nadir is visible and the far side hidden',
async () => {
await nadirView();
ensure(
engine.projector.horizonVisible(cartesian3.fromDegrees(20, 10, 0)),
'the nadir is hidden',
);
ensure(
!engine.projector.horizonVisible(
cartesian3.fromDegrees(-160, -10, 0),
),
'the antipode is visible',
);
ensure(
engine.projector.horizonVisible(cartesian3.fromDegrees(-70, 10, 2e7)),
'a high point beside the limb is hidden',
);
},
],
]; ];
return checks.map(([name, run]) => ({ name, run })); return checks.map(([name, run]) => ({ name, run }));
} }
+418 -201
View File
@@ -3,12 +3,29 @@
// against it, records every contract call and lets tests emit events, drive // against it, records every contract call and lets tests emit events, drive
// input, advance frames and script pick/height/projection results through // input, advance frames and script pick/height/projection results through
// `engine.fake`. It passes src/globe/testing/contractSuite.mjs. // `engine.fake`. It passes src/globe/testing/contractSuite.mjs.
//
// Simplifications against Cesium, which shell tests must not rely on:
// - a flight interpolates the position linearly (eased) through space and
// snaps the orientation at the end; Cesium arcs over the globe and turns
// gradually. A flight without a duration lasts 3 s of fake frame time.
// - moveStart/changed/moveEnd are detected once per `fake.frame()`, and
// moveEnd fires after one frame without movement; Cesium waits 500 ms
// (scene.cameraEventWaitTime) of wall time.
// - nothing occludes picks or projection except where the contract says so
// (horizonVisible); there is no terrain or scene geometry unless scripted.
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { INPUT_TYPES, createEngineEvent } from '../contract.js'; import { INPUT_TYPES, MODIFIERS, createEngineEvent } from '../contract.js';
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo; const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
const WGS84 = ellipsoid.WGS84; const WGS84 = ellipsoid.WGS84;
const RADII = WGS84.radii;
const MAXIMUM_RADIUS = Math.max(RADII.x, RADII.y, RADII.z);
const DEFAULT_FLIGHT_SECONDS = 3;
const DEFAULT_ZOOM_AMOUNT = 100000; // Cesium's camera.defaultZoomAmount
const MINIMUM_SPHERE_RANGE = 100;
const NEAR = 1;
const FAR = 1e9;
const SCRIPTABLE = new Set([ const SCRIPTABLE = new Set([
'pick', 'pick',
'drillPick', 'drillPick',
@@ -16,6 +33,8 @@ const SCRIPTABLE = new Set([
'pickEllipsoid', 'pickEllipsoid',
'viewRectangle', 'viewRectangle',
'sampleHeight', 'sampleHeight',
'terrainHeight',
'clampToHeight',
'tilesLoaded', 'tilesLoaded',
'worldToWindow', 'worldToWindow',
]); ]);
@@ -27,11 +46,26 @@ const EASINGS = {
}; };
const copy3 = (value) => cartesian3.create(value.x, value.y, value.z); const copy3 = (value) => cartesian3.create(value.x, value.y, value.z);
const scaled = (vector, scalar) =>
cartesian3.multiplyByScalar(vector, scalar, cartesian3.create());
const sum = (...vectors) =>
vectors.reduce(
(total, vector) => cartesian3.add(total, vector, total),
cartesian3.create(),
);
const unit = (vector) => cartesian3.normalize(vector, cartesian3.create());
const cross = (left, right) =>
cartesian3.cross(left, right, cartesian3.create());
// An angle in [0, 2π), as Cesium's camera reports it.
function wrapAngle(angle) {
const value = math.zeroToTwoPi(angle);
return value < 1e-12 || math.TWO_PI - value < 1e-12 ? 0 : value;
}
// The local east/north/up axes at a position, in Earth-fixed coordinates. // The local east/north/up axes at a position, in Earth-fixed coordinates.
function localAxes(position) { function localAxes(position) {
const frame = matrix4.create(); const frame = geo.transforms.eastNorthUpToFixedFrame(position, WGS84);
geo.transforms.eastNorthUpToFixedFrame(position, WGS84, frame);
return { return {
east: cartesian3.create(frame[0], frame[1], frame[2]), east: cartesian3.create(frame[0], frame[1], frame[2]),
north: cartesian3.create(frame[4], frame[5], frame[6]), north: cartesian3.create(frame[4], frame[5], frame[6]),
@@ -40,14 +74,16 @@ function localAxes(position) {
} }
const combine = (axes, e, n, u) => const combine = (axes, e, n, u) =>
cartesian3.create( sum(scaled(axes.east, e), scaled(axes.north, n), scaled(axes.up, u));
axes.east.x * e + axes.north.x * n + axes.up.x * u, const toLocal = (axes, vector) => ({
axes.east.y * e + axes.north.y * n + axes.up.y * u, e: cartesian3.dot(vector, axes.east),
axes.east.z * e + axes.north.z * n + axes.up.z * u, n: cartesian3.dot(vector, axes.north),
); u: cartesian3.dot(vector, axes.up),
});
// Direction and up for a heading/pitch/roll in the local frame at position. // 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. // as Cesium's setView builds them: heading 0 looks north, positive pitch looks
// up, positive roll tilts `up` toward the camera's right.
function orientationVectors(position, heading, pitch, roll) { function orientationVectors(position, heading, pitch, roll) {
const axes = localAxes(position); const axes = localAxes(position);
const cosPitch = Math.cos(pitch); const cosPitch = Math.cos(pitch);
@@ -58,54 +94,50 @@ function orientationVectors(position, heading, pitch, roll) {
Math.sin(pitch), Math.sin(pitch),
); );
const levelRight = combine(axes, Math.cos(heading), -Math.sin(heading), 0); const levelRight = combine(axes, Math.cos(heading), -Math.sin(heading), 0);
const levelUp = cartesian3.cross(levelRight, direction, cartesian3.create()); const levelUp = cross(levelRight, direction);
const up = cartesian3.create(); const up = sum(
cartesian3.add( scaled(levelUp, Math.cos(roll)),
cartesian3.multiplyByScalar(levelUp, Math.cos(roll), cartesian3.create()), scaled(levelRight, Math.sin(roll)),
cartesian3.multiplyByScalar(
levelRight,
-Math.sin(roll),
cartesian3.create(),
),
up,
); );
return { direction, up: cartesian3.normalize(up, up) }; return { direction: unit(direction), up: unit(up) };
} }
// Cesium's Camera heading/pitch/roll getters, in the local frame at position.
function headingPitchRoll(position, direction, up) { function headingPitchRoll(position, direction, up) {
const axes = localAxes(position); const axes = localAxes(position);
const e = cartesian3.dot(direction, axes.east); const d = toLocal(axes, direction);
const n = cartesian3.dot(direction, axes.north); const u = toLocal(axes, up);
const u = cartesian3.dot(direction, axes.up); const r = toLocal(axes, cross(direction, up));
const pitch = Math.asin(math.clamp(u, -1, 1)); const vertical = Math.abs(Math.abs(d.u) - 1) <= 1e-3;
// Looking straight up or down, heading follows the camera's up vector. const raw = vertical
const heading = ? Math.atan2(u.n, u.e) - math.PI_OVER_TWO
Math.hypot(e, n) > 1e-9 : Math.atan2(d.n, d.e) - math.PI_OVER_TWO;
? math.zeroToTwoPi(Math.atan2(e, n)) return {
: math.zeroToTwoPi( heading: wrapAngle(math.TWO_PI - math.zeroToTwoPi(raw)),
Math.atan2( pitch: math.PI_OVER_TWO - Math.acos(math.clamp(d.u, -1, 1)),
cartesian3.dot(up, axes.east) * -Math.sign(u), roll: vertical ? 0 : wrapAngle(Math.atan2(-r.u, u.u)),
cartesian3.dot(up, axes.north) * -Math.sign(u), };
), }
);
const level = orientationVectors(position, heading, pitch, 0); // Camera offset from a target for a HeadingPitchRange, in the target's frame.
const levelRight = cartesian3.cross( function offsetFromHeadingPitchRange(heading, pitch, range) {
level.direction, const p = math.clamp(pitch, -math.PI_OVER_TWO, math.PI_OVER_TWO);
level.up, const cosPitch = Math.cos(p);
cartesian3.create(), return cartesian3.create(
-Math.sin(heading) * cosPitch * range,
-Math.cos(heading) * cosPitch * range,
-Math.sin(p) * range,
); );
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. // Nearest intersection of a ray with the WGS84 ellipsoid, if any.
function intersectEllipsoid(origin, direction) { function intersectEllipsoid(origin, direction) {
const r = WGS84.radii; const o = [origin.x / RADII.x, origin.y / RADII.y, origin.z / RADII.z];
const o = [origin.x / r.x, origin.y / r.y, origin.z / r.z]; const d = [
const d = [direction.x / r.x, direction.y / r.y, direction.z / r.z]; direction.x / RADII.x,
direction.y / RADII.y,
direction.z / RADII.z,
];
const a = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; 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 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 c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - 1;
@@ -115,11 +147,60 @@ function intersectEllipsoid(origin, direction) {
const near = (-b - root) / (2 * a); const near = (-b - root) / (2 * a);
const t = near > 0 ? near : (-b + root) / (2 * a); const t = near > 0 ? near : (-b + root) / (2 * a);
if (!(t > 0)) return undefined; if (!(t > 0)) return undefined;
return cartesian3.add( return sum(origin, scaled(direction, t));
origin, }
cartesian3.multiplyByScalar(direction, t, cartesian3.create()),
cartesian3.create(), // Cesium's computeHorizonQuad: four limb points around the camera's nadir.
function horizonQuad(position) {
const q = cartesian3.create(
position.x / RADII.x,
position.y / RADII.y,
position.z / RADII.z,
); );
const qMagnitude = cartesian3.magnitude(q);
const qUnit = unit(q);
let east;
let north;
if (cartesian3.equalsEpsilon(qUnit, cartesian3.UNIT_Z, math.EPSILON10)) {
east = cartesian3.create(0, 1, 0);
north = cartesian3.create(0, 0, 1);
} else {
east = unit(cross(cartesian3.UNIT_Z, qUnit));
north = unit(cross(qUnit, east));
}
const center = scaled(qUnit, 1 / qMagnitude);
const offset =
Math.sqrt(Math.max(0, cartesian3.magnitudeSquared(q) - 1)) / qMagnitude;
const point = (n, e) => {
const p = sum(center, scaled(north, n * offset), scaled(east, e * offset));
return cartesian3.create(p.x * RADII.x, p.y * RADII.y, p.z * RADII.z);
};
// Same order as the corner picks: upper-left, lower-left, lower-right, upper-right.
return [point(1, -1), point(-1, -1), point(-1, 1), point(1, 1)];
}
// Cesium's Rectangle.fromCartographicArray, including the antimeridian case.
function rectangleFromCartographics(points) {
let west = Infinity;
let east = -Infinity;
let westOverIDL = Infinity;
let eastOverIDL = -Infinity;
let south = Infinity;
let north = -Infinity;
for (const { longitude, latitude } of points) {
west = Math.min(west, longitude);
east = Math.max(east, longitude);
south = Math.min(south, latitude);
north = Math.max(north, latitude);
const adjusted = longitude >= 0 ? longitude : longitude + math.TWO_PI;
westOverIDL = Math.min(westOverIDL, adjusted);
eastOverIDL = Math.max(eastOverIDL, adjusted);
}
if (east - west > eastOverIDL - westOverIDL) {
west = westOverIDL > Math.PI ? westOverIDL - math.TWO_PI : westOverIDL;
east = eastOverIDL > Math.PI ? eastOverIDL - math.TWO_PI : eastOverIDL;
}
return { west, south, east, north };
} }
/** /**
@@ -153,6 +234,7 @@ export function createFakeEngine(options = {}) {
}; };
const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()])); const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()]));
const scripts = new Map(); const scripts = new Map();
let transform = [...matrix4.IDENTITY];
let percentageChanged = 0.5; let percentageChanged = 0.5;
let continuous = false; let continuous = false;
let flight = null; let flight = null;
@@ -172,85 +254,63 @@ export function createFakeEngine(options = {}) {
}; };
const record = (method, args) => fake.calls.push({ method, args }); const record = (method, args) => fake.calls.push({ method, args });
function setOrientation(position, orientation) { const aspect = () => width / height;
if (orientation && 'direction' in orientation) { const right = () => unit(cross(state.direction, state.up));
state.position = copy3(position); const poseAngles = () =>
state.direction = cartesian3.normalize( headingPitchRoll(state.position, state.direction, state.up);
orientation.direction,
cartesian3.create(), // The pose for a destination and orientation; missing angles use `fallback`.
); function viewFor(position, orientation, fallback) {
state.up = cartesian3.normalize(orientation.up, cartesian3.create()); if (orientation && 'direction' in orientation)
return; return {
} position: copy3(position),
// Missing angles keep the current ones, measured where the camera is now. direction: unit(orientation.direction),
const current = poseAngles(); up: unit(orientation.up),
state.position = copy3(position); };
const vectors = orientationVectors( const vectors = orientationVectors(
state.position, position,
orientation?.heading ?? current.heading, orientation?.heading ?? fallback.heading,
orientation?.pitch ?? current.pitch, orientation?.pitch ?? fallback.pitch,
orientation?.roll ?? current.roll, orientation?.roll ?? fallback.roll,
); );
state.direction = vectors.direction; return { position: copy3(position), ...vectors };
state.up = vectors.up; }
// Cesium's setView/flyTo defaults for missing angles.
const CESIUM_DEFAULTS = { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 };
function apply(view) {
state.position = copy3(view.position);
state.direction = copy3(view.direction);
state.up = copy3(view.up);
} }
function poseAngles() { function setTransform(next, target) {
if (cartesian3.magnitudeSquared(state.direction) === 0) transform = [...next];
return { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 }; lookAtTarget = target ? copy3(target) : null;
return headingPitchRoll(state.position, state.direction, state.up);
} }
function right() { function lookAtFrame(frame, offset) {
const result = cartesian3.cross( const local =
state.direction, offset && 'heading' in offset
state.up, ? offsetFromHeadingPitchRange(
cartesian3.create(), offset.heading,
); offset.pitch,
return cartesian3.normalize(result, result); offset.range,
} )
: copy3(offset);
// Camera placed at `range` from target, looking at it, with heading/pitch const localDirection = unit(cartesian3.negate(local, cartesian3.create()));
// measured in the target's local frame (Cesium's HeadingPitchRange). let localRight = cross(localDirection, cartesian3.UNIT_Z);
function offsetView(target, heading, pitch, range) { if (cartesian3.magnitudeSquared(localRight) < math.EPSILON10)
const toTarget = orientationVectors(target, heading, pitch, 0); localRight = copy3(cartesian3.UNIT_X);
const position = cartesian3.subtract( localRight = unit(localRight);
target, const localUp = unit(cross(localRight, localDirection));
cartesian3.multiplyByScalar( const vector = (value) =>
toTarget.direction, unit(matrix4.multiplyByPointAsVector(frame, value, cartesian3.create()));
range, apply({
cartesian3.create(), position: matrix4.multiplyByPoint(frame, local, cartesian3.create()),
), direction: vector(localDirection),
cartesian3.create(), up: vector(localUp),
); });
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() { function cancelCurrentFlight() {
@@ -259,12 +319,29 @@ export function createFakeEngine(options = {}) {
current?.cancel?.(); current?.cancel?.();
} }
function startFlight(flightOptions, target) {
cancelCurrentFlight();
const duration = flightOptions.duration ?? DEFAULT_FLIGHT_SECONDS;
if (duration <= 0) {
apply(target);
flightOptions.complete?.();
return;
}
flight = {
start: copy3(state.position),
target,
duration: duration * 1000,
elapsed: 0,
ease: EASINGS[flightOptions.easing] ?? EASINGS.cubicInOut,
complete: flightOptions.complete,
cancel: flightOptions.cancel,
};
}
function advanceFlight(dt) { function advanceFlight(dt) {
if (!flight) return; if (!flight) return;
flight.elapsed += dt; flight.elapsed += dt;
const t = flight.duration const t = Math.min(1, flight.elapsed / flight.duration);
? Math.min(1, flight.elapsed / flight.duration)
: 1;
if (t < 1) { if (t < 1) {
cartesian3.lerp( cartesian3.lerp(
flight.start, flight.start,
@@ -276,7 +353,7 @@ export function createFakeEngine(options = {}) {
} }
const current = flight; const current = flight;
flight = null; flight = null;
applyFlightTarget(current.target); apply(current.target);
current.complete?.(); current.complete?.();
} }
@@ -333,8 +410,7 @@ export function createFakeEngine(options = {}) {
const depth = cartesian3.dot(offset, state.direction); const depth = cartesian3.dot(offset, state.direction);
if (!(depth > 0)) return undefined; if (!(depth > 0)) return undefined;
const tangent = Math.tan(fovy / 2); const tangent = Math.tan(fovy / 2);
const ndcX = const ndcX = cartesian3.dot(offset, right()) / (depth * tangent * aspect());
cartesian3.dot(offset, right()) / (depth * tangent * (width / height));
const ndcY = cartesian3.dot(offset, state.up) / (depth * tangent); const ndcY = cartesian3.dot(offset, state.up) / (depth * tangent);
const x = ((ndcX + 1) / 2) * width; const x = ((ndcX + 1) / 2) * width;
const y = ((1 - ndcY) / 2) * height; const y = ((1 - ndcY) / 2) * height;
@@ -344,48 +420,136 @@ export function createFakeEngine(options = {}) {
return result; return result;
} }
function viewProjection(result = matrix4.create()) {
const f = 1 / Math.tan(fovy / 2);
const rows = [
[right(), f / aspect()],
[state.up, f],
];
const D = state.direction;
const P = state.position;
for (const [row, [axis, scale]] of rows.entries()) {
result[row] = axis.x * scale;
result[4 + row] = axis.y * scale;
result[8 + row] = axis.z * scale;
result[12 + row] = -cartesian3.dot(axis, P) * scale;
}
// Eye z is −direction·(p − position); clip.w = −z_eye.
const A = (FAR + NEAR) / (NEAR - FAR);
const B = (2 * FAR * NEAR) / (NEAR - FAR);
result[2] = -D.x * A;
result[6] = -D.y * A;
result[10] = -D.z * A;
result[14] = cartesian3.dot(D, P) * A + B;
result[3] = D.x;
result[7] = D.y;
result[11] = D.z;
result[15] = -cartesian3.dot(D, P);
return result;
}
// Cesium's EllipsoidalOccluder.isPointVisible.
function horizonVisible(position) {
const camera = cartesian3.create(
state.position.x / RADII.x,
state.position.y / RADII.y,
state.position.z / RADII.z,
);
const limbSquared = cartesian3.magnitudeSquared(camera) - 1;
const target = cartesian3.create(
position.x / RADII.x,
position.y / RADII.y,
position.z / RADII.z,
);
const vt = cartesian3.subtract(target, camera, cartesian3.create());
const vtDotVc = -cartesian3.dot(vt, camera);
const occluded =
limbSquared < 0
? vtDotVc > 0
: vtDotVc > limbSquared &&
(vtDotVc * vtDotVc) / cartesian3.magnitudeSquared(vt) > limbSquared;
return !occluded;
}
function ray(screen) { function ray(screen) {
const tangent = Math.tan(fovy / 2); const tangent = Math.tan(fovy / 2);
const ndcX = (2 * screen.x) / width - 1; const ndcX = (2 * screen.x) / width - 1;
const ndcY = 1 - (2 * screen.y) / height; const ndcY = 1 - (2 * screen.y) / height;
const direction = combine( const direction = sum(
{ east: right(), north: state.up, up: state.direction }, state.direction,
ndcX * tangent * (width / height), scaled(right(), ndcX * tangent * aspect()),
ndcY * tangent, scaled(state.up, ndcY * tangent),
1,
); );
return { return { origin: copy3(state.position), direction: unit(direction) };
origin: copy3(state.position),
direction: cartesian3.normalize(direction, direction),
};
} }
// Whether the sphere around the Earth is fully outside the view frustum.
function globeOutsideFrustum() {
const toCentre = cartesian3.negate(state.position, cartesian3.create());
const D = state.direction;
const tanY = Math.tan(fovy / 2);
const tanX = tanY * aspect();
const R = right();
const U = state.up;
const planes = [
[D, NEAR],
[unit(sum(scaled(R, -1), scaled(D, tanX))), 0],
[unit(sum(R, scaled(D, tanX))), 0],
[unit(sum(scaled(U, -1), scaled(D, tanY))), 0],
[unit(sum(U, scaled(D, tanY))), 0],
];
return planes.some(
([normal, offset]) =>
cartesian3.dot(normal, toCentre) - offset < -MAXIMUM_RADIUS,
);
}
// Cesium's Camera.computeViewRectangle.
function computedViewRectangle() { function computedViewRectangle() {
const corners = [ if (globeOutsideFrustum()) return undefined;
const horizon = horizonQuad(state.position);
let hits = 0;
const points = [
[0, 0], [0, 0],
[width, 0],
[0, height], [0, height],
[width, height], [width, height],
[width / 2, 0], [width, 0],
[width / 2, height], ].map(([x, y], index) => {
[0, height / 2], const hit = intersectEllipsoid(
[width, height / 2],
].map(([x, y]) =>
intersectEllipsoid(
state.position, state.position,
ray(cartesian2.create(x, y)).direction, ray(cartesian2.create(x, y)).direction,
), );
); if (hit) hits++;
if (corners.some((corner) => !corner)) return undefined; return cartographic.fromCartesian(hit ?? horizon[index], WGS84);
const points = corners.map((corner) => });
cartographic.fromCartesian(corner, WGS84), if (hits < 2)
); return {
return { west: -Math.PI,
west: Math.min(...points.map((point) => point.longitude)), south: -math.PI_OVER_TWO,
south: Math.min(...points.map((point) => point.latitude)), east: Math.PI,
east: Math.max(...points.map((point) => point.longitude)), north: math.PI_OVER_TWO,
north: Math.max(...points.map((point) => point.latitude)), };
}; const rectangle = rectangleFromCartographics(points);
let travelled = 0;
let last = points[3].longitude;
for (const { longitude } of points) {
const step = Math.abs(longitude - last);
travelled += step > Math.PI ? math.TWO_PI - step : step;
last = longitude;
}
if (math.equalsEpsilon(Math.abs(travelled), math.TWO_PI, 1e-9)) {
rectangle.west = -Math.PI;
rectangle.east = Math.PI;
if (points[0].latitude >= 0) rectangle.north = math.PI_OVER_TWO;
else rectangle.south = -math.PI_OVER_TWO;
}
return rectangle;
}
function scripted(name, args, fallback) {
if (!scripts.has(name)) return fallback();
const value = scripts.get(name);
return typeof value === 'function' ? value(...args) : value;
} }
const camera = { const camera = {
@@ -407,54 +571,84 @@ export function createFakeEngine(options = {}) {
record('camera.setView', [view]); record('camera.setView', [view]);
if (!view?.destination) if (!view?.destination)
throw new TypeError('setView needs a destination'); throw new TypeError('setView needs a destination');
setOrientation(view.destination, view.orientation); apply(viewFor(view.destination, view.orientation, CESIUM_DEFAULTS));
}, },
flyTo(flightOptions) { flyTo(flightOptions) {
record('camera.flyTo', [flightOptions]); record('camera.flyTo', [flightOptions]);
if (!flightOptions?.destination) if (!flightOptions?.destination)
throw new TypeError('flyTo needs a destination'); throw new TypeError('flyTo needs a destination');
startFlight(
flightOptions,
viewFor(
flightOptions.destination,
flightOptions.orientation,
CESIUM_DEFAULTS,
),
);
},
flyToSphere(sphere, sphereOptions = {}) {
record('camera.flyToSphere', [sphere, sphereOptions]);
setTransform(matrix4.IDENTITY);
let range = sphereOptions.range;
if (!range) {
const tanPhi = Math.tan(fovy / 2);
const tanTheta = aspect() * tanPhi;
range = Math.max(
MINIMUM_SPHERE_RANGE,
Math.max(sphere.radius / tanTheta, sphere.radius / tanPhi),
);
}
const frame = geo.transforms.eastNorthUpToFixedFrame(
sphere.center,
WGS84,
);
const saved = { const saved = {
position: state.position, position: state.position,
direction: state.direction, direction: state.direction,
up: state.up, up: state.up,
}; };
setOrientation(flightOptions.destination, flightOptions.orientation); lookAtFrame(frame, {
heading: sphereOptions.heading ?? 0,
pitch: sphereOptions.pitch ?? -Math.PI / 4,
range,
});
const target = { const target = {
position: state.position, position: state.position,
direction: state.direction, direction: state.direction,
up: state.up, up: state.up,
}; };
Object.assign(state, saved); apply(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); startFlight(sphereOptions, target);
}, },
flightActive: () => flight != null,
lookAt(target, offset) { lookAt(target, offset) {
record('camera.lookAt', [target, offset]); record('camera.lookAt', [target, offset]);
lookAtTarget = copy3(target); const frame = geo.transforms.eastNorthUpToFixedFrame(target, WGS84);
applyFlightTarget( setTransform(frame, target);
offsetView(target, offset.heading, offset.pitch, offset.range), lookAtFrame(frame, offset);
);
}, },
lookAtTransform(frame, offset) {
record('camera.lookAtTransform', [frame, offset]);
setTransform(frame, cartesian3.create(frame[12], frame[13], frame[14]));
if (offset) lookAtFrame(frame, offset);
},
transform: () => [...transform],
release() { release() {
record('camera.release', []); record('camera.release', []);
lookAtTarget = null; setTransform(matrix4.IDENTITY);
}, },
cancelFlight() { cancelFlight() {
record('camera.cancelFlight', []); record('camera.cancelFlight', []);
cancelCurrentFlight(); cancelCurrentFlight();
}, },
zoomIn(amount = DEFAULT_ZOOM_AMOUNT) {
record('camera.zoomIn', [amount]);
state.position = sum(state.position, scaled(state.direction, amount));
},
zoomOut(amount = DEFAULT_ZOOM_AMOUNT) {
record('camera.zoomOut', [amount]);
state.position = sum(state.position, scaled(state.direction, -amount));
},
viewRectangle() { viewRectangle() {
record('camera.viewRectangle', []); record('camera.viewRectangle', []);
return scripted('viewRectangle', [], computedViewRectangle); return scripted('viewRectangle', [], computedViewRectangle);
@@ -514,6 +708,8 @@ export function createFakeEngine(options = {}) {
return cartesian2.clone(value, result); return cartesian2.clone(value, result);
return value; return value;
}, },
viewProjection,
horizonVisible,
}, },
frames: { frames: {
preUpdate: events['frames.preUpdate'], preUpdate: events['frames.preUpdate'],
@@ -537,14 +733,27 @@ export function createFakeEngine(options = {}) {
return scripted('sampleHeight', [position, exclude], () => undefined); return scripted('sampleHeight', [position, exclude], () => undefined);
}, },
supported: true, supported: true,
terrainHeight(position) {
record('surface.terrainHeight', [position]);
return scripted('terrainHeight', [position], () => 0);
},
terrainHeightSupported: true,
clampToHeight(position, exclude) {
record('surface.clampToHeight', [position, exclude]);
return scripted('clampToHeight', [position, exclude], () => undefined);
},
clampToHeightSupported: true,
tilesLoaded: () => scripted('tilesLoaded', [], () => true), tilesLoaded: () => scripted('tilesLoaded', [], () => true),
}, },
input: { input: {
on(type, listener) { on(type, listener, inputOptions = {}) {
record('input.on', [type]); record('input.on', [type, inputOptions]);
const listeners = inputs.get(type); const listeners = inputs.get(type);
if (!listeners) throw new TypeError(`Unknown input type: ${type}`); if (!listeners) throw new TypeError(`Unknown input type: ${type}`);
const entry = (event) => listener(event); const modifier = inputOptions.modifier;
if (modifier !== undefined && !MODIFIERS.includes(modifier))
throw new TypeError(`Unknown input modifier: ${modifier}`);
const entry = { listener, modifier };
listeners.add(entry); listeners.add(entry);
return () => listeners.delete(entry); return () => listeners.delete(entry);
}, },
@@ -598,11 +807,12 @@ export function createFakeEngine(options = {}) {
pose.height ?? 1e7, pose.height ?? 1e7,
WGS84, WGS84,
); );
setOrientation( apply(
position, viewFor(
pose.direction position,
? { direction: pose.direction, up: pose.up } pose.direction ? { direction: pose.direction, up: pose.up } : pose,
: { heading: pose.heading, pitch: pose.pitch, roll: pose.roll }, poseAngles(),
),
); );
}, },
setSize(nextWidth, nextHeight) { setSize(nextWidth, nextHeight) {
@@ -620,11 +830,15 @@ export function createFakeEngine(options = {}) {
if (!event) throw new TypeError(`Unknown engine event: ${name}`); if (!event) throw new TypeError(`Unknown engine event: ${name}`);
event.raiseEvent(...args); event.raiseEvent(...args);
}, },
/** Delivers an input event to `engine.input.on(type)` subscribers. */ /**
input(type, event) { * Delivers input to `engine.input.on(type)` subscribers registered for
* `modifier` (none by default), as Cesium's ScreenSpaceEventHandler does.
*/
input(type, event, modifier) {
const listeners = inputs.get(type); const listeners = inputs.get(type);
if (!listeners) throw new TypeError(`Unknown input type: ${type}`); if (!listeners) throw new TypeError(`Unknown input type: ${type}`);
for (const listener of [...listeners]) listener(event); for (const entry of [...listeners])
if (entry.modifier === modifier) entry.listener(event);
}, },
/** Replaces a result with a value or a function of the call's arguments. */ /** Replaces a result with a value or a function of the call's arguments. */
script(name, valueOrFunction) { script(name, valueOrFunction) {
@@ -635,7 +849,6 @@ export function createFakeEngine(options = {}) {
unscript(name) { unscript(name) {
scripts.delete(name); scripts.delete(name);
}, },
flying: () => flight != null,
lookAtTarget: () => (lookAtTarget ? copy3(lookAtTarget) : undefined), lookAtTarget: () => (lookAtTarget ? copy3(lookAtTarget) : undefined),
/** Runs one frame: preUpdate, flight progress, preRender, camera events, postRender. */ /** Runs one frame: preUpdate, flight progress, preRender, camera events, postRender. */
frame(dt = 16) { frame(dt = 16) {
@@ -651,14 +864,18 @@ export function createFakeEngine(options = {}) {
}, },
}); });
fake.setPose({ apply(
longitude: options.longitude ?? 0, viewFor(
latitude: options.latitude ?? 0, cartesian3.fromRadians(
height: options.altitude ?? 1e7, options.longitude ?? 0,
heading: 0, options.latitude ?? 0,
pitch: -math.PI_OVER_TWO, options.altitude ?? 1e7,
roll: 0, WGS84,
}); ),
CESIUM_DEFAULTS,
CESIUM_DEFAULTS,
),
);
lastFramePose = { lastFramePose = {
position: copy3(state.position), position: copy3(state.position),
direction: copy3(state.direction), direction: copy3(state.direction),
+97 -3
View File
@@ -67,7 +67,7 @@ test('fake flights advance with frames and finish or cancel exactly once', () =>
complete: () => outcomes.push('complete'), complete: () => outcomes.push('complete'),
cancel: () => outcomes.push('cancel'), cancel: () => outcomes.push('cancel'),
}); });
assert.equal(engine.fake.flying(), true); assert.equal(engine.camera.flightActive(), true);
engine.fake.frames(30, 16); engine.fake.frames(30, 16);
assert.deepEqual(outcomes, []); assert.deepEqual(outcomes, []);
engine.fake.frames(40, 16); engine.fake.frames(40, 16);
@@ -87,9 +87,9 @@ test('fake flights advance with frames and finish or cancel exactly once', () =>
duration: 0, duration: 0,
complete: () => outcomes.push('second'), complete: () => outcomes.push('second'),
}); });
assert.deepEqual(outcomes, ['complete', 'cancel']); // Duration 0 lands and completes before flyTo returns, as in Cesium.
engine.fake.frame();
assert.deepEqual(outcomes, ['complete', 'cancel', 'second']); assert.deepEqual(outcomes, ['complete', 'cancel', 'second']);
assert.equal(engine.camera.flightActive(), false);
}); });
test('fake events can be emitted by hand and frames fire in order', () => { test('fake events can be emitted by hand and frames fire in order', () => {
@@ -184,3 +184,97 @@ test('fake credits, maps and destroy keep inspectable state', () => {
engine.destroy(); engine.destroy();
assert.equal(engine.fake.destroyed, true); assert.equal(engine.fake.destroyed, true);
}); });
test('the fake camera follows Cesium conventions for roll, angle ranges and zoom', () => {
const engine = createFakeEngine();
const destination = geo.cartesian3.fromDegrees(0, 0, 1000);
engine.camera.setView({
destination,
orientation: { heading: 0, pitch: 0, roll: 0.3 },
});
const pose = engine.camera.pose();
// Facing north at (0, 0), east is +Y: positive roll tilts up toward east.
close(pose.up.y, Math.sin(0.3), 1e-9, 'up east component');
close(pose.roll, 0.3, 1e-9, 'roll');
engine.camera.setView({
destination,
orientation: { heading: -0.5, pitch: 0, roll: -0.2 },
});
const wrapped = engine.camera.pose();
close(wrapped.heading, 2 * Math.PI - 0.5, 1e-9, 'heading in [0, 2π)');
close(wrapped.roll, 2 * Math.PI - 0.2, 1e-9, 'roll in [0, 2π)');
engine.camera.setView({
destination,
orientation: { heading: 0.4, pitch: -Math.PI / 2, roll: 0.3 },
});
assert.equal(engine.camera.pose().roll, 0);
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(0, 0, 5000),
});
const before = engine.camera.pose();
engine.camera.zoomIn(1000);
const after = engine.camera.pose();
close(
geo.cartesian3.distance(before.position, after.position),
1000,
1e-6,
'zoomIn',
);
engine.camera.zoomOut(400);
close(engine.camera.pose().cartographic.height, 5000 - 600, 1e-3, 'zoomOut');
});
test('fake lookAtTransform keeps the transform until release', () => {
const engine = createFakeEngine();
const target = geo.cartesian3.fromDegrees(10, 10, 0);
const frame = geo.transforms.eastNorthUpToFixedFrame(target);
engine.camera.lookAtTransform(frame, {
heading: 0,
pitch: -0.5,
range: 1000,
});
assert.deepEqual(engine.camera.transform(), [...frame]);
engine.camera.transform()[0] = 42;
assert.notEqual(engine.camera.transform()[0], 42);
engine.camera.release();
assert.deepEqual(engine.camera.transform(), [...geo.matrix4.IDENTITY]);
engine.camera.lookAt(target, { heading: 0, pitch: -0.5, range: 1000 });
assert.deepEqual(engine.camera.transform(), [...frame]);
assert.deepEqual(engine.fake.lookAtTarget(), target);
});
test('fake input honours modifiers like Cesium: unmodified listeners skip modified input', () => {
const engine = createFakeEngine();
const seen = [];
engine.input.on('click', () => seen.push('plain'));
engine.input.on('click', () => seen.push('ctrl'), { modifier: 'ctrl' });
engine.fake.input('click', { position: geo.cartesian2.create(1, 1) });
engine.fake.input('click', { position: geo.cartesian2.create(1, 1) }, 'ctrl');
engine.fake.input(
'click',
{ position: geo.cartesian2.create(1, 1) },
'shift',
);
assert.deepEqual(seen, ['plain', 'ctrl']);
assert.throws(
() => engine.input.on('click', () => {}, { modifier: 'meta' }),
/Unknown input modifier/,
);
});
test('fake terrain height and clamp results default to the bare ellipsoid and can be scripted', () => {
const engine = createFakeEngine();
const place = geo.cartographic.fromDegrees(1, 2, 0);
assert.equal(engine.surface.terrainHeight(place), 0);
assert.equal(
engine.surface.clampToHeight(geo.cartesian3.fromDegrees(1, 2, 50)),
undefined,
);
engine.fake.script('terrainHeight', 12);
engine.fake.script('clampToHeight', (position) => position);
assert.equal(engine.surface.terrainHeight(place), 12);
const position = geo.cartesian3.fromDegrees(1, 2, 50);
assert.equal(engine.surface.clampToHeight(position), position);
assert.equal(engine.surface.terrainHeightSupported, true);
assert.equal(engine.surface.clampToHeightSupported, true);
});
+35 -2
View File
@@ -134,12 +134,45 @@ for (const [name, files, pattern] of [
[ [
'Cesium globe engine imports Navara', 'Cesium globe engine imports Navara',
{ 'src/globe/cesium/engine.js': "import '@navaramap/three';" }, { 'src/globe/cesium/engine.js': "import '@navaramap/three';" },
/Cesium globe engine imports @navaramap\/three/, /Cesium globe engine reaches @navaramap\/three/,
], ],
[ [
'Navara globe engine imports Cesium', 'Navara globe engine imports Cesium',
{ 'src/globe/navara/engine.js': "import { Cartesian3 } from 'cesium';" }, { 'src/globe/navara/engine.js': "import { Cartesian3 } from 'cesium';" },
/Navara globe engine imports cesium/, /Navara globe engine reaches cesium/,
],
[
'Navara globe engine reaches Cesium through a helper',
{
'src/globe/navara/engine.js': "import '../../maps/helper.js';",
'src/maps/helper.js': "export * from 'cesium';",
},
/src\/maps\/helper\.js: Navara globe engine reaches cesium/,
],
[
'Navara globe engine imports the Cesium engine',
{
'src/globe/navara/engine.js': "import '../cesium/camera.js';",
'src/globe/cesium/camera.js': '',
},
/src\/globe\/navara\/engine\.js: Navara globe engine reaches src\/globe\/cesium\/camera\.js/,
],
[
'Cesium globe engine reaches three through a helper',
{
'src/globe/cesium/engine.js': "import '../../ui/helper.js';",
'src/ui/helper.js': "import 'three/addons/controls.js';",
},
/src\/ui\/helper\.js: Cesium globe engine reaches three\/addons\/controls\.js/,
],
[
'Cesium globe engine reaches the Navara engine through a helper',
{
'src/globe/cesium/engine.js': "import '../shared.js';",
'src/globe/shared.js': "import('./navara/engine.js');",
'src/globe/navara/engine.js': '',
},
/Cesium globe engine reaches src\/globe\/navara\/engine\.js/,
], ],
[ [
'browser imports Node without prefix', 'browser imports Node without prefix',