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