mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
528ec75f32
commit
708c96b6f6
8 files changed
+1300
-280
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user