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
(`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
helpers, nor import an engine implementation; the `src/globe/cesium/**` graph
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,
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(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}`,
);
}
// Each engine graph stays on its own engine, through helpers too.
for (const start of files.filter(globeEngine)) {
const navara = start.startsWith('src/globe/navara/');
const label = navara ? 'Navara globe engine' : 'Cesium globe engine';
const foreignPackage = navara ? cesiumPackage : navaraPackage;
const foreignEngine = navara ? 'src/globe/cesium/' : 'src/globe/navara/';
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.
const voiceSeen = new Set();
+139 -41
View File
@@ -1,30 +1,44 @@
// 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.
// objects. Positions are src/geo values in Earth-fixed metres, 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 {number[]} Matrix4 16 numbers, column-major (m[column * 4 + row]).
* @typedef {(...args: any[]) => void} Listener
*
* @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
* Returns a function that removes this registration (idempotent).
* @property {(listener: Listener) => boolean} removeEventListener
*
* @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 {number} heading
* @property {number} pitch
* @property {number} roll
* @property {Cartesian3} direction
* @property {Cartesian3} up
* @property {Cartesian3} right
* @property {Cartesian3} right direction × up
* @property {number} fovy Vertical field of view.
*
* @typedef {{ heading: number, pitch: number, roll: number }
@@ -32,21 +46,49 @@
*
* @typedef {object} FlightOptions
* @property {Cartesian3} destination
* @property {Orientation} [orientation]
* @property {number} [duration] Seconds; 0 lands on the next frame.
* @property {Orientation} [orientation] Missing angles keep the current ones.
* @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 {() => void} [complete]
* @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
* @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 {(options: FlightOptions) => void} flyTo Cancels a running flight first.
* @property {(sphere: { center: Cartesian3, radius: number }, options?: SphereFlightOptions) => void} flyToSphere
* Ends unlocked (identity transform).
* @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 {(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
* 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) => { origin: Cartesian3, direction: Cartesian3 }} pickRay
* @property {EngineEvent} changed Listener receives the change fraction.
@@ -55,18 +97,51 @@
* @property {number} percentageChanged
*
* @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType
* @typedef {'ctrl'|'shift'|'alt'} InputModifier
* @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
* @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 {GlobeProjector} 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 {GlobeRender} render
* @property {GlobeSurface} surface
* @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 {{ 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.
@@ -81,26 +156,30 @@ export const INPUT_TYPES = Object.freeze([
'down',
'up',
]);
export const MODIFIERS = Object.freeze(['ctrl', 'shift', 'alt']);
export const FLIGHT_EASINGS = Object.freeze([
'cubicInOut',
'sinusoidalInOut',
'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,
* removal during dispatch is deferred safely, and listeners added during
* dispatch run from the next raise. `raiseEvent` is for engine implementations.
* An event with the contract's semantics (see EngineEvent). `raiseEvent` is
* for engine implementations; a listener that throws propagates, and the
* event stays usable.
*/
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 active = (listener) =>
entries.find((entry) => entry.listener === listener);
const removeEntry = (entry) => {
if (!entry.listener) return false;
entry.listener = null;
@@ -108,7 +187,7 @@ export function createEngineEvent() {
else entries.splice(entries.indexOf(entry), 1);
return true;
};
return {
const event = {
get numberOfListeners() {
return entries.reduce(
(count, entry) => count + (entry.listener ? 1 : 0),
@@ -118,16 +197,17 @@ export function createEngineEvent() {
addEventListener(listener) {
if (typeof listener !== 'function')
throw new TypeError('Engine event listener must be a function');
const entry = { listener };
entries.push(entry);
let entry = active(listener);
if (!entry) {
entry = { listener };
entries.push(entry);
}
return () => {
removeEntry(entry);
};
},
removeEventListener(listener) {
const entry = entries.find(
(candidate) => candidate.listener === listener,
);
const entry = active(listener);
return entry ? removeEntry(entry) : false;
},
raiseEvent(...args) {
@@ -141,19 +221,20 @@ export function createEngineEvent() {
}
} finally {
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 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);
@@ -165,15 +246,20 @@ const SHAPE = {
setView: isFunction,
flyTo: isFunction,
flyToSphere: isFunction,
flightActive: isFunction,
lookAt: isFunction,
lookAtTransform: isFunction,
transform: isFunction,
release: isFunction,
cancelFlight: isFunction,
zoomIn: isFunction,
zoomOut: isFunction,
viewRectangle: isFunction,
pickEllipsoid: isFunction,
pickRay: isFunction,
changed: isEvent,
moveStart: isEvent,
moveEnd: isEvent,
changed: isEngineEvent,
moveStart: isEngineEvent,
moveEnd: isEngineEvent,
percentageChanged: isNumber,
},
picker: {
@@ -182,8 +268,16 @@ const SHAPE = {
pickPosition: isFunction,
pickPositionSupported: isBoolean,
},
projector: { worldToWindow: isFunction },
frames: { preUpdate: isEvent, preRender: isEvent, postRender: isEvent },
projector: {
worldToWindow: isFunction,
viewProjection: isFunction,
horizonVisible: isFunction,
},
frames: {
preUpdate: isEngineEvent,
preRender: isEngineEvent,
postRender: isEngineEvent,
},
render: {
request: isFunction,
setContinuous: isFunction,
@@ -192,6 +286,10 @@ const SHAPE = {
surface: {
sampleHeight: isFunction,
supported: isBoolean,
terrainHeight: isFunction,
terrainHeightSupported: isBoolean,
clampToHeight: isFunction,
clampToHeightSupported: isBoolean,
tilesLoaded: isFunction,
},
input: { on: isFunction, setCameraInputs: isFunction },
+77 -9
View File
@@ -3,8 +3,10 @@ import test from 'node:test';
import {
ENGINE_KINDS,
INPUT_TYPES,
MODIFIERS,
assertGlobeEngine,
createEngineEvent,
isEngineEvent,
} from './contract.js';
import { createFakeEngine } from './testing/fakeEngine.js';
import {
@@ -44,7 +46,7 @@ test('engine event passes every argument and rejects non-functions', () => {
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 seen = [];
let removeLater;
@@ -76,23 +78,60 @@ test('listeners added during dispatch run from the next raise', () => {
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();
let count = 0;
const listener = () => count++;
const first = event.addEventListener(listener);
first();
first();
const second = event.addEventListener(listener);
const third = event.addEventListener(listener);
assert.equal(event.numberOfListeners, 1);
event.raiseEvent();
assert.equal(count, 2);
assert.equal(count, 1);
second();
second();
first();
event.raiseEvent();
assert.equal(count, 3);
third();
assert.equal(count, 1);
assert.equal(event.numberOfListeners, 0);
const again = event.addEventListener(listener);
first();
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', () => {
@@ -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(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.
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;
class ContractError extends Error {
@@ -42,6 +42,23 @@ const isCartesian3 = (value) =>
Number.isFinite(value.y) &&
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 along = (origin, direction, distance) =>
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 () => {
await nadirView();
const destination = cartesian3.fromDegrees(-3.7, 40.4, 50000);
@@ -325,8 +342,11 @@ export function createContractSuite(
complete: () => outcome.push('complete'),
cancel: () => outcome.push('cancel'),
});
await until(() => outcome.length > 0, 'flight completion');
ensure(outcome.join() === 'complete', `flight ended with ${outcome}`);
ensure(
outcome.join() === 'complete',
`flyTo returned with [${outcome}] instead of [complete]`,
);
ensure(!camera.flightActive(), 'a duration-0 flight is still active');
const pose = camera.pose();
near(
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 () => {
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(
typeof engine.surface.supported === 'boolean',
'supported is not boolean',
@@ -619,13 +661,15 @@ export function createContractSuite(
'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();
for (const modifier of [undefined, ...MODIFIERS]) {
const off = engine.input.on(type, () => {}, { modifier });
ensure(
typeof off === 'function',
`input.on('${type}', ${modifier}) returned no remover`,
);
off();
off();
}
}
engine.input.setCameraInputs(false);
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 }));
}
+418 -201
View File
@@ -3,12 +3,29 @@
// 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.
//
// 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 { INPUT_TYPES, createEngineEvent } from '../contract.js';
import { INPUT_TYPES, MODIFIERS, createEngineEvent } from '../contract.js';
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
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([
'pick',
'drillPick',
@@ -16,6 +33,8 @@ const SCRIPTABLE = new Set([
'pickEllipsoid',
'viewRectangle',
'sampleHeight',
'terrainHeight',
'clampToHeight',
'tilesLoaded',
'worldToWindow',
]);
@@ -27,11 +46,26 @@ const EASINGS = {
};
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.
function localAxes(position) {
const frame = matrix4.create();
geo.transforms.eastNorthUpToFixedFrame(position, WGS84, frame);
const frame = geo.transforms.eastNorthUpToFixedFrame(position, WGS84);
return {
east: cartesian3.create(frame[0], frame[1], frame[2]),
north: cartesian3.create(frame[4], frame[5], frame[6]),
@@ -40,14 +74,16 @@ function localAxes(position) {
}
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,
);
sum(scaled(axes.east, e), scaled(axes.north, n), scaled(axes.up, u));
const toLocal = (axes, vector) => ({
e: cartesian3.dot(vector, axes.east),
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.
// Heading 0 looks north, positive pitch looks up, as in Cesium.
// Direction and up for a heading/pitch/roll in the local frame at position,
// 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) {
const axes = localAxes(position);
const cosPitch = Math.cos(pitch);
@@ -58,54 +94,50 @@ function orientationVectors(position, heading, pitch, roll) {
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,
const levelUp = cross(levelRight, direction);
const up = sum(
scaled(levelUp, Math.cos(roll)),
scaled(levelRight, Math.sin(roll)),
);
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) {
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 d = toLocal(axes, direction);
const u = toLocal(axes, up);
const r = toLocal(axes, cross(direction, up));
const vertical = Math.abs(Math.abs(d.u) - 1) <= 1e-3;
const raw = vertical
? Math.atan2(u.n, u.e) - math.PI_OVER_TWO
: Math.atan2(d.n, d.e) - math.PI_OVER_TWO;
return {
heading: wrapAngle(math.TWO_PI - math.zeroToTwoPi(raw)),
pitch: math.PI_OVER_TWO - Math.acos(math.clamp(d.u, -1, 1)),
roll: vertical ? 0 : wrapAngle(Math.atan2(-r.u, u.u)),
};
}
// Camera offset from a target for a HeadingPitchRange, in the target's frame.
function offsetFromHeadingPitchRange(heading, pitch, range) {
const p = math.clamp(pitch, -math.PI_OVER_TWO, math.PI_OVER_TWO);
const cosPitch = Math.cos(p);
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.
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 o = [origin.x / RADII.x, origin.y / RADII.y, origin.z / RADII.z];
const d = [
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 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;
@@ -115,11 +147,60 @@ function intersectEllipsoid(origin, direction) {
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(),
return sum(origin, scaled(direction, t));
}
// 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 scripts = new Map();
let transform = [...matrix4.IDENTITY];
let percentageChanged = 0.5;
let continuous = false;
let flight = null;
@@ -172,85 +254,63 @@ export function createFakeEngine(options = {}) {
};
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 aspect = () => width / height;
const right = () => unit(cross(state.direction, state.up));
const poseAngles = () =>
headingPitchRoll(state.position, state.direction, state.up);
// The pose for a destination and orientation; missing angles use `fallback`.
function viewFor(position, orientation, fallback) {
if (orientation && 'direction' in orientation)
return {
position: copy3(position),
direction: unit(orientation.direction),
up: unit(orientation.up),
};
const vectors = orientationVectors(
state.position,
orientation?.heading ?? current.heading,
orientation?.pitch ?? current.pitch,
orientation?.roll ?? current.roll,
position,
orientation?.heading ?? fallback.heading,
orientation?.pitch ?? fallback.pitch,
orientation?.roll ?? fallback.roll,
);
state.direction = vectors.direction;
state.up = vectors.up;
return { position: copy3(position), ...vectors };
}
// 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() {
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 setTransform(next, target) {
transform = [...next];
lookAtTarget = target ? copy3(target) : null;
}
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 lookAtFrame(frame, offset) {
const local =
offset && 'heading' in offset
? offsetFromHeadingPitchRange(
offset.heading,
offset.pitch,
offset.range,
)
: copy3(offset);
const localDirection = unit(cartesian3.negate(local, cartesian3.create()));
let localRight = cross(localDirection, cartesian3.UNIT_Z);
if (cartesian3.magnitudeSquared(localRight) < math.EPSILON10)
localRight = copy3(cartesian3.UNIT_X);
localRight = unit(localRight);
const localUp = unit(cross(localRight, localDirection));
const vector = (value) =>
unit(matrix4.multiplyByPointAsVector(frame, value, cartesian3.create()));
apply({
position: matrix4.multiplyByPoint(frame, local, cartesian3.create()),
direction: vector(localDirection),
up: vector(localUp),
});
}
function cancelCurrentFlight() {
@@ -259,12 +319,29 @@ export function createFakeEngine(options = {}) {
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) {
if (!flight) return;
flight.elapsed += dt;
const t = flight.duration
? Math.min(1, flight.elapsed / flight.duration)
: 1;
const t = Math.min(1, flight.elapsed / flight.duration);
if (t < 1) {
cartesian3.lerp(
flight.start,
@@ -276,7 +353,7 @@ export function createFakeEngine(options = {}) {
}
const current = flight;
flight = null;
applyFlightTarget(current.target);
apply(current.target);
current.complete?.();
}
@@ -333,8 +410,7 @@ export function createFakeEngine(options = {}) {
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 ndcX = cartesian3.dot(offset, right()) / (depth * tangent * aspect());
const ndcY = cartesian3.dot(offset, state.up) / (depth * tangent);
const x = ((ndcX + 1) / 2) * width;
const y = ((1 - ndcY) / 2) * height;
@@ -344,48 +420,136 @@ export function createFakeEngine(options = {}) {
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) {
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,
const direction = sum(
state.direction,
scaled(right(), ndcX * tangent * aspect()),
scaled(state.up, ndcY * tangent),
);
return {
origin: copy3(state.position),
direction: cartesian3.normalize(direction, direction),
};
return { origin: copy3(state.position), direction: unit(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() {
const corners = [
if (globeOutsideFrustum()) return undefined;
const horizon = horizonQuad(state.position);
let hits = 0;
const points = [
[0, 0],
[width, 0],
[0, height],
[width, height],
[width / 2, 0],
[width / 2, height],
[0, height / 2],
[width, height / 2],
].map(([x, y]) =>
intersectEllipsoid(
[width, 0],
].map(([x, y], index) => {
const hit = 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)),
};
);
if (hit) hits++;
return cartographic.fromCartesian(hit ?? horizon[index], WGS84);
});
if (hits < 2)
return {
west: -Math.PI,
south: -math.PI_OVER_TWO,
east: Math.PI,
north: math.PI_OVER_TWO,
};
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 = {
@@ -407,54 +571,84 @@ export function createFakeEngine(options = {}) {
record('camera.setView', [view]);
if (!view?.destination)
throw new TypeError('setView needs a destination');
setOrientation(view.destination, view.orientation);
apply(viewFor(view.destination, view.orientation, CESIUM_DEFAULTS));
},
flyTo(flightOptions) {
record('camera.flyTo', [flightOptions]);
if (!flightOptions?.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 = {
position: state.position,
direction: state.direction,
up: state.up,
};
setOrientation(flightOptions.destination, flightOptions.orientation);
lookAtFrame(frame, {
heading: sphereOptions.heading ?? 0,
pitch: sphereOptions.pitch ?? -Math.PI / 4,
range,
});
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,
);
apply(saved);
startFlight(sphereOptions, target);
},
flightActive: () => flight != null,
lookAt(target, offset) {
record('camera.lookAt', [target, offset]);
lookAtTarget = copy3(target);
applyFlightTarget(
offsetView(target, offset.heading, offset.pitch, offset.range),
);
const frame = geo.transforms.eastNorthUpToFixedFrame(target, WGS84);
setTransform(frame, target);
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() {
record('camera.release', []);
lookAtTarget = null;
setTransform(matrix4.IDENTITY);
},
cancelFlight() {
record('camera.cancelFlight', []);
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() {
record('camera.viewRectangle', []);
return scripted('viewRectangle', [], computedViewRectangle);
@@ -514,6 +708,8 @@ export function createFakeEngine(options = {}) {
return cartesian2.clone(value, result);
return value;
},
viewProjection,
horizonVisible,
},
frames: {
preUpdate: events['frames.preUpdate'],
@@ -537,14 +733,27 @@ export function createFakeEngine(options = {}) {
return scripted('sampleHeight', [position, exclude], () => undefined);
},
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),
},
input: {
on(type, listener) {
record('input.on', [type]);
on(type, listener, inputOptions = {}) {
record('input.on', [type, inputOptions]);
const listeners = inputs.get(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);
return () => listeners.delete(entry);
},
@@ -598,11 +807,12 @@ export function createFakeEngine(options = {}) {
pose.height ?? 1e7,
WGS84,
);
setOrientation(
position,
pose.direction
? { direction: pose.direction, up: pose.up }
: { heading: pose.heading, pitch: pose.pitch, roll: pose.roll },
apply(
viewFor(
position,
pose.direction ? { direction: pose.direction, up: pose.up } : pose,
poseAngles(),
),
);
},
setSize(nextWidth, nextHeight) {
@@ -620,11 +830,15 @@ export function createFakeEngine(options = {}) {
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) {
/**
* 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);
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. */
script(name, valueOrFunction) {
@@ -635,7 +849,6 @@ export function createFakeEngine(options = {}) {
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) {
@@ -651,14 +864,18 @@ export function createFakeEngine(options = {}) {
},
});
fake.setPose({
longitude: options.longitude ?? 0,
latitude: options.latitude ?? 0,
height: options.altitude ?? 1e7,
heading: 0,
pitch: -math.PI_OVER_TWO,
roll: 0,
});
apply(
viewFor(
cartesian3.fromRadians(
options.longitude ?? 0,
options.latitude ?? 0,
options.altitude ?? 1e7,
WGS84,
),
CESIUM_DEFAULTS,
CESIUM_DEFAULTS,
),
);
lastFramePose = {
position: copy3(state.position),
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'),
cancel: () => outcomes.push('cancel'),
});
assert.equal(engine.fake.flying(), true);
assert.equal(engine.camera.flightActive(), true);
engine.fake.frames(30, 16);
assert.deepEqual(outcomes, []);
engine.fake.frames(40, 16);
@@ -87,9 +87,9 @@ test('fake flights advance with frames and finish or cancel exactly once', () =>
duration: 0,
complete: () => outcomes.push('second'),
});
assert.deepEqual(outcomes, ['complete', 'cancel']);
engine.fake.frame();
// Duration 0 lands and completes before flyTo returns, as in Cesium.
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', () => {
@@ -184,3 +184,97 @@ test('fake credits, maps and destroy keep inspectable state', () => {
engine.destroy();
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',
{ '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',
{ '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',